diff --git a/api/openapi/helm.openapi.yaml b/api/openapi/helm.openapi.yaml index 094c3d09e..aa01a082e 100644 --- a/api/openapi/helm.openapi.yaml +++ b/api/openapi/helm.openapi.yaml @@ -3647,7 +3647,7 @@ paths: tags: [identity] summary: Create an approval ceremony security: - - AdminBearerAuth: [] + - ServiceBearerAuth: [] requestBody: content: application/json: @@ -6147,6 +6147,7 @@ components: timelock_until: { type: string, format: date-time } expires_at: { type: string, format: date-time } break_glass: { type: boolean } + binding_hash: { type: string } reason: { type: string } receipt_id: { type: string } ceremony_hash: { type: string } diff --git a/core/cmd/helm-ai-kernel/boundary_surface_cmd.go b/core/cmd/helm-ai-kernel/boundary_surface_cmd.go index 2ab4542f4..7831cd6e4 100644 --- a/core/cmd/helm-ai-kernel/boundary_surface_cmd.go +++ b/core/cmd/helm-ai-kernel/boundary_surface_cmd.go @@ -449,6 +449,11 @@ func runApprovalsCreate(args []string, registry *boundarypkg.SurfaceRegistry, st fmt.Fprintln(stderr, "Error: --subject and --action are required") return 2 } + approvalID, err := contracts.NewSurfaceID("approval") + if err != nil { + fmt.Fprintf(stderr, "Error: %v\n", err) + return 1 + } now := time.Now().UTC() var timelock time.Time if *timelockMs > 0 { @@ -459,7 +464,7 @@ func runApprovalsCreate(args []string, registry *boundarypkg.SurfaceRegistry, st expiresAt = now.Add(time.Duration(*expiresInMs) * time.Millisecond) } approval, err := registry.PutApproval(contracts.ApprovalCeremony{ - ApprovalID: contracts.SurfaceID("approval", *subject+"-"+*action), + ApprovalID: approvalID, Subject: *subject, Action: *action, State: contracts.ApprovalCeremonyPending, diff --git a/core/cmd/helm-ai-kernel/contract_routes.go b/core/cmd/helm-ai-kernel/contract_routes.go index ba0227f3d..a061fd568 100644 --- a/core/cmd/helm-ai-kernel/contract_routes.go +++ b/core/cmd/helm-ai-kernel/contract_routes.go @@ -25,6 +25,7 @@ import ( mcppkg "github.com/Mindburn-Labs/helm-ai-kernel/core/pkg/mcp" helmotel "github.com/Mindburn-Labs/helm-ai-kernel/core/pkg/otel" runtimesandbox "github.com/Mindburn-Labs/helm-ai-kernel/core/pkg/runtime/sandbox" + "github.com/Mindburn-Labs/helm-ai-kernel/core/pkg/workstation" ) const ( @@ -1130,7 +1131,7 @@ func registerContractRoutes(mux *http.ServeMux, svc *Services) { writeContractJSON(w, http.StatusOK, snapshot) })) - mux.HandleFunc("/api/v1/approvals", protectRuntimeHandler(RouteAuthAdmin, func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("/api/v1/approvals", protectApprovalCollectionHandler(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: writeContractJSON(w, http.StatusOK, surfaces.ListApprovals()) @@ -1142,6 +1143,7 @@ func registerContractRoutes(mux *http.ServeMux, svc *Services) { RequestedBy string `json:"requested_by"` Approvers []string `json:"approvers"` Quorum int `json:"quorum"` + BindingHash string `json:"binding_hash"` TimelockMs int64 `json:"timelock_ms"` ExpiresInMs int64 `json:"expires_in_ms"` Reason string `json:"reason"` @@ -1153,7 +1155,12 @@ func registerContractRoutes(mux *http.ServeMux, svc *Services) { return } if req.ApprovalID == "" { - req.ApprovalID = contracts.SurfaceID("approval", req.Subject+"-"+req.Action) + var err error + req.ApprovalID, err = contracts.NewSurfaceID("approval") + if err != nil { + api.WriteInternal(w, err) + return + } } now := time.Now().UTC() var timelock time.Time @@ -1172,6 +1179,7 @@ func registerContractRoutes(mux *http.ServeMux, svc *Services) { RequestedBy: req.RequestedBy, Approvers: req.Approvers, Quorum: req.Quorum, + BindingHash: req.BindingHash, TimelockUntil: timelock, ExpiresAt: expires, BreakGlass: req.BreakGlass, @@ -1190,7 +1198,7 @@ func registerContractRoutes(mux *http.ServeMux, svc *Services) { } })) - mux.HandleFunc("/api/v1/approvals/", protectRuntimeHandler(RouteAuthAdmin, func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("/api/v1/approvals/", protectApprovalItemHandler(func(w http.ResponseWriter, r *http.Request) { suffix := strings.TrimPrefix(r.URL.Path, "/api/v1/approvals/") approvalID, action, ok := strings.Cut(suffix, "/") if !ok || approvalID == "" { @@ -1201,6 +1209,51 @@ func registerContractRoutes(mux *http.ServeMux, svc *Services) { api.WriteMethodNotAllowed(w) return } + if action == "consume" { + var req struct { + BindingHash string `json:"binding_hash"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.BindingHash) == "" { + api.WriteBadRequest(w, "binding_hash is required") + return + } + var matched *contracts.ApprovalCeremony + for _, approval := range surfaces.ListApprovals() { + if approval.ApprovalID == approvalID { + copy := approval + matched = © + break + } + } + if matched == nil { + api.WriteNotFound(w, "approval not found") + return + } + if matched.State != contracts.ApprovalCeremonyAllowed || + matched.Subject != workstation.ShellGateApprovalSubject || + matched.Action != workstation.ShellGateApprovalAction || + matched.BindingHash != req.BindingHash { + api.WriteBadRequest(w, "approval is not an approved shell command with this binding") + return + } + if !matched.ExpiresAt.IsZero() && !time.Now().Before(matched.ExpiresAt) { + api.WriteBadRequest(w, "approval is expired") + return + } + approval, err := surfaces.TransitionApproval( + approvalID, + contracts.ApprovalCeremonyRevoked, + servicePrincipalID, + "", + "consumed by workstation shell gate", + ) + if err != nil { + api.WriteBadRequest(w, err.Error()) + return + } + writeContractJSON(w, http.StatusOK, approval) + return + } if action == "webauthn/challenge" { var req struct { Method string `json:"method"` diff --git a/core/cmd/helm-ai-kernel/contract_routes_test.go b/core/cmd/helm-ai-kernel/contract_routes_test.go index 21ef8cfca..2d6c486cb 100644 --- a/core/cmd/helm-ai-kernel/contract_routes_test.go +++ b/core/cmd/helm-ai-kernel/contract_routes_test.go @@ -10,6 +10,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "os" "sort" "strings" "testing" @@ -356,7 +357,7 @@ func TestApprovalRoutesSupportWebAuthnChallengeAssertion(t *testing.T) { registerContractRoutes(mux, svc) createReq := httptest.NewRequest(http.MethodPost, "/api/v1/approvals", strings.NewReader(`{"approval_id":"approval-webauthn","subject":"mcp:srv","action":"mcp.approve","requested_by":"agent:test","quorum":1}`)) - authorizeTestRequest(createReq) + authorizeServiceTestRequest(createReq) createRec := httptest.NewRecorder() mux.ServeHTTP(createRec, createReq) if createRec.Code != http.StatusCreated { @@ -395,6 +396,88 @@ func TestApprovalRoutesSupportWebAuthnChallengeAssertion(t *testing.T) { } } +func TestApprovalRoutesSplitRequestApprovalAndConsumptionAuthority(t *testing.T) { + svc, cleanup := newContractRouteTestServices(t) + defer cleanup() + mux := http.NewServeMux() + registerContractRoutes(mux, svc) + + payload := `{"subject":"shell_command","action":"shell_operate","requested_by":"agent.local","quorum":1,"binding_hash":"sha256:exact-command","reason":"shellgate-binding=sha256:exact-command"}` + adminCreate := httptest.NewRequest(http.MethodPost, approvalAPIBasePath, strings.NewReader(payload)) + authorizeTestRequest(adminCreate) + adminCreateRec := httptest.NewRecorder() + mux.ServeHTTP(adminCreateRec, adminCreate) + if adminCreateRec.Code != http.StatusUnauthorized { + t.Fatalf("admin credential created requester ceremony: status=%d body=%s", adminCreateRec.Code, adminCreateRec.Body.String()) + } + + ids := make([]string, 0, 2) + for i := 0; i < 2; i++ { + req := httptest.NewRequest(http.MethodPost, approvalAPIBasePath, strings.NewReader(payload)) + authorizeServiceTestRequest(req) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("service create %d status=%d body=%s", i, rec.Code, rec.Body.String()) + } + var approval contracts.ApprovalCeremony + if err := json.NewDecoder(rec.Body).Decode(&approval); err != nil { + t.Fatal(err) + } + ids = append(ids, approval.ApprovalID) + } + if ids[0] == ids[1] { + t.Fatalf("missing approval ids collided: %q", ids[0]) + } + duplicatePayload := strings.Replace(payload, `"subject":"shell_command"`, `"approval_id":"`+ids[0]+`","subject":"shell_command"`, 1) + duplicate := httptest.NewRequest(http.MethodPost, approvalAPIBasePath, strings.NewReader(duplicatePayload)) + authorizeServiceTestRequest(duplicate) + duplicateRec := httptest.NewRecorder() + mux.ServeHTTP(duplicateRec, duplicate) + if duplicateRec.Code != http.StatusBadRequest { + t.Fatalf("explicit duplicate overwrote ceremony: status=%d body=%s", duplicateRec.Code, duplicateRec.Body.String()) + } + + serviceApprove := httptest.NewRequest(http.MethodPost, approvalAPIBasePath+"/"+ids[0]+"/approve", strings.NewReader(`{"actor":"operator.cli"}`)) + authorizeServiceTestRequest(serviceApprove) + serviceApproveRec := httptest.NewRecorder() + mux.ServeHTTP(serviceApproveRec, serviceApprove) + if serviceApproveRec.Code != http.StatusUnauthorized { + t.Fatalf("request credential approved ceremony: status=%d body=%s", serviceApproveRec.Code, serviceApproveRec.Body.String()) + } + + adminApprove := httptest.NewRequest(http.MethodPost, approvalAPIBasePath+"/"+ids[0]+"/approve", strings.NewReader(`{"actor":"operator.cli"}`)) + authorizeTestRequest(adminApprove) + adminApproveRec := httptest.NewRecorder() + mux.ServeHTTP(adminApproveRec, adminApprove) + if adminApproveRec.Code != http.StatusOK { + t.Fatalf("admin approve status=%d body=%s", adminApproveRec.Code, adminApproveRec.Body.String()) + } + + wrongConsume := httptest.NewRequest(http.MethodPost, approvalAPIBasePath+"/"+ids[0]+"/consume", strings.NewReader(`{"binding_hash":"sha256:other-command"}`)) + authorizeServiceTestRequest(wrongConsume) + wrongConsumeRec := httptest.NewRecorder() + mux.ServeHTTP(wrongConsumeRec, wrongConsume) + if wrongConsumeRec.Code != http.StatusBadRequest { + t.Fatalf("wrong binding consume status=%d body=%s", wrongConsumeRec.Code, wrongConsumeRec.Body.String()) + } + + consume := httptest.NewRequest(http.MethodPost, approvalAPIBasePath+"/"+ids[0]+"/consume", strings.NewReader(`{"binding_hash":"sha256:exact-command"}`)) + authorizeServiceTestRequest(consume) + consumeRec := httptest.NewRecorder() + mux.ServeHTTP(consumeRec, consume) + if consumeRec.Code != http.StatusOK { + t.Fatalf("exact binding consume status=%d body=%s", consumeRec.Code, consumeRec.Body.String()) + } + replayConsume := httptest.NewRequest(http.MethodPost, approvalAPIBasePath+"/"+ids[0]+"/consume", strings.NewReader(`{"binding_hash":"sha256:exact-command"}`)) + authorizeServiceTestRequest(replayConsume) + replayConsumeRec := httptest.NewRecorder() + mux.ServeHTTP(replayConsumeRec, replayConsume) + if replayConsumeRec.Code != http.StatusBadRequest { + t.Fatalf("approval consumed twice: status=%d body=%s", replayConsumeRec.Code, replayConsumeRec.Body.String()) + } +} + func TestReplayVerifyDetectsReceiptChainBreakWithValidManifest(t *testing.T) { svc, cleanup := newContractRouteTestServices(t) defer cleanup() @@ -524,6 +607,7 @@ func TestReceiptListReturnsCursorPagination(t *testing.T) { func newContractRouteTestServices(t *testing.T) (*Services, func()) { t.Helper() t.Setenv("HELM_ADMIN_API_KEY", testAdminAPIKey) + t.Setenv(serviceAPIKeyEnv, testAdminAPIKey+"-service") db, err := sql.Open("sqlite", ":memory:") if err != nil { t.Fatal(err) @@ -593,6 +677,10 @@ func authorizeTestRequest(req *http.Request) { req.Header.Set(principalHeader, "system-admin") } +func authorizeServiceTestRequest(req *http.Request) { + req.Header.Set("Authorization", "Bearer "+os.Getenv(serviceAPIKeyEnv)) +} + type overflowReceiptStore struct { captureReceiptStore } diff --git a/core/cmd/helm-ai-kernel/route_auth.go b/core/cmd/helm-ai-kernel/route_auth.go index 6ff042469..c3e82be16 100644 --- a/core/cmd/helm-ai-kernel/route_auth.go +++ b/core/cmd/helm-ai-kernel/route_auth.go @@ -57,6 +57,30 @@ func protectRuntimeHandler(auth RouteAuth, handler http.HandlerFunc) http.Handle } } +func protectApprovalCollectionHandler(handler http.HandlerFunc) http.HandlerFunc { + admin := requireRuntimeAdmin(handler) + service := requireRuntimeService(handler) + return func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + service(w, r) + return + } + admin(w, r) + } +} + +func protectApprovalItemHandler(handler http.HandlerFunc) http.HandlerFunc { + admin := requireRuntimeAdmin(handler) + service := requireRuntimeService(handler) + return func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/consume") { + service(w, r) + return + } + admin(w, r) + } +} + func requireRuntimeAdmin(handler http.HandlerFunc) http.HandlerFunc { adminKey := os.Getenv(helmauth.AdminAPIKeyEnv) return func(w http.ResponseWriter, r *http.Request) { diff --git a/core/cmd/helm-ai-kernel/route_registry.go b/core/cmd/helm-ai-kernel/route_registry.go index 0d8644ddd..38f4dbe62 100644 --- a/core/cmd/helm-ai-kernel/route_registry.go +++ b/core/cmd/helm-ai-kernel/route_registry.go @@ -190,7 +190,8 @@ func RuntimeRouteSpecs() []RuntimeRouteSpec { {Method: http.MethodGet, Path: "/api/v1/authz/snapshots", MuxPattern: "/api/v1/authz/snapshots", Auth: RouteAuthAdmin, RateLimit: RouteRateAdmin, ContractStatus: RouteContractPublic, OperationID: "listAuthzSnapshots", Owner: "core/cmd/helm-ai-kernel"}, {Method: http.MethodGet, Path: "/api/v1/authz/snapshots/{snapshot_id}", MuxPattern: "/api/v1/authz/snapshots/", Auth: RouteAuthAdmin, RateLimit: RouteRateAdmin, ContractStatus: RouteContractPublic, OperationID: "getAuthzSnapshot", Owner: "core/cmd/helm-ai-kernel"}, {Method: http.MethodGet, Path: "/api/v1/approvals", MuxPattern: "/api/v1/approvals", Auth: RouteAuthAdmin, RateLimit: RouteRateAdmin, ContractStatus: RouteContractPublic, OperationID: "listApprovalCeremonies", Owner: "core/cmd/helm-ai-kernel"}, - {Method: http.MethodPost, Path: "/api/v1/approvals", MuxPattern: "/api/v1/approvals", Auth: RouteAuthAdmin, RateLimit: RouteRateAdmin, ContractStatus: RouteContractPublic, OperationID: "createApprovalCeremony", Owner: "core/cmd/helm-ai-kernel"}, + {Method: http.MethodPost, Path: "/api/v1/approvals", MuxPattern: "/api/v1/approvals", Auth: RouteAuthService, RateLimit: RouteRateAdmin, ContractStatus: RouteContractPublic, OperationID: "createApprovalCeremony", Owner: "core/cmd/helm-ai-kernel"}, + {Method: http.MethodPost, Path: "/api/v1/approvals/{approval_id}/consume", MuxPattern: "/api/v1/approvals/", Auth: RouteAuthService, RateLimit: RouteRateAdmin, ContractStatus: RouteContractInternal, OperationID: "consumeShellApprovalCeremony", Owner: "core/cmd/helm-ai-kernel"}, {Method: http.MethodPost, Path: "/api/v1/approvals/{approval_id}/webauthn/challenge", MuxPattern: "/api/v1/approvals/", Auth: RouteAuthAdmin, RateLimit: RouteRateAdmin, ContractStatus: RouteContractPublic, OperationID: "createApprovalWebAuthnChallenge", Owner: "core/cmd/helm-ai-kernel"}, {Method: http.MethodPost, Path: "/api/v1/approvals/{approval_id}/webauthn/assert", MuxPattern: "/api/v1/approvals/", Auth: RouteAuthAdmin, RateLimit: RouteRateAdmin, ContractStatus: RouteContractPublic, OperationID: "assertApprovalWebAuthnChallenge", Owner: "core/cmd/helm-ai-kernel"}, {Method: http.MethodPost, Path: "/api/v1/approvals/{approval_id}/{action}", MuxPattern: "/api/v1/approvals/", Auth: RouteAuthAdmin, RateLimit: RouteRateAdmin, ContractStatus: RouteContractPublic, OperationID: "transitionApprovalCeremony", Owner: "core/cmd/helm-ai-kernel"}, diff --git a/core/cmd/helm-ai-kernel/watch_client.go b/core/cmd/helm-ai-kernel/watch_client.go new file mode 100644 index 000000000..f1b0ea2da --- /dev/null +++ b/core/cmd/helm-ai-kernel/watch_client.go @@ -0,0 +1,183 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/Mindburn-Labs/helm-ai-kernel/core/pkg/contracts" +) + +// approvalClient is the client surface of the kernel approval API consumed by +// `watch` and `workstation gate --request-approval`. Pending items always +// derive from server state; implementations must fail closed on transport and +// status errors. +type approvalClient interface { + ListApprovals(ctx context.Context) ([]contracts.ApprovalCeremony, error) + TransitionApproval(ctx context.Context, approvalID, action, actor, reason string) (contracts.ApprovalCeremony, error) + CreateApproval(ctx context.Context, req createApprovalRequest) (contracts.ApprovalCeremony, error) +} + +// createApprovalRequest mirrors the POST /api/v1/approvals payload in +// contract_routes.go. +type createApprovalRequest struct { + ApprovalID string `json:"approval_id,omitempty"` + Subject string `json:"subject"` + Action string `json:"action"` + RequestedBy string `json:"requested_by"` + Approvers []string `json:"approvers,omitempty"` + Quorum int `json:"quorum,omitempty"` + BindingHash string `json:"binding_hash,omitempty"` + Reason string `json:"reason,omitempty"` + ReceiptID string `json:"receipt_id,omitempty"` +} + +const approvalAPIBasePath = "/api/v1/approvals" + +var errApprovalAPIKeyMissing = errors.New("admin API key is required (set HELM_ADMIN_API_KEY or --api-key-file)") + +// approvalHTTPClient talks to the kernel server approval routes with the +// standalone admin API key (Authorization: Bearer). +type approvalHTTPClient struct { + baseURL *url.URL + apiKey string + httpClient *http.Client +} + +func newApprovalHTTPClient(rawURL, apiKey string) (*approvalHTTPClient, error) { + base := strings.TrimSpace(rawURL) + if base == "" { + return nil, errors.New("server URL is required") + } + parsed, err := url.Parse(base) + if err != nil { + return nil, fmt.Errorf("parse server URL: %w", err) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return nil, fmt.Errorf("server URL must be http or https: %q", base) + } + if parsed.Host == "" { + return nil, fmt.Errorf("server URL must include a host: %q", base) + } + // The client sends the admin bearer key on every request, so plain HTTP + // is only acceptable on loopback where the key cannot leave the machine. + // Anything else must use HTTPS — fail closed. + if parsed.Scheme == "http" && !isLoopbackHost(parsed.Hostname()) { + return nil, fmt.Errorf("server URL must use https: plain http is only allowed for loopback hosts (127.0.0.1, ::1, localhost): %q", base) + } + return &approvalHTTPClient{ + baseURL: parsed, + apiKey: strings.TrimSpace(apiKey), + httpClient: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +// isLoopbackHost reports whether host is a loopback identifier: 127.0.0.1, +// ::1, or localhost. Anything else — including other 127/8 addresses, +// 0.0.0.0, and hostnames that merely resolve to loopback — is not loopback +// here (fail closed). +func isLoopbackHost(host string) bool { + switch strings.ToLower(strings.TrimSpace(host)) { + case "127.0.0.1", "::1", "localhost": + return true + } + return false +} + +func (c *approvalHTTPClient) ListApprovals(ctx context.Context) ([]contracts.ApprovalCeremony, error) { + var ceremonies []contracts.ApprovalCeremony + if err := c.do(ctx, http.MethodGet, approvalAPIBasePath, nil, &ceremonies); err != nil { + return nil, err + } + return ceremonies, nil +} + +func (c *approvalHTTPClient) TransitionApproval(ctx context.Context, approvalID, action, actor, reason string) (contracts.ApprovalCeremony, error) { + switch action { + case "approve", "deny", "revoke": + default: + return contracts.ApprovalCeremony{}, fmt.Errorf("unsupported approval transition action %q", action) + } + body := struct { + Actor string `json:"actor"` + Reason string `json:"reason,omitempty"` + }{Actor: actor, Reason: reason} + var ceremony contracts.ApprovalCeremony + path := approvalAPIBasePath + "/" + url.PathEscape(approvalID) + "/" + action + if err := c.do(ctx, http.MethodPost, path, body, &ceremony); err != nil { + return contracts.ApprovalCeremony{}, err + } + return ceremony, nil +} + +func (c *approvalHTTPClient) CreateApproval(ctx context.Context, req createApprovalRequest) (contracts.ApprovalCeremony, error) { + if strings.TrimSpace(req.Subject) == "" || strings.TrimSpace(req.Action) == "" || strings.TrimSpace(req.RequestedBy) == "" { + return contracts.ApprovalCeremony{}, errors.New("approval subject, action, and requested_by are required") + } + var ceremony contracts.ApprovalCeremony + if err := c.do(ctx, http.MethodPost, approvalAPIBasePath, req, &ceremony); err != nil { + return contracts.ApprovalCeremony{}, err + } + return ceremony, nil +} + +func (c *approvalHTTPClient) ConsumeApproval(ctx context.Context, approvalID, bindingHash string) (contracts.ApprovalCeremony, error) { + if strings.TrimSpace(bindingHash) == "" { + return contracts.ApprovalCeremony{}, errors.New("approval binding_hash is required") + } + var ceremony contracts.ApprovalCeremony + path := approvalAPIBasePath + "/" + url.PathEscape(approvalID) + "/consume" + if err := c.do(ctx, http.MethodPost, path, map[string]string{"binding_hash": bindingHash}, &ceremony); err != nil { + return contracts.ApprovalCeremony{}, err + } + return ceremony, nil +} + +func (c *approvalHTTPClient) do(ctx context.Context, method, path string, body, out any) error { + if c.apiKey == "" { + return errApprovalAPIKeyMissing + } + var reader io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("encode approval request: %w", err) + } + reader = bytes.NewReader(data) + } + req, err := http.NewRequestWithContext(ctx, method, c.baseURL.JoinPath(path).String(), reader) + if err != nil { + return fmt.Errorf("build approval request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.apiKey) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Accept", "application/json") + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("approval API %s %s: %w", method, path, err) + } + defer resp.Body.Close() + payload, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return fmt.Errorf("read approval API response: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("approval API %s %s: HTTP %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(payload))) + } + if out == nil { + return nil + } + if err := json.Unmarshal(payload, out); err != nil { + return fmt.Errorf("decode approval API response: %w", err) + } + return nil +} diff --git a/core/cmd/helm-ai-kernel/watch_client_test.go b/core/cmd/helm-ai-kernel/watch_client_test.go new file mode 100644 index 000000000..7417e5653 --- /dev/null +++ b/core/cmd/helm-ai-kernel/watch_client_test.go @@ -0,0 +1,137 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Mindburn-Labs/helm-ai-kernel/core/pkg/contracts" +) + +func TestApprovalHTTPClientListApprovals(t *testing.T) { + apiKey := strings.Join([]string{"test", "key"}, "-") + wantAuthorization := strings.Join([]string{"Bearer", apiKey}, " ") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != approvalAPIBasePath || r.Method != http.MethodGet { + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != wantAuthorization { + t.Fatalf("Authorization = %q, want runtime client credential", got) + } + _ = json.NewEncoder(w).Encode([]contracts.ApprovalCeremony{{ + ApprovalID: "ap-1", + Subject: "shell_command", + Action: "shell_operate", + State: contracts.ApprovalCeremonyPending, + RequestedBy: "agent.local", + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + }}) + })) + defer server.Close() + + client, err := newApprovalHTTPClient(server.URL, apiKey) + if err != nil { + t.Fatalf("newApprovalHTTPClient: %v", err) + } + items, err := client.ListApprovals(context.Background()) + if err != nil { + t.Fatalf("ListApprovals: %v", err) + } + if len(items) != 1 || items[0].ApprovalID != "ap-1" { + t.Fatalf("items = %+v, want one ap-1", items) + } +} + +func TestApprovalHTTPClientListApprovalsUnauthorized(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + })) + defer server.Close() + + client, err := newApprovalHTTPClient(server.URL, "wrong-key") + if err != nil { + t.Fatalf("newApprovalHTTPClient: %v", err) + } + if _, err := client.ListApprovals(context.Background()); err == nil || !strings.Contains(err.Error(), "401") { + t.Fatalf("err = %v, want HTTP 401 error", err) + } +} + +func TestApprovalHTTPClientTransition(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || + (r.URL.Path != approvalAPIBasePath+"/ap-9/approve" && r.URL.Path != approvalAPIBasePath+"/ap-9/revoke") { + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + var body struct { + Actor string `json:"actor"` + Reason string `json:"reason"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body.Actor != "operator.cli" { + t.Fatalf("actor = %q, want operator.cli", body.Actor) + } + state := contracts.ApprovalCeremonyAllowed + if strings.HasSuffix(r.URL.Path, "/revoke") { + state = contracts.ApprovalCeremonyRevoked + } + _ = json.NewEncoder(w).Encode(contracts.ApprovalCeremony{ + ApprovalID: "ap-9", + Subject: "shell_command", + Action: "shell_operate", + State: state, + }) + })) + defer server.Close() + + client, err := newApprovalHTTPClient(server.URL, "test-key") + if err != nil { + t.Fatalf("newApprovalHTTPClient: %v", err) + } + ceremony, err := client.TransitionApproval(context.Background(), "ap-9", "approve", "operator.cli", "ok") + if err != nil { + t.Fatalf("TransitionApproval: %v", err) + } + if ceremony.State != contracts.ApprovalCeremonyAllowed { + t.Fatalf("state = %s, want approved", ceremony.State) + } + ceremony, err = client.TransitionApproval(context.Background(), "ap-9", "revoke", "operator.cli", "consumed") + if err != nil || ceremony.State != contracts.ApprovalCeremonyRevoked { + t.Fatalf("revoke = %+v err=%v, want revoked", ceremony, err) + } +} + +func TestApprovalHTTPClientFailClosedWithoutKey(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("request must never reach the server without an API key") + })) + defer server.Close() + + client, err := newApprovalHTTPClient(server.URL, "") + if err != nil { + t.Fatalf("newApprovalHTTPClient: %v", err) + } + if _, err := client.ListApprovals(context.Background()); !errors.Is(err, errApprovalAPIKeyMissing) { + t.Fatalf("err = %v, want errApprovalAPIKeyMissing", err) + } +} + +func TestNewApprovalHTTPClientRejectsBadURL(t *testing.T) { + if _, err := newApprovalHTTPClient("ftp://example.com", "k"); err == nil { + t.Fatal("non-http scheme must be rejected") + } + if _, err := newApprovalHTTPClient("http://", "k"); err == nil { + t.Fatal("missing host must be rejected") + } + if _, err := newApprovalHTTPClient("http://example.com", "k"); err == nil { + t.Fatal("non-loopback plain HTTP must be rejected before sending the admin key") + } +} diff --git a/core/cmd/helm-ai-kernel/watch_cmd.go b/core/cmd/helm-ai-kernel/watch_cmd.go new file mode 100644 index 000000000..d797ee4c3 --- /dev/null +++ b/core/cmd/helm-ai-kernel/watch_cmd.go @@ -0,0 +1,162 @@ +// watch_cmd.go — `helm-ai-kernel watch`: terminal-native live approval +// watcher with approve/deny hotkeys. +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "io" + "os" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +const ( + defaultWatchURL = "http://127.0.0.1:8080" + watchURLEnv = "HELM_KERNEL_URL" + watchAdminAPIKeyEnv = "HELM_ADMIN_API_KEY" + defaultWatchInterval = 2 * time.Second +) + +func init() { + Register(Subcommand{ + Name: "watch", + Usage: "Watch live approval state with approve/deny hotkeys (TUI; --once for a snapshot)", + RunFn: runWatchCmd, + }) +} + +func runWatchCmd(args []string, stdout, stderr io.Writer) int { + cmd := flag.NewFlagSet("watch", flag.ContinueOnError) + cmd.SetOutput(stderr) + var rawURL, apiKeyFile, actor string + var interval time.Duration + var once, jsonOut bool + cmd.StringVar(&rawURL, "url", "", "Kernel server URL (default $HELM_KERNEL_URL or "+defaultWatchURL+")") + cmd.StringVar(&apiKeyFile, "api-key-file", "", "Path to a 0600 file containing the admin API key (default $HELM_ADMIN_API_KEY)") + cmd.StringVar(&actor, "actor", "operator.cli", "Actor recorded on approve/deny transitions") + cmd.DurationVar(&interval, "interval", defaultWatchInterval, "Polling interval for server state") + cmd.BoolVar(&once, "once", false, "Print a single snapshot and exit (no TUI)") + cmd.BoolVar(&jsonOut, "json", false, "Print the snapshot as JSON (implies --once)") + if err := cmd.Parse(args); err != nil { + if err == flag.ErrHelp { + return 0 + } + return 2 + } + if rawURL == "" { + rawURL = strings.TrimSpace(os.Getenv(watchURLEnv)) + } + if rawURL == "" { + rawURL = defaultWatchURL + } + if interval <= 0 { + _, _ = fmt.Fprintln(stderr, "Error: --interval must be positive") + return 2 + } + + apiKey, err := resolveWatchAPIKey(apiKeyFile) + if err != nil { + _, _ = fmt.Fprintf(stderr, "Error: %v\n", err) + return 2 + } + client, err := newApprovalHTTPClient(rawURL, apiKey) + if err != nil { + _, _ = fmt.Fprintf(stderr, "Error: %v\n", err) + return 2 + } + + // Snapshot mode: explicit --once/--json, or non-TTY stdout (fail closed to + // a plain snapshot rather than a broken TUI). + if jsonOut || once || !writerIsTerminal(stdout) { + return runWatchSnapshot(client, jsonOut, stdout, stderr) + } + + program := tea.NewProgram(newWatchModel(client, actor, interval)) + if _, err := program.Run(); err != nil { + _, _ = fmt.Fprintf(stderr, "Error: watch TUI failed: %v\n", err) + return 1 + } + return 0 +} + +func runWatchSnapshot(client approvalClient, jsonOut bool, stdout, stderr io.Writer) int { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + items, err := client.ListApprovals(ctx) + if err != nil { + // Fail closed: a failed fetch is an error exit, never an empty list. + _, _ = fmt.Fprintf(stderr, "Error: cannot load approval state: %v\n", err) + return 1 + } + if jsonOut { + data, err := json.MarshalIndent(map[string]any{ + "refreshed_at": time.Now().UTC(), + "pending": filterPendingApprovals(items), + }, "", " ") + if err != nil { + _, _ = fmt.Fprintf(stderr, "Error: encode snapshot: %v\n", err) + return 1 + } + _, _ = fmt.Fprintln(stdout, string(data)) + return 0 + } + renderApprovalSnapshot(stdout, items, time.Now()) + return 0 +} + +// resolveWatchAPIKey reads the admin API key from --api-key-file (0600) or the +// HELM_ADMIN_API_KEY environment variable. Missing key fails closed. +func resolveWatchAPIKey(apiKeyFile string) (string, error) { + return resolveAPIKey(apiKeyFile, watchAdminAPIKeyEnv, "admin") +} + +func resolveServiceAPIKey(apiKeyFile string) (string, error) { + return resolveAPIKey(apiKeyFile, serviceAPIKeyEnv, "service") +} + +func resolveAPIKey(apiKeyFile, envName, label string) (string, error) { + if strings.TrimSpace(apiKeyFile) != "" { + info, err := os.Lstat(apiKeyFile) + if err != nil { + return "", fmt.Errorf("read API key file: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return "", fmt.Errorf("API key file %s must be a regular file, not a symlink or special file", apiKeyFile) + } + if info.Mode().Perm()&0o077 != 0 { + return "", fmt.Errorf("API key file %s must not be readable by group/others (chmod 0600)", apiKeyFile) + } + data, err := os.ReadFile(apiKeyFile) + if err != nil { + return "", fmt.Errorf("read API key file: %w", err) + } + key := strings.TrimSpace(string(data)) + if key == "" { + return "", fmt.Errorf("API key file %s is empty", apiKeyFile) + } + return key, nil + } + key := strings.TrimSpace(os.Getenv(envName)) + if key == "" { + return "", fmt.Errorf("%s API key is required (set %s or provide its key file)", label, envName) + } + return key, nil +} + +// writerIsTerminal reports whether w looks like an interactive terminal. +func writerIsTerminal(w io.Writer) bool { + file, ok := w.(*os.File) + if !ok { + return false + } + info, err := file.Stat() + if err != nil { + return false + } + return info.Mode()&os.ModeCharDevice != 0 +} diff --git a/core/cmd/helm-ai-kernel/watch_model.go b/core/cmd/helm-ai-kernel/watch_model.go new file mode 100644 index 000000000..1c18fc7bd --- /dev/null +++ b/core/cmd/helm-ai-kernel/watch_model.go @@ -0,0 +1,337 @@ +// watch_model.go — bubbletea model for `helm-ai-kernel watch`. +// +// Attribution: the keyboard-first approval UX (live pending list with +// approve/deny hotkeys) is adapted from Rowboat (Apache-2.0), +// apps/cli/src/tui/ui.tsx. This is an original Go implementation against the +// HELM approval API; no Rowboat code is copied verbatim. +// +// Fail-closed invariants: +// - Pending items always derive from server state; the model never invents +// or retains stale actionable items. A failed refresh clears the list. +// - Approve/deny are disabled whenever the last refresh failed or a +// transition is in flight. +package main + +import ( + "context" + "fmt" + "io" + "sort" + "strings" + "time" + "unicode" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/Mindburn-Labs/helm-ai-kernel/core/pkg/contracts" + "github.com/Mindburn-Labs/helm-ai-kernel/core/pkg/workstation" +) + +type approvalsFetchedMsg struct { + // generation tags the fetch so a stale (out-of-order) result can never + // overwrite newer state: only the latest generation may mutate the model. + generation int + items []contracts.ApprovalCeremony + err error +} + +type approvalTransitionedMsg struct { + approvalID string + action string + err error +} + +type watchTickMsg time.Time + +// watchModel renders the live approval queue and wires approve/deny hotkeys +// to the kernel approval API. +// +// Refresh invariants (fail closed under races): +// - At most one fetch is ever in flight (inFlight guard): manual refreshes +// and ticks cannot stack up parallel polling loops. +// - Every fetch is tagged with a monotonically increasing generation; a +// result whose generation is not the latest is discarded untouched, so an +// out-of-order success can never overwrite a newer failure (or vice +// versa). +// - The next tick is scheduled only when a fetch completes, keeping a +// single polling loop for the lifetime of the program. +type watchModel struct { + client approvalClient + actor string + interval time.Duration + + pending []contracts.ApprovalCeremony + selected int + lastErr error + busy bool + refreshedAt time.Time + status string + width int + + generation int + inFlight bool +} + +func newWatchModel(client approvalClient, actor string, interval time.Duration) *watchModel { + if interval <= 0 { + interval = 2 * time.Second + } + return &watchModel{client: client, actor: actor, interval: interval} +} + +func (m *watchModel) Init() tea.Cmd { + return m.startFetch() +} + +// startFetch begins a new fetch generation. Callers must hold the inFlight +// invariant: never start a fetch while one is already running. +func (m *watchModel) startFetch() tea.Cmd { + m.generation++ + m.inFlight = true + return m.fetchCmd(m.generation) +} + +func (m *watchModel) fetchCmd(generation int) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + items, err := m.client.ListApprovals(ctx) + return approvalsFetchedMsg{generation: generation, items: items, err: err} + } +} + +func (m *watchModel) tickCmd() tea.Cmd { + return tea.Tick(m.interval, func(t time.Time) tea.Msg { return watchTickMsg(t) }) +} + +func (m *watchModel) transitionCmd(approvalID, action string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _, err := m.client.TransitionApproval(ctx, approvalID, action, m.actor, "operator decision via helm-ai-kernel watch") + return approvalTransitionedMsg{approvalID: approvalID, action: action, err: err} + } +} + +// actGuard explains why an approve/deny action is currently unavailable, or +// returns "" when the selected item can be transitioned. Fail closed: any +// uncertainty disables the action. +func (m *watchModel) actGuard() string { + if m.busy { + return "an approval transition is already in flight" + } + if m.lastErr != nil { + return "approval actions unavailable: last refresh failed" + } + if len(m.pending) == 0 { + return "no pending approvals" + } + if m.selected < 0 || m.selected >= len(m.pending) { + return "no approval selected" + } + return "" +} + +func (m *watchModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch typed := msg.(type) { + case tea.WindowSizeMsg: + m.width = typed.Width + return m, nil + case watchTickMsg: + // A tick never starts a second polling loop: if a fetch is already in + // flight, its completion schedules the next tick. + if m.inFlight { + return m, nil + } + return m, m.startFetch() + case approvalsFetchedMsg: + if typed.generation != m.generation { + // Stale generation: an out-of-order result must never mutate + // state. The latest fetch will apply and schedule the next tick. + return m, nil + } + m.inFlight = false + m.refreshedAt = time.Now() + if typed.err != nil { + // Fail closed: never present stale items as actionable. + m.lastErr = typed.err + m.pending = nil + m.selected = 0 + m.status = "" + return m, m.tickCmd() + } + m.lastErr = nil + selectedID := "" + if m.selected >= 0 && m.selected < len(m.pending) { + selectedID = m.pending[m.selected].ApprovalID + } + m.pending = filterPendingApprovals(typed.items) + if selectedID != "" { + for i := range m.pending { + if m.pending[i].ApprovalID == selectedID { + m.selected = i + break + } + } + } + if m.selected >= len(m.pending) { + m.selected = len(m.pending) - 1 + } + if m.selected < 0 { + m.selected = 0 + } + return m, m.tickCmd() + case approvalTransitionedMsg: + m.busy = false + if typed.err != nil { + m.status = terminalSafe(fmt.Sprintf("%s %s failed: %v", typed.action, typed.approvalID, typed.err)) + } else { + m.status = terminalSafe(fmt.Sprintf("%s %s recorded", typed.action, typed.approvalID)) + } + // A transition invalidates the queue; refresh immediately. Bump the + // generation first so any result from a fetch started before the + // transition is discarded as stale. + if m.inFlight { + m.generation++ + m.inFlight = false + } + return m, m.startFetch() + case tea.KeyMsg: + switch typed.String() { + case "ctrl+c", "q": + return m, tea.Quit + case "up", "k": + if m.selected > 0 { + m.selected-- + } + return m, nil + case "down", "j": + if m.selected < len(m.pending)-1 { + m.selected++ + } + return m, nil + case "r": + if m.inFlight { + m.status = "refresh already in flight" + return m, nil + } + return m, m.startFetch() + case "a", "d": + action := "approve" + if typed.String() == "d" { + action = "deny" + } + if guard := m.actGuard(); guard != "" { + m.status = guard + return m, nil + } + m.busy = true + m.status = fmt.Sprintf("%s %s in flight…", action, m.pending[m.selected].ApprovalID) + return m, m.transitionCmd(m.pending[m.selected].ApprovalID, action) + } + } + return m, nil +} + +func (m *watchModel) View() string { + var b strings.Builder + b.WriteString("HELM WATCH — pending approvals\n") + if !m.refreshedAt.IsZero() { + fmt.Fprintf(&b, "refreshed %s · every %s · server-derived state\n", m.refreshedAt.Format("15:04:05"), m.interval) + } + if m.lastErr != nil { + fmt.Fprintf(&b, "ERROR: %s (actions disabled, fail-closed)\n", terminalSafe(m.lastErr.Error())) + } + b.WriteString("\n") + if len(m.pending) == 0 && m.lastErr == nil { + b.WriteString(" no pending approvals\n") + } + for i, item := range m.pending { + cursor := " " + if i == m.selected { + cursor = "> " + } + fmt.Fprintf(&b, "%s%s\n", cursor, formatApprovalRow(item, time.Now())) + } + b.WriteString("\n") + if m.status != "" { + fmt.Fprintf(&b, "%s\n", terminalSafe(m.status)) + } + b.WriteString("↑/↓ select · a approve · d deny · r refresh · q quit\n") + return b.String() +} + +// filterPendingApprovals keeps only pending ceremonies, sorted oldest-first so +// the operator drains the queue in request order. +func filterPendingApprovals(items []contracts.ApprovalCeremony) []contracts.ApprovalCeremony { + var pending []contracts.ApprovalCeremony + for _, item := range items { + if item.State == contracts.ApprovalCeremonyPending { + pending = append(pending, item) + } + } + sort.Slice(pending, func(i, j int) bool { + return pending[i].CreatedAt.Before(pending[j].CreatedAt) + }) + return pending +} + +// formatApprovalRow renders one approval ceremony as a compact line. +func formatApprovalRow(item contracts.ApprovalCeremony, now time.Time) string { + age := "unknown" + if !item.CreatedAt.IsZero() { + age = now.Sub(item.CreatedAt).Round(time.Second).String() + } + flags := make([]string, 0, 2) + if item.BreakGlass { + flags = append(flags, "break-glass") + } + if !item.TimelockUntil.IsZero() && now.Before(item.TimelockUntil) { + flags = append(flags, "timelocked") + } + suffix := "" + if len(flags) > 0 { + suffix = " [" + strings.Join(flags, ",") + "]" + } + reason := "" + if strings.TrimSpace(item.Reason) != "" { + reason = fmt.Sprintf(" reason %q", terminalSafe(item.Reason)) + } + binding := "" + if item.BindingHash != "" { + binding = " binding " + terminalSafe(item.BindingHash) + if item.Subject == workstation.ShellGateApprovalSubject && + item.Action == workstation.ShellGateApprovalAction && + !workstation.ApprovalReasonMatchesBinding(item.Reason, item.BindingHash) { + binding += " [reason/binding mismatch]" + } + } + return fmt.Sprintf("%s %s:%s by %s age %s%s%s%s", + terminalSafe(item.ApprovalID), terminalSafe(item.Subject), terminalSafe(item.Action), + terminalSafe(item.RequestedBy), age, suffix, binding, reason) +} + +// terminalSafe strips terminal control and Unicode format characters from +// server-controlled text before it reaches an interactive or snapshot view. +func terminalSafe(value string) string { + return strings.Map(func(r rune) rune { + if unicode.IsControl(r) || unicode.In(r, unicode.Cf) { + return '\uFFFD' + } + return r + }, value) +} + +// renderApprovalSnapshot prints a non-interactive snapshot of the pending +// queue (used by --once and non-TTY output). +func renderApprovalSnapshot(w io.Writer, items []contracts.ApprovalCeremony, refreshedAt time.Time) { + pending := filterPendingApprovals(items) + fmt.Fprintf(w, "HELM WATCH snapshot — %s\n", refreshedAt.Format(time.RFC3339)) + if len(pending) == 0 { + fmt.Fprintln(w, " no pending approvals") + return + } + for _, item := range pending { + fmt.Fprintf(w, " %s\n", formatApprovalRow(item, refreshedAt)) + } +} diff --git a/core/cmd/helm-ai-kernel/watch_model_test.go b/core/cmd/helm-ai-kernel/watch_model_test.go new file mode 100644 index 000000000..72ce63b11 --- /dev/null +++ b/core/cmd/helm-ai-kernel/watch_model_test.go @@ -0,0 +1,323 @@ +package main + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/Mindburn-Labs/helm-ai-kernel/core/pkg/contracts" +) + +// fakeApprovalClient implements approvalClient for model tests. +type fakeApprovalClient struct { + items []contracts.ApprovalCeremony + listErr error + transitionErr error + transitionedTo []string +} + +func (f *fakeApprovalClient) ListApprovals(context.Context) ([]contracts.ApprovalCeremony, error) { + return f.items, f.listErr +} + +func (f *fakeApprovalClient) TransitionApproval(_ context.Context, approvalID, action, actor, reason string) (contracts.ApprovalCeremony, error) { + if f.transitionErr != nil { + return contracts.ApprovalCeremony{}, f.transitionErr + } + f.transitionedTo = append(f.transitionedTo, action+":"+approvalID) + state := contracts.ApprovalCeremonyAllowed + if action == "deny" { + state = contracts.ApprovalCeremonyDenied + } + return contracts.ApprovalCeremony{ApprovalID: approvalID, State: state}, nil +} + +func (f *fakeApprovalClient) CreateApproval(context.Context, createApprovalRequest) (contracts.ApprovalCeremony, error) { + return contracts.ApprovalCeremony{}, errors.New("not implemented") +} + +func pendingCeremony(id string, createdAt time.Time) contracts.ApprovalCeremony { + return contracts.ApprovalCeremony{ + ApprovalID: id, + Subject: "shell_command", + Action: "shell_operate", + State: contracts.ApprovalCeremonyPending, + RequestedBy: "agent.local", + CreatedAt: createdAt, + UpdatedAt: createdAt, + } +} + +func updateModel(t *testing.T, m *watchModel, msg tea.Msg) (*watchModel, tea.Cmd) { + t.Helper() + next, cmd := m.Update(msg) + model, ok := next.(*watchModel) + if !ok { + t.Fatalf("Update returned %T, want *watchModel", next) + } + return model, cmd +} + +func TestWatchModelFetchFiltersPending(t *testing.T) { + now := time.Now() + client := &fakeApprovalClient{} + m := newWatchModel(client, "operator.cli", time.Second) + + m, cmd := updateModel(t, m, approvalsFetchedMsg{items: []contracts.ApprovalCeremony{ + {ApprovalID: "ap-old", State: contracts.ApprovalCeremonyAllowed, CreatedAt: now}, + pendingCeremony("ap-new", now), + pendingCeremony("ap-old-pending", now.Add(-time.Hour)), + }}) + if cmd == nil { + t.Fatal("successful fetch must schedule the next tick") + } + if len(m.pending) != 2 { + t.Fatalf("pending = %d, want 2 (non-pending filtered out)", len(m.pending)) + } + if m.pending[0].ApprovalID != "ap-old-pending" { + t.Fatalf("pending[0] = %s, want oldest first", m.pending[0].ApprovalID) + } +} + +func TestWatchModelFetchErrorFailsClosed(t *testing.T) { + client := &fakeApprovalClient{} + m := newWatchModel(client, "operator.cli", time.Second) + + m, _ = updateModel(t, m, approvalsFetchedMsg{items: []contracts.ApprovalCeremony{pendingCeremony("ap-1", time.Now())}}) + if len(m.pending) != 1 { + t.Fatalf("setup: pending = %d, want 1", len(m.pending)) + } + + m, cmd := updateModel(t, m, approvalsFetchedMsg{err: errors.New("connection refused")}) + if cmd == nil { + t.Fatal("failed fetch must still schedule the next tick") + } + if m.lastErr == nil { + t.Fatal("lastErr must record the failure") + } + if len(m.pending) != 0 { + t.Fatalf("stale pending items must be cleared, got %d", len(m.pending)) + } + + // Approve key must not fire a transition while the last refresh failed. + m, cmd = updateModel(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}) + if cmd != nil { + t.Fatal("approve must be disabled after a failed refresh (fail closed)") + } + if !strings.Contains(m.status, "unavailable") { + t.Fatalf("status = %q, want an explanation of the disabled action", m.status) + } + if len(client.transitionedTo) != 0 { + t.Fatalf("no transition may fire, got %v", client.transitionedTo) + } +} + +func TestWatchModelDiscardsStaleFetchGeneration(t *testing.T) { + m := newWatchModel(&fakeApprovalClient{}, "operator.cli", time.Second) + m.generation = 2 + m.inFlight = true + m.pending = []contracts.ApprovalCeremony{pendingCeremony("current", time.Now())} + + m, cmd := updateModel(t, m, approvalsFetchedMsg{ + generation: 1, + err: errors.New("stale failure"), + }) + if cmd != nil || len(m.pending) != 1 || m.pending[0].ApprovalID != "current" || !m.inFlight { + t.Fatalf("stale generation mutated model: pending=%+v inFlight=%t cmd=%v", m.pending, m.inFlight, cmd) + } + + m, _ = updateModel(t, m, approvalsFetchedMsg{ + generation: 2, + err: errors.New("current failure"), + }) + if m.lastErr == nil || len(m.pending) != 0 || m.inFlight { + t.Fatalf("current failure did not fail closed: pending=%+v inFlight=%t err=%v", m.pending, m.inFlight, m.lastErr) + } +} + +func TestWatchModelApproveDenyFlow(t *testing.T) { + client := &fakeApprovalClient{} + m := newWatchModel(client, "operator.cli", time.Second) + m, _ = updateModel(t, m, approvalsFetchedMsg{items: []contracts.ApprovalCeremony{ + pendingCeremony("ap-1", time.Now().Add(-time.Minute)), + pendingCeremony("ap-2", time.Now()), + }}) + + // Navigate to the second item and approve it. + m, _ = updateModel(t, m, tea.KeyMsg{Type: tea.KeyDown}) + if m.selected != 1 { + t.Fatalf("selected = %d, want 1", m.selected) + } + m, cmd := updateModel(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}) + if !m.busy { + t.Fatal("busy must be set while a transition is in flight") + } + if cmd == nil { + t.Fatal("approve must produce a transition command") + } + msg := cmd() + transitioned, ok := msg.(approvalTransitionedMsg) + if !ok { + t.Fatalf("transition cmd produced %T, want approvalTransitionedMsg", msg) + } + if transitioned.err != nil || transitioned.action != "approve" || transitioned.approvalID != "ap-2" { + t.Fatalf("transition = %+v, want approve ap-2", transitioned) + } + if got := client.transitionedTo; len(got) != 1 || got[0] != "approve:ap-2" { + t.Fatalf("client transitions = %v, want [approve:ap-2]", got) + } + + m, refresh := updateModel(t, m, transitioned) + if m.busy { + t.Fatal("busy must clear after the transition completes") + } + if refresh == nil { + t.Fatal("completed transition must trigger a refresh") + } + if !strings.Contains(m.status, "approve ap-2") { + t.Fatalf("status = %q, want transition confirmation", m.status) + } + + // Deny the remaining item. + m, _ = updateModel(t, m, approvalsFetchedMsg{items: []contracts.ApprovalCeremony{pendingCeremony("ap-1", time.Now().Add(-time.Minute))}}) + m, cmd = updateModel(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}}) + if cmd == nil { + t.Fatal("deny must produce a transition command") + } + if transitioned := cmd().(approvalTransitionedMsg); transitioned.action != "deny" { + t.Fatalf("action = %s, want deny", transitioned.action) + } +} + +func TestWatchModelRefreshPreservesSelectedApproval(t *testing.T) { + now := time.Now() + m := newWatchModel(&fakeApprovalClient{}, "operator.cli", time.Second) + m, _ = updateModel(t, m, approvalsFetchedMsg{items: []contracts.ApprovalCeremony{ + pendingCeremony("ap-1", now), + pendingCeremony("ap-2", now.Add(time.Second)), + }}) + m.selected = 1 + m, _ = updateModel(t, m, approvalsFetchedMsg{items: []contracts.ApprovalCeremony{ + pendingCeremony("ap-new", now.Add(-time.Second)), + pendingCeremony("ap-1", now), + pendingCeremony("ap-2", now.Add(time.Second)), + }}) + if got := m.pending[m.selected].ApprovalID; got != "ap-2" { + t.Fatalf("refresh changed selection to %q, want ap-2", got) + } +} + +func TestWatchModelTransitionErrorKeepsQueue(t *testing.T) { + client := &fakeApprovalClient{transitionErr: errors.New("conflict")} + m := newWatchModel(client, "operator.cli", time.Second) + m, _ = updateModel(t, m, approvalsFetchedMsg{items: []contracts.ApprovalCeremony{pendingCeremony("ap-1", time.Now())}}) + + m, cmd := updateModel(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}) + transitioned := cmd().(approvalTransitionedMsg) + m, _ = updateModel(t, m, transitioned) + if !strings.Contains(m.status, "failed") { + t.Fatalf("status = %q, want the failure surfaced", m.status) + } + if m.busy { + t.Fatal("busy must clear even on transition failure") + } +} + +func TestWatchModelQuitAndGuards(t *testing.T) { + client := &fakeApprovalClient{} + m := newWatchModel(client, "operator.cli", time.Second) + + // Empty queue: approve is a no-op with an explanatory status. + m, cmd := updateModel(t, m, approvalsFetchedMsg{items: nil}) + if cmd == nil { + t.Fatal("tick must be scheduled") + } + m, cmd = updateModel(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}) + if cmd != nil { + t.Fatal("approve with an empty queue must not fire") + } + if !strings.Contains(m.status, "no pending approvals") { + t.Fatalf("status = %q", m.status) + } + + // q quits. + _, cmd = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) + if cmd == nil { + t.Fatal("q must produce a quit command") + } + if _, ok := cmd().(tea.QuitMsg); !ok { + t.Fatalf("quit cmd produced %T, want tea.QuitMsg", cmd()) + } +} + +func TestWatchModelView(t *testing.T) { + client := &fakeApprovalClient{} + m := newWatchModel(client, "operator.cli", time.Second) + item := pendingCeremony("ap-1", time.Now().Add(-time.Minute)) + item.BindingHash = "sha256:command-one" + item.Reason = `blocked command "rm /tmp/x"` + m, _ = updateModel(t, m, approvalsFetchedMsg{items: []contracts.ApprovalCeremony{ + item, + }}) + view := m.View() + for _, want := range []string{"ap-1", "a approve", "d deny", "q quit", "shell_command", "rm /tmp/x", "sha256:command-one", "reason/binding mismatch"} { + if !strings.Contains(view, want) { + t.Fatalf("view missing %q:\n%s", want, view) + } + } + + // Error state renders and announces fail-closed. + m, _ = updateModel(t, m, approvalsFetchedMsg{err: errors.New("boom")}) + view = m.View() + if !strings.Contains(view, "ERROR") || !strings.Contains(view, "fail-closed") { + t.Fatalf("error view missing fail-closed notice:\n%s", view) + } +} + +func TestWatchModelSanitizesServerControlledTerminalText(t *testing.T) { + m := newWatchModel(&fakeApprovalClient{}, "operator.cli", time.Second) + item := pendingCeremony("ap-\x1b[2J", time.Now()) + item.Subject = "shell\x00command" + item.Action = "operate\u202Etxt" + item.RequestedBy = "agent\rspoof" + item.Reason = "blocked\x1b]52;c;payload\a" + m, _ = updateModel(t, m, approvalsFetchedMsg{items: []contracts.ApprovalCeremony{item}}) + view := m.View() + for _, forbidden := range []string{"\x1b", "\x00", "\r", "\u202E", "\a"} { + if strings.Contains(view, forbidden) { + t.Fatalf("view contains terminal control %q: %q", forbidden, view) + } + } + for _, want := range []string{"ap-", "shell", "operate", "agent", "blocked"} { + if !strings.Contains(view, want) { + t.Fatalf("sanitized view lost safe text %q: %q", want, view) + } + } +} + +func TestRenderApprovalSnapshot(t *testing.T) { + var buf bytes.Buffer + items := []contracts.ApprovalCeremony{ + pendingCeremony("ap-1", time.Now().Add(-time.Minute)), + {ApprovalID: "ap-done", State: contracts.ApprovalCeremonyDenied}, + } + renderApprovalSnapshot(&buf, items, time.Now()) + out := buf.String() + if !strings.Contains(out, "ap-1") { + t.Fatalf("snapshot missing pending item:\n%s", out) + } + if strings.Contains(out, "ap-done") { + t.Fatalf("snapshot must only show pending items:\n%s", out) + } + + buf.Reset() + renderApprovalSnapshot(&buf, nil, time.Now()) + if !strings.Contains(buf.String(), "no pending approvals") { + t.Fatalf("empty snapshot:\n%s", buf.String()) + } +} diff --git a/core/cmd/helm-ai-kernel/workstation_cmd.go b/core/cmd/helm-ai-kernel/workstation_cmd.go index 138e475a0..d703d8451 100644 --- a/core/cmd/helm-ai-kernel/workstation_cmd.go +++ b/core/cmd/helm-ai-kernel/workstation_cmd.go @@ -17,7 +17,7 @@ import ( func runWorkstationCmd(args []string, stdout, stderr io.Writer) int { if len(args) == 0 { - _, _ = fmt.Fprintln(stderr, "Usage: helm-ai-kernel workstation [flags]") + _, _ = fmt.Fprintln(stderr, "Usage: helm-ai-kernel workstation [flags]") return 2 } switch args[0] { @@ -29,6 +29,8 @@ func runWorkstationCmd(args []string, stdout, stderr io.Writer) int { return runWorkstationDecisionCmd(args[1:], stdout, stderr) case "enforce": return runWorkstationEnforceCmd(args[1:], stdout, stderr) + case "gate": + return runWorkstationGateCmd(args[1:], stdout, stderr) case "verify-decision": return runWorkstationVerifyDecisionCmd(args[1:], stdout, stderr) case "operator": @@ -49,7 +51,7 @@ func runWorkstationCmd(args []string, stdout, stderr io.Writer) int { return runWorkstationCaptureCmd(args[1:], stdout, stderr) default: _, _ = fmt.Fprintf(stderr, "Unknown workstation command: %s\n", args[0]) - _, _ = fmt.Fprintln(stderr, "Usage: helm-ai-kernel workstation [flags]") + _, _ = fmt.Fprintln(stderr, "Usage: helm-ai-kernel workstation [flags]") return 2 } } diff --git a/core/cmd/helm-ai-kernel/workstation_gate_cmd.go b/core/cmd/helm-ai-kernel/workstation_gate_cmd.go new file mode 100644 index 000000000..e9a83e2f3 --- /dev/null +++ b/core/cmd/helm-ai-kernel/workstation_gate_cmd.go @@ -0,0 +1,168 @@ +// workstation_gate_cmd.go — `helm-ai-kernel workstation gate`: escalating +// shell gate. Blocked commands become pending approvals in the dev profile +// and stay fail-closed denials in the production profile. +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/Mindburn-Labs/helm-ai-kernel/core/pkg/contracts" + "github.com/Mindburn-Labs/helm-ai-kernel/core/pkg/workstation" +) + +const ( + exitGateAllow = 0 + exitGatePendingApproval = 3 + exitGateDeny = 126 +) + +func runWorkstationGateCmd(args []string, stdout, stderr io.Writer) int { + cmd := flag.NewFlagSet("workstation gate", flag.ContinueOnError) + cmd.SetOutput(stderr) + var profileRaw, command, allowlistPath, dataDir, serviceKeyFile, approvalID string + var jsonOut, requestApproval bool + var rawURL, actor string + cmd.StringVar(&profileRaw, "profile", string(workstation.ShellGateProfileProduction), "Gate profile: dev escalates blocked commands to pending approvals; anything else is production (deny, fail-closed)") + cmd.StringVar(&command, "command", "", "Shell command line to gate (alternative to trailing args after --)") + cmd.StringVar(&allowlistPath, "allowlist", "", "Shell allowlist JSON path (default /workstation/shell-allowlist.json)") + cmd.StringVar(&dataDir, "data-dir", defaultSetupDataDir(), "HELM local data directory") + cmd.BoolVar(&jsonOut, "json", false, "Print the gate decision as JSON") + cmd.BoolVar(&requestApproval, "request-approval", false, "On a pending_approval verdict, create the approval ceremony on the kernel server") + cmd.StringVar(&rawURL, "url", "", "Kernel server URL for --request-approval (default $HELM_KERNEL_URL or "+defaultWatchURL+")") + cmd.StringVar(&actor, "actor", "agent.local", "Requesting actor recorded on the approval (must differ from the approving watch actor)") + cmd.StringVar(&serviceKeyFile, "service-key-file", "", "Path to a 0600 service API key file (default $HELM_SERVICE_API_KEY; never use an admin key)") + cmd.StringVar(&approvalID, "approval-id", "", "Consume this approved, command-bound ceremony to allow a pending dev command") + if err := cmd.Parse(args); err != nil { + if err == flag.ErrHelp { + return 0 + } + return 2 + } + if strings.TrimSpace(command) == "" { + command = strings.Join(cmd.Args(), " ") + } + if strings.TrimSpace(command) == "" { + _, _ = fmt.Fprintln(stderr, "Error: --command or trailing command args are required") + return 2 + } + if allowlistPath == "" { + allowlistPath = workstation.DefaultShellAllowlistPath(dataDir) + } + profile := workstation.NormalizeShellGateProfile(profileRaw) + store := workstation.NewShellAllowlistStore(allowlistPath) + decision := workstation.GateShellCommandWithStore(profile, command, store) + + if approvalID != "" { + if decision.Verdict != workstation.ShellGateVerdictPendingApproval { + _, _ = fmt.Fprintln(stderr, "Error: --approval-id is valid only for a pending dev-profile command") + return 2 + } + if err := consumeShellGateApproval(decision, approvalID, rawURL, serviceKeyFile); err != nil { + _, _ = fmt.Fprintf(stderr, "Error: approval cannot authorize command: %v\n", err) + return 1 + } + decision.Verdict = workstation.ShellGateVerdictAllow + decision.Reason = "exact command authorized by single-use approval " + approvalID + } + + if jsonOut { + data, _ := json.MarshalIndent(decision, "", " ") + _, _ = fmt.Fprintln(stdout, string(data)) + } else { + printGateDecision(stdout, decision, store.Path()) + } + + switch decision.Verdict { + case workstation.ShellGateVerdictAllow: + return exitGateAllow + case workstation.ShellGateVerdictPendingApproval: + if !requestApproval { + return exitGatePendingApproval + } + if err := requestShellGateApproval(decision, rawURL, serviceKeyFile, actor, stdout); err != nil { + _, _ = fmt.Fprintf(stderr, "Error: approval request failed: %v\n", err) + return 1 + } + return exitGatePendingApproval + default: + return exitGateDeny + } +} + +func printGateDecision(stdout io.Writer, decision workstation.ShellGateDecision, allowlistPath string) { + _, _ = fmt.Fprintf(stdout, "%sShell Gate Decision%s\n", ColorBold, ColorReset) + _, _ = fmt.Fprintf(stdout, " verdict: %s\n", decision.Verdict) + _, _ = fmt.Fprintf(stdout, " profile: %s\n", decision.Profile) + _, _ = fmt.Fprintf(stdout, " command: %s\n", terminalSafe(decision.Command)) + _, _ = fmt.Fprintf(stdout, " invoked: %s\n", terminalSafe(strings.Join(decision.Invoked, ", "))) + if len(decision.Blocked) > 0 { + _, _ = fmt.Fprintf(stdout, " blocked: %s\n", terminalSafe(strings.Join(decision.Blocked, ", "))) + } + if decision.Reason != "" { + _, _ = fmt.Fprintf(stdout, " reason: %s\n", terminalSafe(decision.Reason)) + } + _, _ = fmt.Fprintf(stdout, " allowlist: %s\n", terminalSafe(allowlistPath)) +} + +// requestShellGateApproval turns a pending_approval verdict into an approval +// ceremony on the kernel server, so `watch` can drain it. +func shellGateApprovalClient(rawURL, serviceKeyFile string) (*approvalHTTPClient, error) { + if strings.TrimSpace(rawURL) == "" { + rawURL = strings.TrimSpace(os.Getenv(watchURLEnv)) + } + if rawURL == "" { + rawURL = defaultWatchURL + } + apiKey, err := resolveServiceAPIKey(serviceKeyFile) + if err != nil { + return nil, err + } + return newApprovalHTTPClient(rawURL, apiKey) +} + +func requestShellGateApproval(decision workstation.ShellGateDecision, rawURL, serviceKeyFile, actor string, stdout io.Writer) error { + client, err := shellGateApprovalClient(rawURL, serviceKeyFile) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + ceremony, err := client.CreateApproval(ctx, createApprovalRequest{ + Subject: workstation.ShellGateApprovalSubject, + Action: workstation.ShellGateApprovalAction, + RequestedBy: actor, + Quorum: 1, + BindingHash: workstation.ShellCommandBindingRef(decision.Command), + Reason: fmt.Sprintf("shell gate escalation (dev profile): blocked commands [%s] in %q; %s", + strings.Join(decision.Blocked, ", "), decision.Command, workstation.ShellCommandBinding(decision.Command)), + }) + if err != nil { + return err + } + _, _ = fmt.Fprintf(stdout, " approval: %s (pending on server)\n", ceremony.ApprovalID) + return nil +} + +func consumeShellGateApproval(decision workstation.ShellGateDecision, approvalID, rawURL, serviceKeyFile string) error { + client, err := shellGateApprovalClient(rawURL, serviceKeyFile) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + approval, err := client.ConsumeApproval(ctx, approvalID, workstation.ShellCommandBindingRef(decision.Command)) + if err != nil { + return fmt.Errorf("consume approval %s: %w", approvalID, err) + } + if approval.State != contracts.ApprovalCeremonyRevoked { + return fmt.Errorf("consume approval %s returned state %s", approvalID, approval.State) + } + return nil +} diff --git a/core/cmd/helm-ai-kernel/workstation_gate_cmd_test.go b/core/cmd/helm-ai-kernel/workstation_gate_cmd_test.go new file mode 100644 index 000000000..de8a7800e --- /dev/null +++ b/core/cmd/helm-ai-kernel/workstation_gate_cmd_test.go @@ -0,0 +1,290 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/Mindburn-Labs/helm-ai-kernel/core/pkg/contracts" + "github.com/Mindburn-Labs/helm-ai-kernel/core/pkg/workstation" +) + +func gateTestAllowlist(t *testing.T, entries []string) string { + t.Helper() + dir := t.TempDir() + path := dir + "/shell-allowlist.json" + data, err := json.Marshal(entries) + if err != nil { + t.Fatalf("marshal allowlist: %v", err) + } + if err := writeFile0600(path, data); err != nil { + t.Fatalf("write allowlist: %v", err) + } + return path +} + +func writeFile0600(path string, data []byte) error { + return os.WriteFile(path, data, 0o600) +} + +func runGateForTest(t *testing.T, args ...string) (int, string, string) { + t.Helper() + var stdout, stderr bytes.Buffer + code := runWorkstationGateCmd(args, &stdout, &stderr) + return code, stdout.String(), stderr.String() +} + +func TestWorkstationGateAllow(t *testing.T) { + allowlist := gateTestAllowlist(t, []string{"ls", "cat"}) + code, out, _ := runGateForTest(t, "--allowlist", allowlist, "--command", "cat f | ls") + if code != exitGateAllow { + t.Fatalf("exit = %d, want %d (out: %s)", code, exitGateAllow, out) + } + if !strings.Contains(out, "allow") { + t.Fatalf("output missing allow verdict:\n%s", out) + } +} + +func TestWorkstationGateProductionDeny(t *testing.T) { + allowlist := gateTestAllowlist(t, []string{"ls"}) + code, out, _ := runGateForTest(t, "--allowlist", allowlist, "--command", "ls && rm -rf /tmp/x") + if code != exitGateDeny { + t.Fatalf("exit = %d, want %d (out: %s)", code, exitGateDeny, out) + } + if !strings.Contains(out, "deny") || !strings.Contains(out, "rm") { + t.Fatalf("output missing deny verdict and blocked command:\n%s", out) + } +} + +func TestPrintGateDecisionSanitizesTerminalText(t *testing.T) { + var out bytes.Buffer + printGateDecision(&out, workstation.ShellGateDecision{ + Command: "\x1b[2Jrm\rspoof", + Invoked: []string{"rm\x00"}, + Blocked: []string{"rm\x1b"}, + Reason: "blocked\u202Etxt", + }, "/tmp/\x1ballowlist") + if strings.Count(out.String(), "\x1b") != 2 || strings.Contains(out.String(), "\x1b[2J") { + t.Fatalf("gate output contains attacker-controlled terminal escape: %q", out.String()) + } + for _, control := range []string{"\r", "\x00", "\u202E"} { + if strings.Contains(out.String(), control) { + t.Fatalf("gate output contains terminal control %q: %q", control, out.String()) + } + } +} + +func TestWorkstationGateDevEscalates(t *testing.T) { + allowlist := gateTestAllowlist(t, []string{"ls"}) + code, out, _ := runGateForTest(t, "--profile", "dev", "--allowlist", allowlist, "--command", "ls && rm -rf /tmp/x") + if code != exitGatePendingApproval { + t.Fatalf("exit = %d, want %d (out: %s)", code, exitGatePendingApproval, out) + } + if !strings.Contains(out, "pending_approval") { + t.Fatalf("output missing pending_approval verdict:\n%s", out) + } +} + +func TestWorkstationGateUnknownProfileFailsClosed(t *testing.T) { + allowlist := gateTestAllowlist(t, []string{"ls"}) + code, _, _ := runGateForTest(t, "--profile", "staging", "--allowlist", allowlist, "--command", "rm x") + if code != exitGateDeny { + t.Fatalf("exit = %d, want %d for unknown profile", code, exitGateDeny) + } +} + +func TestWorkstationGateCorruptAllowlistFailsClosed(t *testing.T) { + path := t.TempDir() + "/shell-allowlist.json" + if err := os.WriteFile(path, []byte("{corrupt"), 0o600); err != nil { + t.Fatalf("write corrupt allowlist: %v", err) + } + code, _, _ := runGateForTest(t, "--allowlist", path, "--command", "ls") + if code != exitGateDeny { + t.Fatalf("exit = %d, want %d for corrupt allowlist in production", code, exitGateDeny) + } + code, _, _ = runGateForTest(t, "--profile", "dev", "--allowlist", path, "--command", "ls") + if code != exitGatePendingApproval { + t.Fatalf("exit = %d, want %d for corrupt allowlist in dev", code, exitGatePendingApproval) + } +} + +func TestWorkstationGateRequestApprovalCreatesCeremony(t *testing.T) { + var gotBody createApprovalRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != approvalAPIBasePath { + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode body: %v", err) + } + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(contracts.ApprovalCeremony{ + ApprovalID: "ap-gate-1", + Subject: gotBody.Subject, + Action: gotBody.Action, + State: contracts.ApprovalCeremonyPending, + }) + })) + defer server.Close() + t.Setenv(serviceAPIKeyEnv, "test-key") + + allowlist := gateTestAllowlist(t, []string{"ls"}) + code, out, errOut := runGateForTest(t, + "--profile", "dev", + "--allowlist", allowlist, + "--request-approval", + "--url", server.URL, + "--command", "sudo rm /x", + ) + if code != exitGatePendingApproval { + t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitGatePendingApproval, errOut) + } + if gotBody.Subject != "shell_command" || gotBody.Action != "shell_operate" { + t.Fatalf("approval request = %+v", gotBody) + } + if !strings.Contains(gotBody.Reason, "rm") || !strings.Contains(gotBody.Reason, "sudo") { + t.Fatalf("approval reason must name blocked commands: %q", gotBody.Reason) + } + if !strings.Contains(gotBody.Reason, workstation.ShellCommandBinding("sudo rm /x")) { + t.Fatalf("approval reason must bind the exact command: %q", gotBody.Reason) + } + if gotBody.BindingHash != workstation.ShellCommandBindingRef("sudo rm /x") { + t.Fatalf("approval binding = %q, want immutable command hash", gotBody.BindingHash) + } + if gotBody.RequestedBy != "agent.local" { + t.Fatalf("default requester = %q, want agent.local distinct from watch approver", gotBody.RequestedBy) + } + if !strings.Contains(out, "ap-gate-1") { + t.Fatalf("output must surface the created approval id:\n%s", out) + } +} + +func TestWorkstationGateConsumesExactApprovalOnce(t *testing.T) { + command := "rm /x" + approval := contracts.ApprovalCeremony{ + ApprovalID: "ap-bound", + Subject: workstation.ShellGateApprovalSubject, + Action: workstation.ShellGateApprovalAction, + State: contracts.ApprovalCeremonyAllowed, + RequestedBy: "operator.cli", + BindingHash: workstation.ShellCommandBindingRef(command), + Reason: "approved; " + workstation.ShellCommandBinding(command), + } + revokeCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != approvalAPIBasePath+"/"+approval.ApprovalID+"/consume" { + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + var body map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body["binding_hash"] != approval.BindingHash || approval.State != contracts.ApprovalCeremonyAllowed { + http.Error(w, "not approved for binding", http.StatusBadRequest) + return + } + revokeCount++ + approval.State = contracts.ApprovalCeremonyRevoked + _ = json.NewEncoder(w).Encode(approval) + })) + defer server.Close() + t.Setenv(serviceAPIKeyEnv, "test-key") + + allowlist := gateTestAllowlist(t, []string{"ls"}) + args := []string{ + "--profile", "dev", + "--allowlist", allowlist, + "--data-dir", t.TempDir(), + "--approval-id", approval.ApprovalID, + "--url", server.URL, + "--command", command, + } + code, out, errOut := runGateForTest(t, args...) + if code != exitGateAllow { + t.Fatalf("first consume exit = %d, want allow; out=%s err=%s", code, out, errOut) + } + args[5] = t.TempDir() + code, _, errOut = runGateForTest(t, args...) + if code != 1 || !strings.Contains(errOut, "400") || revokeCount != 1 { + t.Fatalf("cross-ledger reuse exit=%d revokes=%d err=%s, want server-side consumed rejection", code, revokeCount, errOut) + } +} + +func TestWorkstationGateRejectsApprovalForDifferentCommand(t *testing.T) { + approval := contracts.ApprovalCeremony{ + ApprovalID: "ap-wrong", + Subject: workstation.ShellGateApprovalSubject, + Action: workstation.ShellGateApprovalAction, + State: contracts.ApprovalCeremonyAllowed, + RequestedBy: "operator.cli", + BindingHash: workstation.ShellCommandBindingRef("rm /tmp/safe"), + Reason: "approved; " + workstation.ShellCommandBinding("rm /tmp/safe"), + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]string + _ = json.NewDecoder(r.Body).Decode(&body) + if body["binding_hash"] != approval.BindingHash { + http.Error(w, "wrong binding", http.StatusBadRequest) + return + } + _ = json.NewEncoder(w).Encode(approval) + })) + defer server.Close() + t.Setenv(serviceAPIKeyEnv, "test-key") + + code, _, errOut := runGateForTest(t, + "--profile", "dev", + "--allowlist", gateTestAllowlist(t, []string{"ls"}), + "--data-dir", t.TempDir(), + "--approval-id", approval.ApprovalID, + "--url", server.URL, + "--command", "rm /etc/passwd", + ) + if code != 1 || !strings.Contains(errOut, "400") { + t.Fatalf("exit = %d err=%s, want command-binding rejection", code, errOut) + } +} + +func TestWorkstationGateRequestApprovalServerDown(t *testing.T) { + t.Setenv(serviceAPIKeyEnv, "test-key") + allowlist := gateTestAllowlist(t, []string{"ls"}) + code, _, errOut := runGateForTest(t, + "--profile", "dev", + "--allowlist", allowlist, + "--request-approval", + "--url", "http://127.0.0.1:1", + "--command", "rm /x", + ) + if code != 1 { + t.Fatalf("exit = %d, want 1 when the approval request fails", code) + } + if !strings.Contains(errOut, "approval request failed") { + t.Fatalf("stderr missing failure detail:\n%s", errOut) + } +} + +func TestWorkstationGateJSONOutput(t *testing.T) { + allowlist := gateTestAllowlist(t, []string{"ls"}) + code, out, _ := runGateForTest(t, "--allowlist", allowlist, "--json", "--command", "echo $(rm /x)") + if code != exitGateDeny { + t.Fatalf("exit = %d, want %d", code, exitGateDeny) + } + var decision map[string]any + if err := json.Unmarshal([]byte(out), &decision); err != nil { + t.Fatalf("output is not JSON: %v\n%s", err, out) + } + if decision["verdict"] != "deny" { + t.Fatalf("verdict = %v, want deny", decision["verdict"]) + } +} + +func TestWorkstationGateRequiresCommand(t *testing.T) { + code, _, _ := runGateForTest(t, "--profile", "dev") + if code != 2 { + t.Fatalf("exit = %d, want 2 for missing command", code) + } +} diff --git a/core/go.mod b/core/go.mod index 3a05213f3..973fdd48b 100644 --- a/core/go.mod +++ b/core/go.mod @@ -12,6 +12,7 @@ require ( github.com/aws/aws-sdk-go-v2/credentials v1.19.14 github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0 github.com/cedar-policy/cedar-go v1.6.0 + github.com/charmbracelet/bubbletea v1.3.10 github.com/cloudflare/circl v1.6.3 github.com/fxamacker/cbor/v2 v2.9.0 github.com/go-jose/go-jose/v4 v4.1.4 @@ -71,9 +72,15 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect github.com/aws/smithy-go v1.24.2 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect @@ -81,6 +88,7 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-ini/ini v1.67.0 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -102,9 +110,15 @@ require ( github.com/lestrrat-go/httprc/v3 v3.0.2 // indirect github.com/lestrrat-go/jwx/v3 v3.0.13 // indirect github.com/lestrrat-go/option/v2 v2.0.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect github.com/minio/crc64nvme v1.1.1 // indirect github.com/minio/md5-simd v1.1.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/philhofer/fwd v1.2.0 // indirect @@ -115,6 +129,7 @@ require ( github.com/prometheus/procfs v0.17.0 // indirect github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/rs/xid v1.6.0 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/sirupsen/logrus v1.9.4 // indirect @@ -127,6 +142,7 @@ require ( github.com/x448/float16 v0.8.4 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yashtewari/glob-intersection v0.2.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.43.0 // indirect diff --git a/core/go.sum b/core/go.sum index 24b742eeb..b929ca751 100644 --- a/core/go.sum +++ b/core/go.sum @@ -80,6 +80,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBU github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw= github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= @@ -94,6 +96,18 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= @@ -121,6 +135,8 @@ github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= @@ -198,8 +214,14 @@ github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLO github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM= github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk= github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= @@ -208,6 +230,12 @@ github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= github.com/minio/minio-go/v7 v7.0.100 h1:ShkWi8Tyj9RtU57OQB2HIXKz4bFgtVib0bbT1sbtLI8= github.com/minio/minio-go/v7 v7.0.100/go.mod h1:EtGNKtlX20iL2yaYnxEigaIvj0G0GwSDnifnG8ClIdw= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= @@ -235,6 +263,9 @@ github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfS github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= @@ -271,6 +302,8 @@ github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMc github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yashtewari/glob-intersection v0.2.0 h1:8iuHdN88yYuCzCdjt0gDe+6bAhUwBeEWqThExu54RFg= github.com/yashtewari/glob-intersection v0.2.0/go.mod h1:LK7pIC3piUjovexikBbJ26Yml7g8xa5bsjfx2v1fwok= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= @@ -323,6 +356,7 @@ golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/core/pkg/boundary/surface_registry.go b/core/pkg/boundary/surface_registry.go index 1c1d837bf..2a4f43d2f 100644 --- a/core/pkg/boundary/surface_registry.go +++ b/core/pkg/boundary/surface_registry.go @@ -464,12 +464,19 @@ func (r *SurfaceRegistry) ListCheckpoints() []contracts.BoundaryCheckpoint { } func (r *SurfaceRegistry) PutApproval(approval contracts.ApprovalCeremony) (contracts.ApprovalCeremony, error) { + r.mu.Lock() + defer r.mu.Unlock() + if _, exists := r.approvals[approval.ApprovalID]; exists { + return contracts.ApprovalCeremony{}, fmt.Errorf("approval %q already exists", approval.ApprovalID) + } + return r.putApprovalLocked(approval) +} + +func (r *SurfaceRegistry) putApprovalLocked(approval contracts.ApprovalCeremony) (contracts.ApprovalCeremony, error) { sealed, err := approval.Seal() if err != nil { return contracts.ApprovalCeremony{}, err } - r.mu.Lock() - defer r.mu.Unlock() r.approvals[sealed.ApprovalID] = sealed if err := r.appendEventLocked("approval", sealed.ApprovalID, sealed); err != nil { return contracts.ApprovalCeremony{}, err @@ -492,23 +499,31 @@ func (r *SurfaceRegistry) ListApprovals() []contracts.ApprovalCeremony { } func (r *SurfaceRegistry) TransitionApproval(id string, state contracts.ApprovalCeremonyState, actor, receiptID, reason string) (contracts.ApprovalCeremony, error) { - r.mu.RLock() + r.mu.Lock() + defer r.mu.Unlock() + return r.transitionApprovalLocked(id, state, actor, receiptID, reason) +} + +func (r *SurfaceRegistry) transitionApprovalLocked(id string, state contracts.ApprovalCeremonyState, actor, receiptID, reason string) (contracts.ApprovalCeremony, error) { approval, ok := r.approvals[id] - r.mu.RUnlock() if !ok { return contracts.ApprovalCeremony{}, fmt.Errorf("approval %q not found", id) } + if approval.State != contracts.ApprovalCeremonyPending && + !(approval.State == contracts.ApprovalCeremonyAllowed && state == contracts.ApprovalCeremonyRevoked) { + return contracts.ApprovalCeremony{}, fmt.Errorf("approval %q cannot transition from %s to %s", id, approval.State, state) + } now := r.now().UTC() if !approval.ExpiresAt.IsZero() && now.After(approval.ExpiresAt) && state == contracts.ApprovalCeremonyAllowed { approval.State = contracts.ApprovalCeremonyExpired approval.UpdatedAt = now approval.Reason = "approval expired before assertion" - return r.PutApproval(approval) + return r.putApprovalLocked(approval) } if state == contracts.ApprovalCeremonyAllowed && !approval.TimelockUntil.IsZero() && now.Before(approval.TimelockUntil) { approval.UpdatedAt = now approval.Reason = "approval timelock has not elapsed" - return r.PutApproval(approval) + return r.putApprovalLocked(approval) } if state == contracts.ApprovalCeremonyAllowed && approval.BreakGlass && (strings.TrimSpace(reason) == "" || strings.TrimSpace(receiptID) == "") { return contracts.ApprovalCeremony{}, fmt.Errorf("break-glass approval requires reason and receipt_id") @@ -539,7 +554,7 @@ func (r *SurfaceRegistry) TransitionApproval(id string, state contracts.Approval approval.Reason = fmt.Sprintf( "approval requires a %d-party quorum, which cannot be established from an asserted actor name; "+ "verified approver credentials are required", quorumFor(approval)) - return r.PutApproval(approval) + return r.putApprovalLocked(approval) } } @@ -559,7 +574,7 @@ func (r *SurfaceRegistry) TransitionApproval(id string, state contracts.Approval approval.Reason = fmt.Sprintf("approval quorum pending: %d/%d", len(approval.Approvers), quorum) } } - return r.PutApproval(approval) + return r.putApprovalLocked(approval) } // quorumFor normalises an unset quorum to single-approver. @@ -618,15 +633,6 @@ func (r *SurfaceRegistry) AssertApprovalChallenge(assertion contracts.ApprovalWe if strings.TrimSpace(assertion.ChallengeID) == "" || strings.TrimSpace(assertion.Actor) == "" || strings.TrimSpace(assertion.Assertion) == "" { return contracts.ApprovalCeremony{}, fmt.Errorf("challenge_id, actor, and assertion are required") } - r.mu.RLock() - challenge, ok := r.challenges[assertion.ChallengeID] - r.mu.RUnlock() - if !ok { - return contracts.ApprovalCeremony{}, fmt.Errorf("approval challenge %q not found", assertion.ChallengeID) - } - if r.now().UTC().After(challenge.ExpiresAt) { - return contracts.ApprovalCeremony{}, fmt.Errorf("approval challenge expired") - } assertionHash, err := canonicalize.CanonicalHash(map[string]string{ "challenge_id": assertion.ChallengeID, "actor": assertion.Actor, @@ -635,7 +641,16 @@ func (r *SurfaceRegistry) AssertApprovalChallenge(assertion contracts.ApprovalWe if err != nil { return contracts.ApprovalCeremony{}, err } - approval, err := r.TransitionApproval(challenge.ApprovalID, contracts.ApprovalCeremonyAllowed, assertion.Actor, assertion.ReceiptID, assertion.Reason) + r.mu.Lock() + defer r.mu.Unlock() + challenge, ok := r.challenges[assertion.ChallengeID] + if !ok { + return contracts.ApprovalCeremony{}, fmt.Errorf("approval challenge %q not found", assertion.ChallengeID) + } + if r.now().UTC().After(challenge.ExpiresAt) { + return contracts.ApprovalCeremony{}, fmt.Errorf("approval challenge expired") + } + approval, err := r.transitionApprovalLocked(challenge.ApprovalID, contracts.ApprovalCeremonyAllowed, assertion.Actor, assertion.ReceiptID, assertion.Reason) if err != nil { return contracts.ApprovalCeremony{}, err } @@ -643,16 +658,14 @@ func (r *SurfaceRegistry) AssertApprovalChallenge(assertion contracts.ApprovalWe approval.ChallengeID = challenge.ChallengeID approval.ChallengeHash = challenge.ChallengeHash approval.AssertionHash = "sha256:" + assertionHash - sealed, err := r.PutApproval(approval) + sealed, err := r.putApprovalLocked(approval) if err != nil { return contracts.ApprovalCeremony{}, err } challenge.Verified = sealed.State == contracts.ApprovalCeremonyAllowed challenge.AssertionHash = sealed.AssertionHash - r.mu.Lock() r.challenges[challenge.ChallengeID] = challenge err = r.persistLocked() - r.mu.Unlock() if err != nil { return contracts.ApprovalCeremony{}, err } diff --git a/core/pkg/boundary/surface_registry_test.go b/core/pkg/boundary/surface_registry_test.go index a6cc2fcdd..25bf25bfe 100644 --- a/core/pkg/boundary/surface_registry_test.go +++ b/core/pkg/boundary/surface_registry_test.go @@ -3,8 +3,10 @@ package boundary import ( "context" "database/sql" + "fmt" "path/filepath" "strings" + "sync" "testing" "time" @@ -68,6 +70,55 @@ func TestApprovalTransitionSealsCeremony(t *testing.T) { } } +func TestApprovalTransitionPreservesImmutableBinding(t *testing.T) { + now := time.Date(2026, 5, 5, 12, 0, 0, 0, time.UTC) + registry := NewSurfaceRegistry(func() time.Time { return now }) + pending, err := registry.PutApproval(contracts.ApprovalCeremony{ + ApprovalID: "approval-command-bound", + Subject: "shell_command", + Action: "shell_operate", + State: contracts.ApprovalCeremonyPending, + RequestedBy: "agent.local", + BindingHash: "sha256:command-binding", + Reason: "request details", + CreatedAt: now, + UpdatedAt: now, + }) + if err != nil { + t.Fatal(err) + } + approved, err := registry.TransitionApproval( + pending.ApprovalID, + contracts.ApprovalCeremonyAllowed, + "operator.cli", + "", + "approver-controlled reason", + ) + if err != nil { + t.Fatal(err) + } + if approved.BindingHash != pending.BindingHash { + t.Fatalf("binding changed across transition: got %q want %q", approved.BindingHash, pending.BindingHash) + } + if approved.Reason != "approver-controlled reason" { + t.Fatalf("reason = %q, want mutable audit note", approved.Reason) + } +} + +func TestApprovedApprovalCanOnlyBeRevokedOnce(t *testing.T) { + now := time.Date(2026, 5, 5, 12, 0, 0, 0, time.UTC) + registry := NewSurfaceRegistry(func() time.Time { return now }) + if _, err := registry.TransitionApproval("approval-bootstrap", contracts.ApprovalCeremonyAllowed, "user:alice", "rcpt-1", "reviewed"); err != nil { + t.Fatal(err) + } + if _, err := registry.TransitionApproval("approval-bootstrap", contracts.ApprovalCeremonyRevoked, "workstation.shellgate", "", "consumed"); err != nil { + t.Fatalf("first revoke: %v", err) + } + if _, err := registry.TransitionApproval("approval-bootstrap", contracts.ApprovalCeremonyRevoked, "workstation.shellgate", "", "consumed"); err == nil { + t.Fatal("second revoke must fail atomically") + } +} + func TestApprovalTransitionEnforcesQuorumAndTimelock(t *testing.T) { now := time.Date(2026, 5, 5, 12, 0, 0, 0, time.UTC) registry := NewSurfaceRegistry(func() time.Time { return now }) @@ -144,6 +195,71 @@ func TestApprovalChallengeAssertionBindsPasskeyEvidence(t *testing.T) { } } +func TestApprovalChallengeAssertionCannotOverwriteConcurrentRevocation(t *testing.T) { + now := time.Date(2026, 5, 5, 12, 0, 0, 0, time.UTC) + for i := 0; i < 100; i++ { + registry := NewSurfaceRegistry(func() time.Time { return now }) + approvalID := fmt.Sprintf("approval-concurrent-%d", i) + if _, err := registry.PutApproval(contracts.ApprovalCeremony{ + ApprovalID: approvalID, + Subject: "shell_command", + Action: "shell_operate", + State: contracts.ApprovalCeremonyPending, + RequestedBy: "agent.local", + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + t.Fatal(err) + } + challenge, err := registry.CreateApprovalChallenge(approvalID, "passkey", time.Minute) + if err != nil { + t.Fatal(err) + } + + start := make(chan struct{}) + var wg sync.WaitGroup + wg.Add(2) + var revokeErr error + go func() { + defer wg.Done() + <-start + _, _ = registry.AssertApprovalChallenge(contracts.ApprovalWebAuthnAssertion{ + ChallengeID: challenge.ChallengeID, + Actor: "user:alice", + Assertion: "signed-client-data", + }) + }() + go func() { + defer wg.Done() + <-start + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + items := registry.ListApprovals() + for _, item := range items { + if item.ApprovalID == approvalID && item.State == contracts.ApprovalCeremonyAllowed { + _, revokeErr = registry.TransitionApproval(approvalID, contracts.ApprovalCeremonyRevoked, "workstation.shellgate", "", "consumed") + return + } + } + } + }() + close(start) + wg.Wait() + + if revokeErr == nil { + var final contracts.ApprovalCeremony + for _, item := range registry.ListApprovals() { + if item.ApprovalID == approvalID { + final = item + } + } + if final.State != contracts.ApprovalCeremonyRevoked { + t.Fatalf("iteration %d: successful revoke was overwritten: %+v", i, final) + } + } + } +} + func TestFileBackedSurfaceRegistryPersistsRecords(t *testing.T) { now := time.Date(2026, 5, 5, 12, 0, 0, 0, time.UTC) path := filepath.Join(t.TempDir(), "surfaces.json") diff --git a/core/pkg/contracts/boundary_surfaces.go b/core/pkg/contracts/boundary_surfaces.go index 10b096c12..070ba84a7 100644 --- a/core/pkg/contracts/boundary_surfaces.go +++ b/core/pkg/contracts/boundary_surfaces.go @@ -1,6 +1,8 @@ package contracts import ( + "crypto/rand" + "encoding/hex" "fmt" "strings" "time" @@ -136,6 +138,7 @@ type ApprovalCeremony struct { ChallengeID string `json:"challenge_id,omitempty"` ChallengeHash string `json:"challenge_hash,omitempty"` AssertionHash string `json:"assertion_hash,omitempty"` + BindingHash string `json:"binding_hash,omitempty"` Reason string `json:"reason,omitempty"` ReceiptID string `json:"receipt_id,omitempty"` BoundaryRecordID string `json:"boundary_record_id,omitempty"` @@ -367,3 +370,11 @@ func SurfaceID(prefix, value string) string { } return prefix + "-" + normalized } + +func NewSurfaceID(prefix string) (string, error) { + var entropy [16]byte + if _, err := rand.Read(entropy[:]); err != nil { + return "", fmt.Errorf("generate %s id: %w", prefix, err) + } + return SurfaceID(prefix, hex.EncodeToString(entropy[:])), nil +} diff --git a/core/pkg/workstation/shellallowlist.go b/core/pkg/workstation/shellallowlist.go new file mode 100644 index 000000000..47f359eff --- /dev/null +++ b/core/pkg/workstation/shellallowlist.go @@ -0,0 +1,261 @@ +// shellallowlist.go — user-editable shell allowlist file with stable reads. +// +// Attribution: the file format tolerance (bare array / {"allowedCommands"} / +// truthy map) are adapted from Rowboat (Apache-2.0), +// apps/cli/src/config/security.ts. This is an original Go implementation; no +// Rowboat code is copied verbatim. +// +// Fail-closed deviations from Rowboat: +// - A corrupt or unreadable allowlist file is an error, not a silent fallback +// to the defaults. Callers must treat the error as "everything blocked". +// - Each read verifies the opened file against the path and reads identical +// bytes twice, so replacement or in-place mutation fails closed. +package workstation + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + "sync" +) + +// DefaultShellAllowlist is the minimal read-only set seeded on first use. +// curl and echo are deliberately NOT in the shipped defaults: `curl -o file +// URL` and `echo x > file` are arbitrary writes, and an allowlisted writer +// defeats the gate. Operators who need them add them explicitly. +var DefaultShellAllowlist = []string{ + "cat", + "grep", + "jq", + "ls", + "pwd", + "whoami", +} + +// ShellAllowlistFilename is the allowlist file name under the workstation +// data directory. +const ShellAllowlistFilename = "shell-allowlist.json" + +// DefaultShellAllowlistPath returns the default allowlist path inside the +// given data directory (e.g. defaultSetupDataDir()). +func DefaultShellAllowlistPath(dataDir string) string { + return filepath.Join(dataDir, "workstation", ShellAllowlistFilename) +} + +// ShellAllowlistStore reads a user-editable JSON allowlist file. It is safe +// for concurrent use. +type ShellAllowlistStore struct { + path string + mu sync.Mutex +} + +// NewShellAllowlistStore creates a store rooted at path. +func NewShellAllowlistStore(path string) *ShellAllowlistStore { + return &ShellAllowlistStore{path: path} +} + +// Path returns the allowlist file path. +func (s *ShellAllowlistStore) Path() string { + return s.path +} + +// Allowlist returns the current allowlist. A missing file is seeded with +// DefaultShellAllowlist. Parse and I/O failures return an error — callers must +// fail closed. The allowlist file must be a regular file (never a symlink or +// special file) and must not be writable by group or others. +func (s *ShellAllowlistStore) Allowlist() ([]string, error) { + s.mu.Lock() + defer s.mu.Unlock() + + _, err := os.Lstat(s.path) + if err != nil { + if !os.IsNotExist(err) { + return nil, fmt.Errorf("stat shell allowlist %s: %w", s.path, err) + } + if err := s.seedLocked(); err != nil { + return nil, err + } + } + allowlist, err := readStableShellAllowlistFile(s.path) + if err != nil { + return nil, err + } + return allowlist, nil +} + +// validateShellAllowlistInfo enforces the file-safety invariants of the +// allowlist: regular file, no symlink, not writable by group/others. +func validateShellAllowlistInfo(path string, info os.FileInfo) error { + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return fmt.Errorf("shell allowlist %s must be a regular file, not a symlink or special file", path) + } + if info.Mode().Perm()&0o022 != 0 { + return fmt.Errorf("shell allowlist %s must not be writable by group or others (chmod 0600)", path) + } + return nil +} + +// Reset remains for API compatibility. Allowlist always performs a stable +// read, so there is no cache to clear. +func (s *ShellAllowlistStore) Reset() { + s.mu.Lock() + defer s.mu.Unlock() +} + +// seedLocked writes the default allowlist to a missing file with restrictive +// permissions (directory 0700, file 0600). +func (s *ShellAllowlistStore) seedLocked() error { + dir := filepath.Dir(s.path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("create shell allowlist directory %s: %w", dir, err) + } + data, err := json.MarshalIndent(DefaultShellAllowlist, "", " ") + if err != nil { + return fmt.Errorf("encode default shell allowlist: %w", err) + } + file, err := os.OpenFile(s.path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return fmt.Errorf("seed shell allowlist %s: %w", s.path, err) + } + if _, err := file.Write(append(data, '\n')); err != nil { + _ = file.Close() + return fmt.Errorf("seed shell allowlist %s: %w", s.path, err) + } + if err := file.Sync(); err != nil { + _ = file.Close() + return fmt.Errorf("sync shell allowlist %s: %w", s.path, err) + } + if err := file.Close(); err != nil { + return fmt.Errorf("close shell allowlist %s: %w", s.path, err) + } + return nil +} + +// readShellAllowlistFile parses the allowlist file. Accepted forms mirror the +// Rowboat security config: +// - a bare JSON array: ["ls", "cat"] +// - an object with an allowedCommands array: {"allowedCommands": ["ls"]} +// - a truthy map: {"ls": true, "rm": false} → ["ls"] +func parseShellAllowlist(path string, data []byte) ([]string, error) { + var payload any + if err := json.Unmarshal(data, &payload); err != nil { + return nil, fmt.Errorf("parse shell allowlist %s: %w", path, err) + } + switch value := payload.(type) { + case []any: + return normalizeShellAllowlist(value), nil + case map[string]any: + if raw, ok := value["allowedCommands"]; ok { + entries, ok := raw.([]any) + if !ok { + return nil, fmt.Errorf("parse shell allowlist %s: allowedCommands must be an array", path) + } + return normalizeShellAllowlist(entries), nil + } + var truthy []any + for key, entry := range value { + if jsonTruthy(entry) { + truthy = append(truthy, key) + } + } + return normalizeShellAllowlist(truthy), nil + default: + return nil, fmt.Errorf("parse shell allowlist %s: expected array or object", path) + } +} + +func readStableShellAllowlistFile(path string) ([]string, error) { + const attempts = 3 + for attempt := 0; attempt < attempts; attempt++ { + first, err := readShellAllowlistBytes(path) + if err != nil { + return nil, err + } + second, err := readShellAllowlistBytes(path) + if err != nil { + return nil, err + } + if bytes.Equal(first, second) { + return parseShellAllowlist(path, first) + } + } + return nil, fmt.Errorf("read shell allowlist %s: file changed during stable read", path) +} + +func readShellAllowlistBytes(path string) ([]byte, error) { + pathInfo, err := os.Lstat(path) + if err != nil { + return nil, fmt.Errorf("stat shell allowlist %s: %w", path, err) + } + if err := validateShellAllowlistInfo(path, pathInfo); err != nil { + return nil, err + } + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open shell allowlist %s: %w", path, err) + } + defer file.Close() + openedInfo, err := file.Stat() + if err != nil { + return nil, fmt.Errorf("stat opened shell allowlist %s: %w", path, err) + } + if !os.SameFile(pathInfo, openedInfo) { + return nil, fmt.Errorf("read shell allowlist %s: path changed before open", path) + } + data, err := io.ReadAll(file) + if err != nil { + return nil, fmt.Errorf("read shell allowlist %s: %w", path, err) + } + currentInfo, err := os.Lstat(path) + if err != nil { + return nil, fmt.Errorf("re-stat shell allowlist %s: %w", path, err) + } + if !os.SameFile(openedInfo, currentInfo) { + return nil, fmt.Errorf("read shell allowlist %s: path changed during read", path) + } + return data, nil +} + +// jsonTruthy mirrors JavaScript truthiness for decoded JSON values. +func jsonTruthy(value any) bool { + switch v := value.(type) { + case nil: + return false + case bool: + return v + case float64: + return v != 0 + case string: + return v != "" + default: + return true + } +} + +// normalizeShellAllowlist keeps string entries only, trims, lowercases, +// de-duplicates, and sorts. +func normalizeShellAllowlist(entries []any) []string { + seen := make(map[string]struct{}, len(entries)) + for _, entry := range entries { + text, ok := entry.(string) + if !ok { + continue + } + normalized := strings.ToLower(strings.TrimSpace(text)) + if normalized == "" { + continue + } + seen[normalized] = struct{}{} + } + out := make([]string, 0, len(seen)) + for name := range seen { + out = append(out, name) + } + sort.Strings(out) + return out +} diff --git a/core/pkg/workstation/shellapproval.go b/core/pkg/workstation/shellapproval.go new file mode 100644 index 000000000..195d9534b --- /dev/null +++ b/core/pkg/workstation/shellapproval.go @@ -0,0 +1,58 @@ +// shellapproval.go — binding between a shell gate escalation and the approval +// ceremony that authorizes it. +// +// An approval ceremony created for a blocked shell command carries a binding +// token derived from the exact command line (args included): the ceremony +// authorizes that command line and nothing else. When the gate re-checks a +// pending command, it consumes a matching approved ceremony server-side; a +// ceremony approved for a different command never satisfies the gate. +package workstation + +import ( + "crypto/sha256" + "encoding/hex" + "strings" +) + +// ShellGateApprovalSubject and ShellGateApprovalAction identify approval +// ceremonies created by the shell gate. +const ( + ShellGateApprovalSubject = "shell_command" + ShellGateApprovalAction = "shell_operate" +) + +// shellGateBindingPrefix prefixes the binding token embedded in the approval +// reason so it is greppable by operators and parseable by the gate. +const shellGateBindingPrefix = "shellgate-binding=sha256:" + +// ShellCommandBindingHash returns the hex SHA-256 of the exact command line. +// The hash covers the full line, arguments included, so an approval for +// `rm /tmp/a` never authorizes `rm /etc/b`. +func ShellCommandBindingHash(command string) string { + sum := sha256.Sum256([]byte(command)) + return hex.EncodeToString(sum[:]) +} + +// ShellCommandBinding returns the binding token to embed in an approval +// ceremony for command. +func ShellCommandBinding(command string) string { + return shellGateBindingPrefix + ShellCommandBindingHash(command) +} + +// ShellCommandBindingRef returns the structured immutable binding stored on +// the approval ceremony. Human-readable reasons are deliberately excluded. +func ShellCommandBindingRef(command string) string { + return "sha256:" + ShellCommandBindingHash(command) +} + +// ApprovalBindsToCommand reports whether a structured ceremony binding covers +// exactly this command line. +func ApprovalBindsToCommand(bindingHash, command string) bool { + return bindingHash == ShellCommandBindingRef(command) +} + +// ApprovalReasonMatchesBinding checks the optional human-readable shell token +// against the immutable structured binding shown to an approver. +func ApprovalReasonMatchesBinding(reason, bindingHash string) bool { + return strings.Contains(reason, shellGateBindingPrefix+strings.TrimPrefix(bindingHash, "sha256:")) +} diff --git a/core/pkg/workstation/shellgate.go b/core/pkg/workstation/shellgate.go new file mode 100644 index 000000000..714b31629 --- /dev/null +++ b/core/pkg/workstation/shellgate.go @@ -0,0 +1,501 @@ +// shellgate.go — command-name extraction and the escalating shell gate for the +// workstation boundary. +// +// Attribution: the extraction and allowlist semantics implemented here are +// adapted from Rowboat (Apache-2.0), apps/cli/src/application/lib/command-executor.ts +// (extractCommandNames / isBlocked). This file is an original Go implementation +// of those mechanisms for the HELM workstation boundary; no Rowboat code is +// copied verbatim. +// +// Deliberate hardening deviations from the Rowboat semantics (fail-closed +// beats convenient): +// - Wrapper unwrapping is recursive: `sudo env time rm x` extracts +// {sudo, env, time, rm} instead of only the wrapper and its immediate next +// token. More names must be allowlisted, never fewer. +// - After a wrapper, leading ENV=value assignments and bare `-` flags are +// skipped before resolving the wrapped command, so `env FOO=1 rm x` +// extracts {env, rm} (Rowboat extracts {env, "foo=1"}, which blocks by +// accident rather than by policy). Flags that take separate values +// (e.g. `sudo -u root rm x`) are modeled per wrapper so their values +// cannot shadow the wrapped command. +// - Unknown gate profiles normalize to production (deny), never to dev. +// - The gate is redirection-aware: output redirections (`>`, `>>`, `>|`) +// and downloader output flags (`-o`, `--output`, `-O`, `--remote-name`, +// `--output-document`) are treated as writes that always require an +// approval (dev) or a denial (production), even when every command name +// is allowlisted. An allowlisted `cat` must not become `cat x > /etc/y`. +// +// Threat-model limit (documented, accepted): gating is command-name and +// write-target based, not a full shell parser. Glob expansions and arguments +// that a program itself interprets as write destinations (e.g. `tee file`, +// `dd of=file`, `sed -i`) are out of scope; programs with intrinsic write +// behavior must stay off the allowlist. Redirection scanning understands +// quoted operators and targets but may still false-positive on unusual +// unquoted `>` usage — that fails closed. +package workstation + +import ( + "regexp" + "sort" + "strings" +) + +// commandSplitPattern splits a shell command line into segments at every +// construct that can start a new command: pipes, logical operators, command +// separators, background execution, command substitution (backticks and +// $(...)), and subshell open parens. Order matters: `||` and `&&` must +// precede their single-character prefixes so the leftmost-longest +// alternation consumes the right token. Without `&`, backtick, `$(`, and +// `(`, `echo hi & rm /x`, `echo `+"`rm /x`"+`, and `echo $(rm /x)` would +// slip past the gate with only `echo` allowlisted. `)` is deliberately not a +// split point: `ls $(pwd)/x` would otherwise yield a bogus `/x` "command" +// from the suffix after the substitution. sanitizeCommandToken truncates at +// `)` instead, so the segment yields `pwd`. +var commandSplitPattern = regexp.MustCompile(`\|\||&&|&|;|\||\n|` + "`" + `|\$\(|\(`) + +// envAssignmentPattern matches leading ENV=value prefixes that are not command +// names (e.g. `FOO=bar ls`). +var envAssignmentPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*=`) + +// wrapperCommands are command wrappers whose first real argument is itself a +// command that must also be allowlisted. +var wrapperCommands = map[string]struct{}{ + "sudo": {}, + "env": {}, + "time": {}, + "command": {}, +} + +// wrapperValueFlags are wrapper flags that consume the next token as a value +// (e.g. `sudo -u root`, `env -u NAME`, `time -o FILE`). The value token must +// be skipped with the flag so it neither shadows nor replaces the wrapped +// command: `sudo -u root rm /x` must extract {sudo, rm}, never stop at +// `root`. Long `--flag=value` forms carry no separate token and are skipped +// as bare flags. +var wrapperValueFlags = map[string]map[string]struct{}{ + "sudo": { + "-u": {}, "--user": {}, "-g": {}, "--group": {}, "-h": {}, "--host": {}, + "-p": {}, "--prompt": {}, "-C": {}, "--chdir": {}, + }, + "env": { + "-u": {}, "--unset": {}, "-C": {}, "--chdir": {}, "-S": {}, "--split-string": {}, + }, + "time": { + "-o": {}, "--output": {}, "-f": {}, "--format": {}, + }, +} + +// ExtractCommandNames returns the sorted, de-duplicated, lowercased set of +// command names a shell command line would invoke. It is robust to chaining +// (&&, ||, |, ;, &), command substitution (backticks, $(...)), subshells, +// leading ENV=value assignments, and sudo/env/time/command wrappers. +func ExtractCommandNames(command string) []string { + discovered := make(map[string]struct{}) + for _, segment := range commandSplitPattern.Split(command, -1) { + tokens := strings.Fields(segment) + if len(tokens) == 0 { + continue + } + index := 0 + for index < len(tokens) && envAssignmentPattern.MatchString(tokens[index]) { + index++ + } + if index >= len(tokens) { + continue + } + primary := sanitizeCommandToken(tokens[index]) + if primary == "" { + continue + } + discovered[primary] = struct{}{} + if _, isWrapper := wrapperCommands[primary]; isWrapper { + for _, wrapped := range unwrapWrappedCommands(primary, tokens[index+1:]) { + discovered[wrapped] = struct{}{} + } + } + } + names := make([]string, 0, len(discovered)) + for name := range discovered { + names = append(names, name) + } + sort.Strings(names) + if len(names) == 0 { + return nil + } + return names +} + +// unwrapWrappedCommands resolves the command names hidden behind one or more +// nested wrappers, including the intermediate wrappers themselves. Leading +// ENV=value assignments and bare `-` flags after a wrapper are skipped; flags +// known to take a separate value (sudo -u, env -u, time -o, …) are skipped +// together with their value so the value cannot shadow the wrapped command. +func unwrapWrappedCommands(activeWrapper string, tokens []string) []string { + var out []string + for i := 0; i < len(tokens); i++ { + token := tokens[i] + if envAssignmentPattern.MatchString(token) { + continue + } + if strings.HasPrefix(token, "-") { + // Bare wrapper flag (e.g. `sudo -E`, `time -p`). Value-taking + // flags consume the next token as well, so `sudo -u root rm /x` + // still resolves `rm` instead of stopping at `root`. Unknown + // value-taking flags may surface their value as a command name; + // that false positive fails closed and is accepted. + if _, takesValue := wrapperValueFlags[activeWrapper][token]; takesValue { + i++ + } + continue + } + name := sanitizeCommandToken(token) + if name == "" { + continue + } + out = append(out, name) + if _, isWrapper := wrapperCommands[name]; isWrapper { + activeWrapper = name + continue + } + break + } + return out +} + +// sanitizeCommandToken normalizes a raw token into a comparable command name: +// trimmed, unquoted, lowercased, and truncated at the first `)` so a command +// substitution suffix (`$(pwd)/x` → `pwd)/x`) cannot become a bogus command. +func sanitizeCommandToken(token string) string { + cleaned := strings.ToLower(strings.Trim(strings.TrimSpace(token), `'"`)) + if idx := strings.IndexByte(cleaned, ')'); idx >= 0 { + cleaned = cleaned[:idx] + } + return cleaned +} + +// BlockedCommandNames returns the invoked command names that are not present +// in the allowlist. Semantics mirror Rowboat's isBlocked: an empty allowlist +// blocks everything, and `*` allows everything. Allowlist entries are +// normalized (trimmed, lowercased) before comparison. +func BlockedCommandNames(command string, allowlist []string) []string { + invoked := ExtractCommandNames(command) + if len(invoked) == 0 { + return nil + } + if len(allowlist) == 0 { + return invoked + } + allowed := make(map[string]struct{}, len(allowlist)) + for _, entry := range allowlist { + if normalized := sanitizeCommandToken(entry); normalized != "" { + allowed[normalized] = struct{}{} + } + } + if _, wildcard := allowed["*"]; wildcard { + return nil + } + var blocked []string + for _, name := range invoked { + if _, ok := allowed[name]; !ok { + blocked = append(blocked, name) + } + } + return blocked +} + +// outputValueFlags are flags whose value is a file the command writes to +// (curl/wget style). The value may be inline (`--output=file`), concatenated +// (`-ofile`), or the next token (`-o file`). +var outputValueFlags = map[string]struct{}{ + "-o": {}, + "--output": {}, + "--output-document": {}, +} + +// outputBooleanFlags are flags that make the command write to a +// command-chosen file name (curl -O / --remote-name). +var outputBooleanFlags = map[string]struct{}{ + "-O": {}, + "--remote-name": {}, + "--remote-name-all": {}, +} + +// ExtractWriteTargets returns the write destinations a shell command line +// would create or overwrite: output redirections (`>`, `>>`, `>|`, with +// optional fd prefixes like `2>`) and downloader-style output flags. File +// descriptor duplication (`2>&1`) is not a write. Operators inside quoted +// strings are ignored, while quoted destinations are retained. Destinations +// containing shell expansion are returned as so the gate fails +// closed instead of treating an unresolved path as no write. +func ExtractWriteTargets(command string) []string { + seen := make(map[string]struct{}) + for _, target := range redirectionTargets(command) { + seen[target] = struct{}{} + } + for _, target := range outputFlagTargets(command) { + seen[target] = struct{}{} + } + for _, target := range inPlaceWriteTargets(command) { + seen[target] = struct{}{} + } + targets := make([]string, 0, len(seen)) + for target := range seen { + targets = append(targets, target) + } + sort.Strings(targets) + if len(targets) == 0 { + return nil + } + return targets +} + +// redirectionTargets scans for `>` / `>>` / `>|` output redirections. An +// optional fd prefix (`2>`, `&>`) is part of the operator. `>&1` duplicates a +// descriptor and is skipped; `>&file` redirects both streams and is a write. +func redirectionTargets(line string) []string { + var targets []string + var quote byte + for i := 0; i < len(line); i++ { + if line[i] == '\\' && quote != '\'' { + i++ + continue + } + if line[i] == '\'' || line[i] == '"' { + if quote == 0 { + quote = line[i] + } else if quote == line[i] { + quote = 0 + } + continue + } + if quote != 0 { + continue + } + if line[i] != '>' { + continue + } + j := i + 1 + if j < len(line) && line[j] == '>' { // append: >> + j++ + } + if j < len(line) && line[j] == '|' { // noclobber override: >| + j++ + } + if j < len(line) && line[j] == '&' { + j++ + if j < len(line) && ((line[j] >= '0' && line[j] <= '9') || line[j] == '-') { + i = j // fd duplication/closure: 2>&1 or 2>&- + continue + } + } + for j < len(line) && (line[j] == ' ' || line[j] == '\t') { + j++ + } + start := j + if j < len(line) && (line[j] == '$' || line[j] == '`') { + targets = append(targets, "") + i = j + continue + } + var targetQuote byte + for j < len(line) { + if line[j] == '\\' && targetQuote != '\'' && j+1 < len(line) { + j += 2 + continue + } + if line[j] == '\'' || line[j] == '"' { + if targetQuote == 0 { + targetQuote = line[j] + } else if targetQuote == line[j] { + targetQuote = 0 + } + j++ + continue + } + if targetQuote == 0 && strings.ContainsRune(" \t\r\n|;&<>()$`", rune(line[j])) { + break + } + j++ + } + if j > start { + target := strings.Trim(line[start:j], `"'`) + if strings.ContainsAny(target, "$`") { + target = "" + } + targets = append(targets, target) + } + i = j + } + return targets +} + +// inPlaceWriteTargets catches allowlisted tools whose flags turn a read into +// an in-place write. yq is intentionally absent from the default allowlist, +// but a user-added yq must still require approval when invoked with -i. +func inPlaceWriteTargets(command string) []string { + if !containsString(ExtractCommandNames(command), "yq") { + return nil + } + for _, field := range strings.Fields(command) { + if field == "-i" || field == "--in-place" { + return []string{""} + } + } + return nil +} + +func containsString(values []string, wanted string) bool { + for _, value := range values { + if value == wanted { + return true + } + } + return false +} + +// outputFlagTargets scans for downloader-style output flags: `-o file`, +// `--output file`, `--output=file`, `-ofile`, `-O`, `--remote-name`, +// `--output-document`. A trailing `-o` with no value, or `-o -` (stdout), is +// not a write. Unknown programs that reuse `-o` for non-write purposes may +// false-positive; that fails closed. +func outputFlagTargets(line string) []string { + fields := strings.Fields(line) + var targets []string + for i, field := range fields { + name, inline := field, "" + if strings.HasPrefix(field, "--") { + if idx := strings.IndexByte(field, '='); idx >= 0 { + name, inline = field[:idx], field[idx+1:] + } + } else if strings.HasPrefix(field, "-o") && len(field) > 2 && !strings.HasPrefix(field, "--") { + name, inline = "-o", field[2:] + } + if _, ok := outputBooleanFlags[name]; ok { + targets = append(targets, "") + continue + } + if _, ok := outputValueFlags[name]; !ok { + continue + } + switch { + case inline != "" && inline != "-": + targets = append(targets, inline) + case i+1 < len(fields) && fields[i+1] != "-": + targets = append(targets, fields[i+1]) + } + } + return targets +} + +// ShellGateProfile selects the failure mode of the shell gate. +type ShellGateProfile string + +const ( + // ShellGateProfileProduction fails closed: blocked commands are denied. + ShellGateProfileProduction ShellGateProfile = "production" + // ShellGateProfileDev escalates: blocked commands become pending approvals + // instead of hard failures. + ShellGateProfileDev ShellGateProfile = "dev" +) + +// NormalizeShellGateProfile maps a raw profile string to a gate profile. +// Anything other than "dev" resolves to production — fail closed. +func NormalizeShellGateProfile(raw string) ShellGateProfile { + if strings.EqualFold(strings.TrimSpace(raw), string(ShellGateProfileDev)) { + return ShellGateProfileDev + } + return ShellGateProfileProduction +} + +// ShellGateVerdict is the outcome of a shell gate evaluation. +type ShellGateVerdict string + +const ( + // ShellGateVerdictAllow — every invoked command name is allowlisted. + ShellGateVerdictAllow ShellGateVerdict = "allow" + // ShellGateVerdictPendingApproval — dev profile escalation: the command is + // not executed; it requires an approval ceremony first. + ShellGateVerdictPendingApproval ShellGateVerdict = "pending_approval" + // ShellGateVerdictDeny — production profile fail-closed denial. + ShellGateVerdictDeny ShellGateVerdict = "deny" +) + +// ShellGateDecision is the result of gating one shell command line. +type ShellGateDecision struct { + Verdict ShellGateVerdict `json:"verdict"` + Profile ShellGateProfile `json:"profile"` + Command string `json:"command"` + Invoked []string `json:"invoked_commands"` + Blocked []string `json:"blocked_commands,omitempty"` + WriteTargets []string `json:"write_targets,omitempty"` + Reason string `json:"reason,omitempty"` +} + +// gateReason explains why a command did not pass the gate, covering both +// blocked command names and detected write targets. +func gateReason(decision ShellGateDecision, dev bool) string { + mode := "are denied in the production profile" + if dev { + mode = "escalate to a pending approval in the dev profile" + } + var parts []string + if len(decision.Blocked) > 0 { + parts = append(parts, "blocked shell commands "+mode+": "+strings.Join(decision.Blocked, ", ")) + } + if len(decision.WriteTargets) > 0 { + parts = append(parts, "shell writes "+mode+": "+strings.Join(decision.WriteTargets, ", ")) + } + return strings.Join(parts, "; ") +} + +// GateShellCommand evaluates a shell command line against an allowlist under +// the given profile. Blocked command names and any detected write target +// (output redirection or output flag) are denied in the production profile +// (fail closed) and escalated to a pending approval in the dev profile — even +// when every command name is allowlisted, an allowlisted reader must not +// become a writer (`cat x > y`, `curl -o y url`). +func GateShellCommand(profile ShellGateProfile, command string, allowlist []string) ShellGateDecision { + decision := ShellGateDecision{ + Profile: profile, + Command: command, + Invoked: ExtractCommandNames(command), + Blocked: BlockedCommandNames(command, allowlist), + WriteTargets: ExtractWriteTargets(command), + } + if len(decision.Blocked) == 0 && len(decision.WriteTargets) == 0 { + decision.Verdict = ShellGateVerdictAllow + return decision + } + if profile == ShellGateProfileDev { + decision.Verdict = ShellGateVerdictPendingApproval + decision.Reason = gateReason(decision, true) + return decision + } + decision.Verdict = ShellGateVerdictDeny + decision.Reason = gateReason(decision, false) + return decision +} + +// GateShellCommandWithStore loads the allowlist from the store and gates the +// command. A store failure fails closed: production denies, dev escalates, +// with every invoked command treated as blocked. +func GateShellCommandWithStore(profile ShellGateProfile, command string, store *ShellAllowlistStore) ShellGateDecision { + allowlist, err := store.Allowlist() + if err == nil { + return GateShellCommand(profile, command, allowlist) + } + decision := ShellGateDecision{ + Profile: profile, + Command: command, + Invoked: ExtractCommandNames(command), + Blocked: ExtractCommandNames(command), + WriteTargets: ExtractWriteTargets(command), + Reason: "shell allowlist unavailable, failing closed: " + err.Error(), + } + if profile == ShellGateProfileDev { + decision.Verdict = ShellGateVerdictPendingApproval + return decision + } + decision.Verdict = ShellGateVerdictDeny + return decision +} diff --git a/core/pkg/workstation/shellgate_test.go b/core/pkg/workstation/shellgate_test.go new file mode 100644 index 000000000..b71ab5bd0 --- /dev/null +++ b/core/pkg/workstation/shellgate_test.go @@ -0,0 +1,375 @@ +package workstation + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "sort" + "testing" + "time" +) + +func TestExtractCommandNames(t *testing.T) { + cases := []struct { + name string + command string + want []string + }{ + {"simple", "ls -la", []string{"ls"}}, + {"pipe", "cat f | grep x", []string{"cat", "grep"}}, + {"and", "echo a && rm b", []string{"echo", "rm"}}, + {"or", "false || echo ok", []string{"echo", "false"}}, + {"or is not two pipes", "cat a.json || cat b.json", []string{"cat"}}, + {"semicolon", "ls; pwd", []string{"ls", "pwd"}}, + {"background", "sleep 1 & rm -rf /tmp/x", []string{"rm", "sleep"}}, + {"backticks", "echo `rm /x`", []string{"echo", "rm"}}, + {"dollar paren", "echo $(rm /x)", []string{"echo", "rm"}}, + {"subshell", "(rm /x)", []string{"rm"}}, + {"subshell chained", "echo hi && (cd /tmp && make)", []string{"cd", "echo", "make"}}, + {"newline", "ls\nrm /x", []string{"ls", "rm"}}, + {"env prefix", "FOO=bar ls", []string{"ls"}}, + {"multiple env prefixes", "FOO=bar BAZ=qux sudo rm /x", []string{"rm", "sudo"}}, + {"env prefix only", "FOO=bar", nil}, + {"sudo wrapper", "sudo rm /x", []string{"rm", "sudo"}}, + {"sudo value flag", "sudo -u root rm /x", []string{"rm", "sudo"}}, + {"env wrapper", "env rm /x", []string{"env", "rm"}}, + {"time wrapper", "time ls", []string{"ls", "time"}}, + {"command wrapper", "command ls", []string{"command", "ls"}}, + {"nested wrappers", "sudo time rm /x", []string{"rm", "sudo", "time"}}, + {"nested wrappers with env", "sudo env FOO=1 rm /x", []string{"env", "rm", "sudo"}}, + {"env wrapper skips assignments", "env FOO=bar rm /x", []string{"env", "rm"}}, + {"wrapper with flag", "time -p ls", []string{"ls", "time"}}, + {"wrapper alone", "sudo", []string{"sudo"}}, + {"quoted command", "'rm' /x", []string{"rm"}}, + {"double quoted command", `"curl" https://example.com`, []string{"curl"}}, + {"uppercase lowered", "SUDO RM /x", []string{"rm", "sudo"}}, + {"mixed chaining", "cat a | grep b && jq . || echo done", []string{"cat", "echo", "grep", "jq"}}, + {"substitution inside args", "ls $(pwd)/x", []string{"ls", "pwd"}}, + {"empty", "", nil}, + {"whitespace", " ", nil}, + {"separator only", "|", nil}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := ExtractCommandNames(tc.command) + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("ExtractCommandNames(%q) = %v, want %v", tc.command, got, tc.want) + } + }) + } +} + +func TestBlockedCommandNames(t *testing.T) { + allowlist := []string{"cat", "grep", "ls", "sudo", "echo"} + cases := []struct { + name string + command string + allowlist []string + want []string + }{ + {"all allowed", "cat f | grep x", allowlist, nil}, + {"one blocked", "cat f | rm x", allowlist, []string{"rm"}}, + {"blocked behind wrapper", "sudo rm /x", allowlist, []string{"rm"}}, + {"blocked behind substitution", "echo $(rm /x)", allowlist, []string{"rm"}}, + {"wildcard allows everything", "rm -rf /", []string{"*"}, nil}, + {"empty allowlist blocks everything", "ls", nil, []string{"ls"}}, + {"no commands blocks nothing", "", allowlist, nil}, + {"allowlist entries normalized", "LS -la", []string{" ls "}, nil}, + {"wrapper not allowlisted", "sudo ls", []string{"ls"}, []string{"sudo"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := BlockedCommandNames(tc.command, tc.allowlist) + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("BlockedCommandNames(%q, %v) = %v, want %v", tc.command, tc.allowlist, got, tc.want) + } + }) + } +} + +func TestGateShellCommandProfiles(t *testing.T) { + allowlist := []string{"ls"} + + t.Run("allowed in production", func(t *testing.T) { + decision := GateShellCommand(ShellGateProfileProduction, "ls -la", allowlist) + if decision.Verdict != ShellGateVerdictAllow { + t.Fatalf("verdict = %s, want allow", decision.Verdict) + } + }) + + t.Run("allowed in dev", func(t *testing.T) { + decision := GateShellCommand(ShellGateProfileDev, "ls", allowlist) + if decision.Verdict != ShellGateVerdictAllow { + t.Fatalf("verdict = %s, want allow", decision.Verdict) + } + }) + + t.Run("blocked in production denies", func(t *testing.T) { + decision := GateShellCommand(ShellGateProfileProduction, "rm -rf /", allowlist) + if decision.Verdict != ShellGateVerdictDeny { + t.Fatalf("verdict = %s, want deny", decision.Verdict) + } + if !reflect.DeepEqual(decision.Blocked, []string{"rm"}) { + t.Fatalf("blocked = %v, want [rm]", decision.Blocked) + } + }) + + t.Run("blocked in dev escalates to pending approval", func(t *testing.T) { + decision := GateShellCommand(ShellGateProfileDev, "rm -rf /", allowlist) + if decision.Verdict != ShellGateVerdictPendingApproval { + t.Fatalf("verdict = %s, want pending_approval", decision.Verdict) + } + if decision.Reason == "" { + t.Fatal("escalation must carry a reason") + } + }) + + t.Run("unknown profile fails closed as production", func(t *testing.T) { + if got := NormalizeShellGateProfile("staging"); got != ShellGateProfileProduction { + t.Fatalf("NormalizeShellGateProfile(staging) = %s, want production", got) + } + decision := GateShellCommand(NormalizeShellGateProfile("STAGING"), "rm x", allowlist) + if decision.Verdict != ShellGateVerdictDeny { + t.Fatalf("verdict = %s, want deny for unknown profile", decision.Verdict) + } + }) +} + +func TestGateShellCommandDetectsQuotedRedirectAndYQInPlace(t *testing.T) { + for _, command := range []string{ + `cat input > "/tmp/out"`, + `cat input >&/tmp/out`, + `OUT=/tmp/x; cat payload >$OUT`, + `cat payload >"$OUT"`, + "cat payload >`mktemp`", + `yq -i '.x = 1' config.yaml`, + `yq --in-place '.x = 1' config.yaml`, + } { + decision := GateShellCommand(ShellGateProfileProduction, command, []string{"cat", "yq"}) + if decision.Verdict != ShellGateVerdictDeny || len(decision.WriteTargets) == 0 { + t.Fatalf("GateShellCommand(%q) = %+v, want detected write denial", command, decision) + } + } + if got := ExtractWriteTargets(`OUT=/tmp/x; cat payload >$OUT`); len(got) != 1 || got[0] != "" { + t.Fatalf("dynamic redirect targets = %v, want []", got) + } + if got := ExtractWriteTargets(`echo "a > b"`); got != nil { + t.Fatalf("operator inside quoted text produced targets %v", got) + } + for _, command := range []string{`cat input 2>&1`, `cat input >&2`, `cat input 2>&-`} { + if got := ExtractWriteTargets(command); got != nil { + t.Fatalf("descriptor duplication %q produced write targets %v", command, got) + } + } +} + +func writeShellAllowlist(t *testing.T, path string, payload any, mode os.FileMode) time.Time { + t.Helper() + data, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal allowlist: %v", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(path, data, mode); err != nil { + t.Fatalf("write allowlist: %v", err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat allowlist: %v", err) + } + return info.ModTime() +} + +func TestShellAllowlistStoreSeedsDefaults(t *testing.T) { + path := filepath.Join(t.TempDir(), "workstation", ShellAllowlistFilename) + store := NewShellAllowlistStore(path) + + got, err := store.Allowlist() + if err != nil { + t.Fatalf("Allowlist: %v", err) + } + want := append([]string(nil), DefaultShellAllowlist...) + sort.Strings(want) + if !reflect.DeepEqual(got, want) { + t.Fatalf("seeded allowlist = %v, want %v", got, want) + } + info, err := os.Stat(path) + if err != nil { + t.Fatalf("seeded file missing: %v", err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("seeded file mode = %o, want 600", info.Mode().Perm()) + } +} + +func TestShellAllowlistSeedDoesNotFollowSymlink(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target") + if err := os.WriteFile(target, []byte("keep"), 0o600); err != nil { + t.Fatalf("write target: %v", err) + } + path := filepath.Join(dir, ShellAllowlistFilename) + if err := os.Symlink(target, path); err != nil { + t.Fatalf("symlink: %v", err) + } + + if err := NewShellAllowlistStore(path).seedLocked(); err == nil { + t.Fatal("seed through symlink must fail") + } + data, err := os.ReadFile(target) + if err != nil { + t.Fatalf("read target: %v", err) + } + if string(data) != "keep" { + t.Fatalf("symlink target changed to %q", data) + } +} + +func TestDefaultShellAllowlistExcludesYQ(t *testing.T) { + if containsString(DefaultShellAllowlist, "yq") { + t.Fatal("default allowlist must exclude yq because it can edit files in place") + } +} + +func TestDefaultShellAllowlistExcludesMutatingDate(t *testing.T) { + if containsString(DefaultShellAllowlist, "date") { + t.Fatal("default allowlist must exclude date because --set mutates the system clock") + } +} + +func TestShellAllowlistStoreFormats(t *testing.T) { + cases := []struct { + name string + payload any + want []string + }{ + {"bare array", []string{"LS", " cat ", "ls", ""}, []string{"cat", "ls"}}, + {"allowedCommands object", map[string]any{"allowedCommands": []string{"JQ", "ls"}}, []string{"jq", "ls"}}, + {"truthy map", map[string]any{"ls": true, "rm": false, "cat": 1, "dd": 0, "pwd": "yes", "xargs": ""}, []string{"cat", "ls", "pwd"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), ShellAllowlistFilename) + writeShellAllowlist(t, path, tc.payload, 0o600) + got, err := NewShellAllowlistStore(path).Allowlist() + if err != nil { + t.Fatalf("Allowlist: %v", err) + } + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("Allowlist = %v, want %v", got, tc.want) + } + }) + } +} + +func TestShellAllowlistStoreReloadsDespiteUnchangedMetadata(t *testing.T) { + path := filepath.Join(t.TempDir(), ShellAllowlistFilename) + store := NewShellAllowlistStore(path) + + firstMtime := writeShellAllowlist(t, path, []string{"ls"}, 0o600) + got, err := store.Allowlist() + if err != nil { + t.Fatalf("Allowlist: %v", err) + } + if !reflect.DeepEqual(got, []string{"ls"}) { + t.Fatalf("Allowlist = %v, want [ls]", got) + } + + // Rewrite with the same mtime and size: content, not metadata, is authority. + if err := os.WriteFile(path, []byte(`["dd"]`), 0o600); err != nil { + t.Fatalf("rewrite allowlist: %v", err) + } + if err := os.Chtimes(path, firstMtime, firstMtime); err != nil { + t.Fatalf("chtimes: %v", err) + } + got, err = store.Allowlist() + if err != nil { + t.Fatalf("Allowlist after same-mtime rewrite: %v", err) + } + if !reflect.DeepEqual(got, []string{"dd"}) { + t.Fatalf("Allowlist after same-metadata rewrite = %v, want [dd]", got) + } + + // Rewrite with a newer mtime: cache must reload. + secondMtime := firstMtime.Add(2 * time.Second) + if err := os.WriteFile(path, []byte(`["dd","ls"]`), 0o600); err != nil { + t.Fatalf("rewrite allowlist: %v", err) + } + if err := os.Chtimes(path, secondMtime, secondMtime); err != nil { + t.Fatalf("chtimes: %v", err) + } + got, err = store.Allowlist() + if err != nil { + t.Fatalf("Allowlist after mtime bump: %v", err) + } + if !reflect.DeepEqual(got, []string{"dd", "ls"}) { + t.Fatalf("reloaded Allowlist = %v, want [dd ls]", got) + } +} + +func TestShellAllowlistStoreFailClosed(t *testing.T) { + t.Run("corrupt file", func(t *testing.T) { + path := filepath.Join(t.TempDir(), ShellAllowlistFilename) + if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil { + t.Fatalf("write corrupt allowlist: %v", err) + } + if _, err := NewShellAllowlistStore(path).Allowlist(); err == nil { + t.Fatal("corrupt allowlist must fail closed with an error") + } + }) + + t.Run("scalar payload", func(t *testing.T) { + path := filepath.Join(t.TempDir(), ShellAllowlistFilename) + if err := os.WriteFile(path, []byte(`"ls"`), 0o600); err != nil { + t.Fatalf("write scalar allowlist: %v", err) + } + if _, err := NewShellAllowlistStore(path).Allowlist(); err == nil { + t.Fatal("scalar allowlist must fail closed with an error") + } + }) + + t.Run("gate fails closed on store error", func(t *testing.T) { + path := filepath.Join(t.TempDir(), ShellAllowlistFilename) + if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil { + t.Fatalf("write corrupt allowlist: %v", err) + } + store := NewShellAllowlistStore(path) + + prod := GateShellCommandWithStore(ShellGateProfileProduction, "ls", store) + if prod.Verdict != ShellGateVerdictDeny { + t.Fatalf("production verdict = %s, want deny", prod.Verdict) + } + dev := GateShellCommandWithStore(ShellGateProfileDev, "ls", store) + if dev.Verdict != ShellGateVerdictPendingApproval { + t.Fatalf("dev verdict = %s, want pending_approval", dev.Verdict) + } + }) +} + +func TestGateShellCommandWithStoreEscalationFlow(t *testing.T) { + path := filepath.Join(t.TempDir(), ShellAllowlistFilename) + writeShellAllowlist(t, path, []string{"ls", "cat"}, 0o600) + store := NewShellAllowlistStore(path) + + // Step 1: a blocked command escalates in dev. + blocked := GateShellCommandWithStore(ShellGateProfileDev, "cat f | rm x", store) + if blocked.Verdict != ShellGateVerdictPendingApproval { + t.Fatalf("verdict = %s, want pending_approval", blocked.Verdict) + } + if !reflect.DeepEqual(blocked.Blocked, []string{"rm"}) { + t.Fatalf("blocked = %v, want [rm]", blocked.Blocked) + } + + // Step 2: the operator approves by adding rm to the user-editable allowlist. + writeShellAllowlist(t, path, []string{"ls", "cat", "rm"}, 0o600) + + // Step 3: the same command now passes the gate without a store reset — + // the mtime cache must have reloaded. + allowed := GateShellCommandWithStore(ShellGateProfileDev, "cat f | rm x", store) + if allowed.Verdict != ShellGateVerdictAllow { + t.Fatalf("verdict after allowlist edit = %s, want allow", allowed.Verdict) + } +} diff --git a/sdk/go/client/execution_boundary_test.go b/sdk/go/client/execution_boundary_test.go index 33ba80c1b..c3ee29947 100644 --- a/sdk/go/client/execution_boundary_test.go +++ b/sdk/go/client/execution_boundary_test.go @@ -8,6 +8,10 @@ import ( "testing" ) +func stringPtr(value string) *string { + return &value +} + func TestExecutionBoundaryClientMethods(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc("/api/v1/evidence/envelopes", func(w http.ResponseWriter, r *http.Request) { @@ -32,7 +36,7 @@ func TestExecutionBoundaryClientMethods(t *testing.T) { writeJSON(t, w, ApprovalWebAuthnChallenge{"challenge_id": "ch1", "approval_id": "ap1"}) }) mux.HandleFunc("/api/v1/approvals/ap1/webauthn/assert", func(w http.ResponseWriter, r *http.Request) { - writeJSON(t, w, ApprovalCeremony{"approval_id": "ap1", "state": "approved"}) + writeJSON(t, w, ApprovalCeremony{"approval_id": "ap1", "state": "approved", "binding_hash": "sha256:command"}) }) mux.HandleFunc("/api/v1/conformance/negative", func(w http.ResponseWriter, r *http.Request) { writeJSON(t, w, []NegativeBoundaryVector{{ID: "pdp-outage", Category: "policy"}}) @@ -79,7 +83,7 @@ func TestExecutionBoundaryClientMethods(t *testing.T) { t.Fatalf("challenge = %#v, err = %v", challenge, err) } asserted, err := client.AssertApprovalWebAuthnChallenge("ap1", ApprovalWebAuthnAssertion{"challenge_id": "ch1", "assertion": "sig"}) - if err != nil || (*asserted)["state"] != "approved" { + if err != nil || (*asserted)["state"] != "approved" || (*asserted)["binding_hash"] != "sha256:command" { t.Fatalf("asserted = %#v, err = %v", asserted, err) } vectors, err := client.ListNegativeConformanceVectors() @@ -252,7 +256,7 @@ func TestGoClientEndpointCoverageMatrix(t *testing.T) { {"get authz snapshot", "GET /api/v1/authz/snapshots/snapshot%2Fa%20b", func() error { _, err := client.GetAuthzSnapshot("snapshot/a b"); return err }}, {"list approvals", "GET /api/v1/approvals", func() error { _, err := client.ListApprovalCeremonies(); return err }}, {"create approval", "POST /api/v1/approvals", func() error { - _, err := client.CreateApprovalCeremony(ApprovalCeremony{"approval_id": "a1"}) + _, err := client.CreateApprovalCeremony(ApprovalCeremony{"approval_id": "a1", "binding_hash": "sha256:command"}) return err }}, {"transition approval", "POST /api/v1/approvals/approval%2Fa%20b/approve", func() error { diff --git a/sdk/go/generated.manifest.json b/sdk/go/generated.manifest.json index 045dee896..01be732e4 100644 --- a/sdk/go/generated.manifest.json +++ b/sdk/go/generated.manifest.json @@ -8,7 +8,7 @@ "generator": "openapitools/openapi-generator-cli:v7.4.0@sha256:579832bed49ea6c275ce2fb5f2d515f5b03d2b6243f3c80fa8430e4f5a770e9a", "sdk": "go", "source": { - "sha256": "052e82eb5ed3e704abce7e1d6ca63a819a93ee2fa06a8f578e0b262fc6f56f97", + "sha256": "13927036c1c3a7d6ab8046c532478565b7447ec378bc2377cec1140bea30678b", "spec": "api/openapi/helm.openapi.yaml" } } diff --git a/sdk/java/generated.manifest.json b/sdk/java/generated.manifest.json index 4c0d20e62..6be2c06f0 100644 --- a/sdk/java/generated.manifest.json +++ b/sdk/java/generated.manifest.json @@ -2,13 +2,13 @@ "files": [ { "path": "src/main/java/labs/mindburn/helm/TypesGen.java", - "sha256": "041d60d39289a0413faac4166ab105e179dcdb5ca71ff8babde386f745743be1" + "sha256": "384429f8a99ea6ca3830199a14edc828dbfef511783e8e19f2b70965151c4d50" } ], "generator": "openapitools/openapi-generator-cli:v7.4.0@sha256:579832bed49ea6c275ce2fb5f2d515f5b03d2b6243f3c80fa8430e4f5a770e9a", "sdk": "java", "source": { - "sha256": "052e82eb5ed3e704abce7e1d6ca63a819a93ee2fa06a8f578e0b262fc6f56f97", + "sha256": "13927036c1c3a7d6ab8046c532478565b7447ec378bc2377cec1140bea30678b", "spec": "api/openapi/helm.openapi.yaml" } } diff --git a/sdk/java/src/main/java/labs/mindburn/helm/TypesGen.java b/sdk/java/src/main/java/labs/mindburn/helm/TypesGen.java index 64df567ac..7737409cf 100644 --- a/sdk/java/src/main/java/labs/mindburn/helm/TypesGen.java +++ b/sdk/java/src/main/java/labs/mindburn/helm/TypesGen.java @@ -3717,6 +3717,7 @@ public String toUrlQueryString(String prefix) { ApprovalCeremony.JSON_PROPERTY_TIMELOCK_UNTIL, ApprovalCeremony.JSON_PROPERTY_EXPIRES_AT, ApprovalCeremony.JSON_PROPERTY_BREAK_GLASS, + ApprovalCeremony.JSON_PROPERTY_BINDING_HASH, ApprovalCeremony.JSON_PROPERTY_REASON, ApprovalCeremony.JSON_PROPERTY_RECEIPT_ID, ApprovalCeremony.JSON_PROPERTY_CEREMONY_HASH, @@ -3796,6 +3797,9 @@ public static StateEnum fromValue(String value) { public static final String JSON_PROPERTY_BREAK_GLASS = "break_glass"; private Boolean breakGlass; + public static final String JSON_PROPERTY_BINDING_HASH = "binding_hash"; + private String bindingHash; + public static final String JSON_PROPERTY_REASON = "reason"; private String reason; @@ -4072,6 +4076,31 @@ public void setBreakGlass(Boolean breakGlass) { } + public ApprovalCeremony bindingHash(String bindingHash) { + this.bindingHash = bindingHash; + return this; + } + + /** + * Get bindingHash + * @return bindingHash + **/ + @javax.annotation.Nullable + @JsonProperty(JSON_PROPERTY_BINDING_HASH) + @JsonInclude(value = JsonInclude.Include.USE_DEFAULTS) + + public String getBindingHash() { + return bindingHash; + } + + + @JsonProperty(JSON_PROPERTY_BINDING_HASH) + @JsonInclude(value = JsonInclude.Include.USE_DEFAULTS) + public void setBindingHash(String bindingHash) { + this.bindingHash = bindingHash; + } + + public ApprovalCeremony reason(String reason) { this.reason = reason; return this; @@ -4219,6 +4248,7 @@ public boolean equals(Object o) { Objects.equals(this.timelockUntil, approvalCeremony.timelockUntil) && Objects.equals(this.expiresAt, approvalCeremony.expiresAt) && Objects.equals(this.breakGlass, approvalCeremony.breakGlass) && + Objects.equals(this.bindingHash, approvalCeremony.bindingHash) && Objects.equals(this.reason, approvalCeremony.reason) && Objects.equals(this.receiptId, approvalCeremony.receiptId) && Objects.equals(this.ceremonyHash, approvalCeremony.ceremonyHash) && @@ -4228,7 +4258,7 @@ public boolean equals(Object o) { @Override public int hashCode() { - return Objects.hash(approvalId, subject, action, state, requestedBy, approvers, quorum, timelockUntil, expiresAt, breakGlass, reason, receiptId, ceremonyHash, createdAt, updatedAt); + return Objects.hash(approvalId, subject, action, state, requestedBy, approvers, quorum, timelockUntil, expiresAt, breakGlass, bindingHash, reason, receiptId, ceremonyHash, createdAt, updatedAt); } @Override @@ -4245,6 +4275,7 @@ public String toString() { sb.append(" timelockUntil: ").append(toIndentedString(timelockUntil)).append("\n"); sb.append(" expiresAt: ").append(toIndentedString(expiresAt)).append("\n"); sb.append(" breakGlass: ").append(toIndentedString(breakGlass)).append("\n"); + sb.append(" bindingHash: ").append(toIndentedString(bindingHash)).append("\n"); sb.append(" reason: ").append(toIndentedString(reason)).append("\n"); sb.append(" receiptId: ").append(toIndentedString(receiptId)).append("\n"); sb.append(" ceremonyHash: ").append(toIndentedString(ceremonyHash)).append("\n"); @@ -4351,6 +4382,11 @@ public String toUrlQueryString(String prefix) { joiner.add(String.format("%sbreak_glass%s=%s", prefix, suffix, URLEncoder.encode(String.valueOf(getBreakGlass()), StandardCharsets.UTF_8).replaceAll("\\+", "%20"))); } + // add `binding_hash` to the URL query string + if (getBindingHash() != null) { + joiner.add(String.format("%sbinding_hash%s=%s", prefix, suffix, URLEncoder.encode(String.valueOf(getBindingHash()), StandardCharsets.UTF_8).replaceAll("\\+", "%20"))); + } + // add `reason` to the URL query string if (getReason() != null) { joiner.add(String.format("%sreason%s=%s", prefix, suffix, URLEncoder.encode(String.valueOf(getReason()), StandardCharsets.UTF_8).replaceAll("\\+", "%20"))); diff --git a/sdk/python/generated.manifest.json b/sdk/python/generated.manifest.json index 125a72c05..a9276404a 100644 --- a/sdk/python/generated.manifest.json +++ b/sdk/python/generated.manifest.json @@ -2,13 +2,13 @@ "files": [ { "path": "helm_sdk/types_gen.py", - "sha256": "e6837572f98682d783983903342f3252e58c05c4bee05f71bb610c1eb569fc6d" + "sha256": "1254f57eeb6063797d2d7692023141e40950ca3bf9f7d9c6e67d7eac0cdc3f12" } ], "generator": "openapitools/openapi-generator-cli:v7.4.0@sha256:579832bed49ea6c275ce2fb5f2d515f5b03d2b6243f3c80fa8430e4f5a770e9a", "sdk": "python", "source": { - "sha256": "052e82eb5ed3e704abce7e1d6ca63a819a93ee2fa06a8f578e0b262fc6f56f97", + "sha256": "13927036c1c3a7d6ab8046c532478565b7447ec378bc2377cec1140bea30678b", "spec": "api/openapi/helm.openapi.yaml" } } diff --git a/sdk/python/helm_sdk/types_gen.py b/sdk/python/helm_sdk/types_gen.py index 4b63b69e4..d54dbd627 100644 --- a/sdk/python/helm_sdk/types_gen.py +++ b/sdk/python/helm_sdk/types_gen.py @@ -1006,12 +1006,13 @@ class ApprovalCeremony(BaseModel): timelock_until: Optional[datetime] = None expires_at: Optional[datetime] = None break_glass: Optional[StrictBool] = None + binding_hash: Optional[StrictStr] = None reason: Optional[StrictStr] = None receipt_id: Optional[StrictStr] = None ceremony_hash: Optional[StrictStr] = None created_at: Optional[datetime] = None updated_at: Optional[datetime] = None - __properties: ClassVar[List[str]] = ["approval_id", "subject", "action", "state", "requested_by", "approvers", "quorum", "timelock_until", "expires_at", "break_glass", "reason", "receipt_id", "ceremony_hash", "created_at", "updated_at"] + __properties: ClassVar[List[str]] = ["approval_id", "subject", "action", "state", "requested_by", "approvers", "quorum", "timelock_until", "expires_at", "break_glass", "binding_hash", "reason", "receipt_id", "ceremony_hash", "created_at", "updated_at"] @field_validator('state') def state_validate_enum(cls, value): @@ -1084,6 +1085,7 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: "timelock_until": obj.get("timelock_until"), "expires_at": obj.get("expires_at"), "break_glass": obj.get("break_glass"), + "binding_hash": obj.get("binding_hash"), "reason": obj.get("reason"), "receipt_id": obj.get("receipt_id"), "ceremony_hash": obj.get("ceremony_hash"), diff --git a/sdk/rust/generated.manifest.json b/sdk/rust/generated.manifest.json index 3301aee52..05637c8da 100644 --- a/sdk/rust/generated.manifest.json +++ b/sdk/rust/generated.manifest.json @@ -2,13 +2,13 @@ "files": [ { "path": "src/types_gen.rs", - "sha256": "cf77a2b1441e0e25c96ad13ba7e50058e2f5d7d6332a02d04835061006c761b0" + "sha256": "73fd78978f744c5715075ea876ae1f2fff504df82b722004e3201b4932ea16be" } ], "generator": "openapitools/openapi-generator-cli:v7.4.0@sha256:579832bed49ea6c275ce2fb5f2d515f5b03d2b6243f3c80fa8430e4f5a770e9a", "sdk": "rust", "source": { - "sha256": "052e82eb5ed3e704abce7e1d6ca63a819a93ee2fa06a8f578e0b262fc6f56f97", + "sha256": "13927036c1c3a7d6ab8046c532478565b7447ec378bc2377cec1140bea30678b", "spec": "api/openapi/helm.openapi.yaml" } } diff --git a/sdk/rust/src/types_gen.rs b/sdk/rust/src/types_gen.rs index 17deddd3a..990a8de26 100644 --- a/sdk/rust/src/types_gen.rs +++ b/sdk/rust/src/types_gen.rs @@ -494,6 +494,8 @@ pub struct ApprovalCeremony { pub expires_at: Option, #[serde(rename = "break_glass", skip_serializing_if = "Option::is_none")] pub break_glass: Option, + #[serde(rename = "binding_hash", skip_serializing_if = "Option::is_none")] + pub binding_hash: Option, #[serde(rename = "reason", skip_serializing_if = "Option::is_none")] pub reason: Option, #[serde(rename = "receipt_id", skip_serializing_if = "Option::is_none")] @@ -519,6 +521,7 @@ impl ApprovalCeremony { timelock_until: None, expires_at: None, break_glass: None, + binding_hash: None, reason: None, receipt_id: None, ceremony_hash: None, diff --git a/sdk/ts/generated.manifest.json b/sdk/ts/generated.manifest.json index d7c2eaaf7..c766db2b9 100644 --- a/sdk/ts/generated.manifest.json +++ b/sdk/ts/generated.manifest.json @@ -2,13 +2,13 @@ "files": [ { "path": "src/types.gen.ts", - "sha256": "b3c0bc31be599c39c15a7c4e40053a0ec3481dedcb32aef3814db5aa13c26e1e" + "sha256": "cc963d3c8e7e7e072099ed96737e8c84134071fa4305b059404c3843eee185d1" } ], "generator": "openapitools/openapi-generator-cli:v7.4.0@sha256:579832bed49ea6c275ce2fb5f2d515f5b03d2b6243f3c80fa8430e4f5a770e9a", "sdk": "ts", "source": { - "sha256": "052e82eb5ed3e704abce7e1d6ca63a819a93ee2fa06a8f578e0b262fc6f56f97", + "sha256": "13927036c1c3a7d6ab8046c532478565b7447ec378bc2377cec1140bea30678b", "spec": "api/openapi/helm.openapi.yaml" } } diff --git a/sdk/ts/src/types.gen.ts b/sdk/ts/src/types.gen.ts index b6ae0b937..1969fadd0 100644 --- a/sdk/ts/src/types.gen.ts +++ b/sdk/ts/src/types.gen.ts @@ -1171,6 +1171,12 @@ export interface ApprovalCeremony { * @memberof ApprovalCeremony */ break_glass?: boolean; + /** + * + * @type {string} + * @memberof ApprovalCeremony + */ + binding_hash?: string; /** * * @type {string} @@ -1244,6 +1250,7 @@ export function ApprovalCeremonyFromJSONTyped(json: any, ignoreDiscriminator: bo 'timelock_until': json['timelock_until'] == null ? undefined : (new Date(json['timelock_until'])), 'expires_at': json['expires_at'] == null ? undefined : (new Date(json['expires_at'])), 'break_glass': json['break_glass'] == null ? undefined : json['break_glass'], + 'binding_hash': json['binding_hash'] == null ? undefined : json['binding_hash'], 'reason': json['reason'] == null ? undefined : json['reason'], 'receipt_id': json['receipt_id'] == null ? undefined : json['receipt_id'], 'ceremony_hash': json['ceremony_hash'] == null ? undefined : json['ceremony_hash'], @@ -1268,6 +1275,7 @@ export function ApprovalCeremonyToJSON(value?: ApprovalCeremony | null): any { 'timelock_until': value['timelock_until'] == null ? undefined : ((value['timelock_until']).toISOString()), 'expires_at': value['expires_at'] == null ? undefined : ((value['expires_at']).toISOString()), 'break_glass': value['break_glass'], + 'binding_hash': value['binding_hash'], 'reason': value['reason'], 'receipt_id': value['receipt_id'], 'ceremony_hash': value['ceremony_hash'], diff --git a/tools/boundary/protected.manifest b/tools/boundary/protected.manifest index c9c220be9..daeb2a7ef 100644 --- a/tools/boundary/protected.manifest +++ b/tools/boundary/protected.manifest @@ -152,7 +152,7 @@ a65443797c63eaf591076538f1fb4cddcd578c120db433b5a3b70b6521ba6f6c core/pkg/contr 13ca81cd8b0b8e86d1c3f6a2db8eea6f36bc7ca2cdd9034c5bd838520ad0e27f core/pkg/contracts/autonomy_envelope.go 23425a052bfbb886dabacb4eef162782f58e153289eb53e2d224506a2819cac7 core/pkg/contracts/autonomy_state.go 0d01b067f08db8a2fba31443f0be003a775ebe9b2e4bfa9afd0425abbe84c19c core/pkg/contracts/autonomy_state_test.go -9182be7780200297ae4bc58fc0a9bf7a1df5463867ddb99f980be696b376fe06 core/pkg/contracts/boundary_surfaces.go +057790dbb586327cdb6872f5acf3162e9d030b20e84d1007dd28f84b96628089 core/pkg/contracts/boundary_surfaces.go 58d52b26cb02373d2cd3aeb7bde69ba02e9f7cd1cd52b203407a4c17c2b46424 core/pkg/contracts/build.go cb435386cac61780c38600c984752912aca4d48d41115d9ec41e704aca5ecf66 core/pkg/contracts/capability_diff.go 0dac155baf5861ed19fdf3b7f119d5f768e566207634a345f1162910d8bd896f core/pkg/contracts/capability_diff_determinism_test.go