From 901dbd66e4a71f95c587968eabf10407ab7935b0 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sun, 16 Aug 2026 20:04:36 +0100 Subject: [PATCH 01/11] feat: add human actor identity descriptors --- .github/tests/test_detached_supervision.py | 9 +- .github/tests/test_repository_contract.py | 14 ++ .../cmd/boatstack-helper/declarative_flow.go | 20 +- .../boatstack-helper/delegation_command.go | 50 +++- .../boatstack-helper/delegation_runtime.go | 23 +- .../cmd/boatstack-helper/flow_runtime.go | 9 +- .../cmd/boatstack-helper/flow_runtime_test.go | 45 +++- .../cmd/boatstack-helper/human_identity.go | 145 ++++++++++++ .../boatstack-helper/human_identity_test.go | 111 +++++++++ .../cmd/boatstack-helper/input_command.go | 23 +- .../boatstack-helper/input_command_test.go | 46 ++++ boatstack/cmd/boatstack-helper/main.go | 39 +++- boatstack/cmd/boatstack-helper/main_test.go | 18 ++ .../product_delivery_flow_e2e_test.go | 6 +- boatstack/distribution/standard_test.go | 3 + boatstack/flow/softwaredelivery/skills.go | 28 ++- .../flow/softwaredelivery/skills_test.go | 9 + .../softwaredelivery/delegation/record.go | 71 +++--- .../delegation/record_test.go | 44 +++- .../effects/cas_integration_test.go | 4 +- .../effects/command_boundary_test.go | 2 +- .../effects/delegation_record_test.go | 4 +- .../effects/integration_test.go | 18 +- .../softwaredelivery/effects/recovery_test.go | 2 +- .../humanidentity/identity.go | 221 ++++++++++++++++++ .../humanidentity/identity_test.go | 99 ++++++++ .../softwaredelivery/protocol/config.go | 15 +- .../softwaredelivery/protocol/config_test.go | 55 ++++- .../softwaredelivery/surfaces/protocol.go | 29 +-- boatstack/references/config-schema.md | 11 +- boatstack/references/host-hook-contracts.md | 6 +- boatstack/sdk/sdk.go | 3 + docs/configuration.md | 45 +++- docs/control-program-ir.md | 10 +- docs/getting-started.md | 8 +- .../authority-and-delegation.md | 8 + install.ps1 | 8 +- install.sh | 9 +- project.example.json | 9 +- .../2026-08-16-human-actor-identity.md | 6 + 40 files changed, 1150 insertions(+), 135 deletions(-) create mode 100644 boatstack/cmd/boatstack-helper/human_identity.go create mode 100644 boatstack/cmd/boatstack-helper/human_identity_test.go create mode 100644 boatstack/internal/softwaredelivery/humanidentity/identity.go create mode 100644 boatstack/internal/softwaredelivery/humanidentity/identity_test.go create mode 100644 release-notes/2026-08-16-human-actor-identity.md diff --git a/.github/tests/test_detached_supervision.py b/.github/tests/test_detached_supervision.py index 97a4356..fdd150f 100644 --- a/.github/tests/test_detached_supervision.py +++ b/.github/tests/test_detached_supervision.py @@ -175,7 +175,8 @@ def test_detached_installation_and_engaged_guard_use_the_same_kernel(self) -> No config.write_text( json.dumps( { - "schema_version": 2, + "schema_version": 3, + "identity": {"human": {"kind": "literal", "value": "contract"}}, "project": {"name": "fixture", "default_branch": "main", "commands": {}}, "policy": {"plan_approval": "human", "visual_evidence": "optional"}, "hosts": ["cli", "cursor", "codex", "claude", "gemini", "mcp"], @@ -244,7 +245,8 @@ def test_authority_free_frontier_does_not_block_authorized_plan_creation(self) - config.write_text( json.dumps( { - "schema_version": 2, + "schema_version": 3, + "identity": {"human": {"kind": "literal", "value": "contract"}}, "project": {"name": "driver-fixture", "default_branch": "main", "commands": {}}, "policy": {"plan_approval": "human", "visual_evidence": "optional"}, "hosts": ["cli", "codex"], @@ -344,7 +346,8 @@ def test_one_delivery_context_rematerializes_repository_authority_after_initiali config.write_text( json.dumps( { - "schema_version": 2, + "schema_version": 3, + "identity": {"human": {"kind": "literal", "value": "contract"}}, "project": { "name": "retained-authority-fixture", "default_branch": "main", diff --git a/.github/tests/test_repository_contract.py b/.github/tests/test_repository_contract.py index a25bcaf..e7d9a5d 100644 --- a/.github/tests/test_repository_contract.py +++ b/.github/tests/test_repository_contract.py @@ -793,6 +793,19 @@ def test_rpc_and_configuration_decoders_fail_closed(self) -> None: self.assertIn("unknown field", (rejected.stdout + rejected.stderr).lower()) self.assertFalse((repository / ".boatstack" / "project.json").exists()) + def test_installers_require_an_explicit_human_actor(self) -> None: + if os.name != "nt": + env = dict(os.environ) + env.pop("BOATSTACK_ACTOR", None) + rejected = self.run_command( + "bash", REPO / "install.sh", cwd=REPO, env=env, expected=2 + ) + self.assertIn("BOATSTACK_HUMAN_ACTOR_REQUIRED", rejected.stderr) + powershell = (REPO / "install.ps1").read_text() + shell = (REPO / "install.sh").read_text() + self.assertNotIn("$env:USERNAME", powershell) + self.assertNotIn("${USER", shell) + def test_offline_installer_initializes_updates_and_guards_through_kernel(self) -> None: if os.name == "nt": self.skipTest("the repository contract job exercises the POSIX installer") @@ -954,6 +967,7 @@ def test_installer_download_and_checksum_failures_are_actionable_and_non_mutatin "BOATSTACK_HOME": str(root / "home"), "BOATSTACK_INSTALL_DIR": str(root / "bin"), "BOATSTACK_VERSION": "v9.9.9", + "BOATSTACK_ACTOR": "contract", } ) unavailable = self.run_command( diff --git a/boatstack/cmd/boatstack-helper/declarative_flow.go b/boatstack/cmd/boatstack-helper/declarative_flow.go index e285dc6..115f733 100644 --- a/boatstack/cmd/boatstack-helper/declarative_flow.go +++ b/boatstack/cmd/boatstack-helper/declarative_flow.go @@ -17,6 +17,7 @@ import ( softwareflow "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery" boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/effects" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/humanidentity" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/plant" "github.com/operatorstack/boatstack/boatstack/invocation" ) @@ -187,20 +188,29 @@ func runDeclarativeFlow(ctx context.Context, compiled controlprogram.Compiled, o if err := runtimeContext.store.SaveRequest(*result.Request); err != nil { return err } + presentation, identityErr := humanIdentityPresentationForRepository(repository) + if identityErr != nil { + return identityErr + } return encodeDeclarativeResult(map[string]any{ "kind": "suspended", "code": result.Request.Code, "run_id": runtimeContext.state.RunID, "program_fingerprint": compiled.Fingerprint, "entry_id": entry.ID, "target_id": entry.Target, - "transition_id": transition.ID, "request": result.Request, + "transition_id": transition.ID, "request": result.Request, "human_identity": presentation, }, options.format) } if result.Ready == nil { return fmt.Errorf("FLOW_INVOCATION_INCOMPLETE: declarative materialization produced no evidence") } if err := requireDeclarativeAuthority(transition, operator, options.humanActor); err != nil { + presentation, identityErr := humanIdentityPresentationForRepository(repository) + if identityErr != nil { + return identityErr + } return encodeDeclarativeResult(map[string]any{ "kind": "blocked", "code": "AUTHORITY_REQUIRED", "detail": err.Error(), "run_id": runtimeContext.state.RunID, "program_fingerprint": compiled.Fingerprint, "entry_id": entry.ID, "target_id": entry.Target, "transition_id": transition.ID, + "human_identity": presentation, }, options.format) } @@ -256,7 +266,13 @@ func runDeclarativeFlow(ctx context.Context, compiled controlprogram.Compiled, o } func requireDeclarativeAuthority(transition controlprogram.Transition, operator controlprogram.Operator, humanActor string) error { - providedHuman := strings.TrimSpace(humanActor) != "" + actor := strings.TrimSpace(humanActor) + providedHuman := actor != "" + if providedHuman { + if err := humanidentity.ValidateActor(actor); err != nil { + return err + } + } if len(operator.Authority.AnyOf) != 0 && !providedHuman { return fmt.Errorf("operator %q requires one of %s", operator.ID, strings.Join(operator.Authority.AnyOf, ", ")) } diff --git a/boatstack/cmd/boatstack-helper/delegation_command.go b/boatstack/cmd/boatstack-helper/delegation_command.go index 20a6462..ff8aeb2 100644 --- a/boatstack/cmd/boatstack-helper/delegation_command.go +++ b/boatstack/cmd/boatstack-helper/delegation_command.go @@ -14,6 +14,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/delegation" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/effects" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/humanidentity" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/plant" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/supervisor" @@ -45,20 +46,25 @@ func runFlowAuthorize(arguments []string) error { flags.SetOutput(os.Stderr) options := commandOptions{repository: ".", host: "cli"} requestFingerprint := "" + identityProviderFingerprint := "" expiresIn := time.Duration(0) flags.StringVar(&options.repository, "repo", options.repository, "repository or worktree") flags.StringVar(&options.programID, "flow", "", "repository Control Program identity") flags.StringVar(&options.entryID, "entry", "", "named Flow entry") flags.StringVar(&options.runID, "run-id", "", "exact run identity") flags.StringVar(&requestFingerprint, "request-fingerprint", "", "exact delegation request fingerprint") + flags.StringVar(&identityProviderFingerprint, "human-identity-provider-fingerprint", "", "exact human identity provider fingerprint from the delegation request") flags.StringVar(&options.humanActor, "human", "", "authorizing human actor") flags.StringVar(&options.host, "host", options.host, "trusted host identity") flags.DurationVar(&expiresIn, "expires-in", 0, "optional delegation lifetime") if err := flags.Parse(arguments); err != nil { return err } - if flags.NArg() != 0 || options.runID == "" || requestFingerprint == "" || options.humanActor == "" { - return fmt.Errorf("flow authorize requires --flow, --entry, --run-id, --request-fingerprint, and --human") + if flags.NArg() != 0 || options.runID == "" || requestFingerprint == "" || identityProviderFingerprint == "" || options.humanActor == "" { + return fmt.Errorf("flow authorize requires --flow, --entry, --run-id, --request-fingerprint, --human-identity-provider-fingerprint, and --human") + } + if err := humanidentity.ValidateActor(options.humanActor); err != nil { + return err } if expiresIn < 0 { return fmt.Errorf("flow authorize --expires-in cannot be negative") @@ -77,6 +83,9 @@ func runFlowAuthorize(arguments []string) error { if bound.delegationRequestFingerprint == "" || requestFingerprint != bound.delegationRequestFingerprint || bound.runID != options.runID { return fmt.Errorf("DELEGATION_REQUEST_MISMATCH: authorization does not match the exact current request") } + if identityProviderFingerprint != bound.delegationRequest.HumanIdentityProviderFingerprint { + return fmt.Errorf("HUMAN_IDENTITY_DRIFT: authorization does not match the current identity provider") + } resolver, err := plant.NewResolver("") if err != nil { return err @@ -109,7 +118,7 @@ func runFlowAuthorize(arguments []string) error { return loadErr } now := time.Now().UTC() - record, changed, err := authorizeDelegation(existing, bound.delegationRequest, requestFingerprint, options.humanActor, expiresIn, now, bound.delegationReprojection) + record, changed, err := authorizeDelegation(existing, bound.delegationRequest, requestFingerprint, identityProviderFingerprint, options.humanActor, expiresIn, now, bound.delegationReprojection) if err != nil { return err } @@ -130,7 +139,7 @@ func runFlowAuthorize(arguments []string) error { return printDelegationRecord(record) } -func authorizeDelegation(existing *delegation.Record, request delegation.Request, requestFingerprint, actor string, expiresIn time.Duration, now time.Time, allowReprojection bool) (delegation.Record, bool, error) { +func authorizeDelegation(existing *delegation.Record, request delegation.Request, requestFingerprint, identityProviderFingerprint, actor string, expiresIn time.Duration, now time.Time, allowReprojection bool) (delegation.Record, bool, error) { if expiresIn < 0 { return delegation.Record{}, false, fmt.Errorf("flow authorize --expires-in cannot be negative") } @@ -138,6 +147,12 @@ func authorizeDelegation(existing *delegation.Record, request delegation.Request if err != nil || computedFingerprint != requestFingerprint { return delegation.Record{}, false, fmt.Errorf("DELEGATION_REQUEST_MISMATCH: authorization does not match the exact current request") } + if identityProviderFingerprint != request.HumanIdentityProviderFingerprint { + return delegation.Record{}, false, fmt.Errorf("HUMAN_IDENTITY_DRIFT: authorization does not match the current identity provider") + } + if err := humanidentity.ValidateActor(actor); err != nil { + return delegation.Record{}, false, err + } if existing != nil { if allowReprojection && existing.RequestFingerprint != requestFingerprint { if existing.Status != "active" && existing.Status != "revoked" { @@ -146,15 +161,16 @@ func authorizeDelegation(existing *delegation.Record, request delegation.Request record := delegation.Record{ Schema: delegation.Schema, SchemaRevision: delegation.SchemaRevision, Request: request, RequestFingerprint: requestFingerprint, - ReceiptID: authorizationReceiptID(requestFingerprint, actor, existing.Revision+1, now), Actor: actor, - AuthorizedAt: now, Revision: existing.Revision + 1, Status: "active", + ReceiptID: authorizationReceiptID(requestFingerprint, actor, identityProviderFingerprint, existing.Revision+1, now), Actor: actor, + ActorIdentityProviderFingerprint: identityProviderFingerprint, + AuthorizedAt: now, Revision: existing.Revision + 1, Status: "active", } if expiresIn > 0 { record.ExpiresAt = now.Add(expiresIn) } return record, true, nil } - if existing.RequestFingerprint != requestFingerprint || existing.Actor != actor || existing.Status != "active" { + if existing.RequestFingerprint != requestFingerprint || existing.Actor != actor || existing.ActorIdentityProviderFingerprint != identityProviderFingerprint || existing.Status != "active" { return delegation.Record{}, false, fmt.Errorf("DELEGATION_CONFLICT: run already has a different authorization, actor, or status") } if existing.ExpiresAt.IsZero() || now.Before(existing.ExpiresAt) { @@ -167,15 +183,16 @@ func authorizeDelegation(existing *delegation.Record, request delegation.Request if expiresIn > 0 { record.ExpiresAt = now.Add(expiresIn) } - record.ReceiptID = authorizationReceiptID(requestFingerprint, actor, record.Revision, now) + record.ReceiptID = authorizationReceiptID(requestFingerprint, actor, identityProviderFingerprint, record.Revision, now) record.RevokedAt, record.EndedAt, record.EndReason = time.Time{}, time.Time{}, "" return record, true, nil } record := delegation.Record{ Schema: delegation.Schema, SchemaRevision: delegation.SchemaRevision, Request: request, RequestFingerprint: requestFingerprint, - ReceiptID: authorizationReceiptID(requestFingerprint, actor, 1, now), Actor: actor, - AuthorizedAt: now, Revision: 1, Status: "active", + ReceiptID: authorizationReceiptID(requestFingerprint, actor, identityProviderFingerprint, 1, now), Actor: actor, + ActorIdentityProviderFingerprint: identityProviderFingerprint, + AuthorizedAt: now, Revision: 1, Status: "active", } if expiresIn > 0 { record.ExpiresAt = now.Add(expiresIn) @@ -183,8 +200,8 @@ func authorizeDelegation(existing *delegation.Record, request delegation.Request return record, true, nil } -func authorizationReceiptID(requestFingerprint, actor string, revision uint64, authorizedAt time.Time) string { - receiptDigest := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%d\x00%s", requestFingerprint, actor, revision, authorizedAt.UTC().Format(time.RFC3339Nano)))) +func authorizationReceiptID(requestFingerprint, actor, identityProviderFingerprint string, revision uint64, authorizedAt time.Time) string { + receiptDigest := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%s\x00%d\x00%s", requestFingerprint, actor, identityProviderFingerprint, revision, authorizedAt.UTC().Format(time.RFC3339Nano)))) return "authorization-" + hex.EncodeToString(receiptDigest[:12]) } @@ -202,6 +219,9 @@ func runFlowRevoke(arguments []string) error { if flags.NArg() != 0 || runID == "" || actor == "" { return fmt.Errorf("flow revoke requires --run-id and --human") } + if err := humanidentity.ValidateActor(actor); err != nil { + return err + } resolver, err := plant.NewResolver("") if err != nil { return err @@ -314,6 +334,9 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa return surfaces.Response{}, err } if programChangeResponse != nil { + if err := attachHumanIdentity(resolveRequest, programChangeResponse); err != nil { + return surfaces.Response{}, err + } return *programChangeResponse, nil } _, delegationResponse, err := prepareDelegation(ctx, &resolveRequest) @@ -399,6 +422,9 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa return resolved, err } } + if err := attachHumanIdentity(resolveRequest, &resolved); err != nil { + return surfaces.Response{}, err + } applyRequest := resolveRequest applyRequest.Operation = surfaces.OperationApply applyRequest.TransitionID = resolved.Prescription.TransitionID diff --git a/boatstack/cmd/boatstack-helper/delegation_runtime.go b/boatstack/cmd/boatstack-helper/delegation_runtime.go index 4efbc3d..1795fca 100644 --- a/boatstack/cmd/boatstack-helper/delegation_runtime.go +++ b/boatstack/cmd/boatstack-helper/delegation_runtime.go @@ -35,6 +35,10 @@ func prepareDelegation(ctx context.Context, request *surfaces.Request) (ports.Lo if request.ProgramID == "" || len(request.DelegatedAuthorities) == 0 { return nil, nil, nil } + presentation, err := humanIdentityPresentationForRequest(*request) + if err != nil { + return nil, nil, err + } resolver, err := plant.NewResolver("") if err != nil { return nil, nil, err @@ -81,17 +85,19 @@ func prepareDelegation(ctx context.Context, request *surfaces.Request) (ports.Lo if request.Operation == surfaces.OperationExplain { return nil, nil, nil } - return nil, delegationRequiredResponse(*request), nil + response, responseErr := delegationRequiredResponse(*request) + return nil, response, responseErr } if err != nil { releaseOnError() return nil, nil, err } - if record.RequestFingerprint != request.DelegationRequestFingerprint || record.Request.RunID != request.FlowID || record.Request.ProgramID != request.ProgramID || record.Request.ProgramFingerprint != request.ProgramFingerprint || record.Request.ControlBundleFingerprint != request.ControlBundleFingerprint || record.Request.EntryID != request.EntryID || record.Request.TargetID != string(request.Objective.TargetID) || record.Request.ObjectiveID != request.Objective.ID || record.Request.DeliveryID != request.Objective.DeliveryID || record.Request.RepositoryID != invocation.RepositoryID || record.Request.GitCommonID != invocation.GitCommonID || record.Request.BindingFingerprint != request.DelegationBindingFingerprint { + if record.RequestFingerprint != request.DelegationRequestFingerprint || record.Request.RunID != request.FlowID || record.Request.ProgramID != request.ProgramID || record.Request.ProgramFingerprint != request.ProgramFingerprint || record.Request.ControlBundleFingerprint != request.ControlBundleFingerprint || record.Request.EntryID != request.EntryID || record.Request.TargetID != string(request.Objective.TargetID) || record.Request.ObjectiveID != request.Objective.ID || record.Request.DeliveryID != request.Objective.DeliveryID || record.Request.RepositoryID != invocation.RepositoryID || record.Request.GitCommonID != invocation.GitCommonID || record.Request.BindingFingerprint != request.DelegationBindingFingerprint || record.Request.HumanIdentityProviderFingerprint != presentation.ProviderFingerprint || record.ActorIdentityProviderFingerprint != presentation.ProviderFingerprint { reprojected, reprojectErr := canReprojectDelegation(layout, invocation, record.Request, delegation.Request{ RunID: request.FlowID, ProgramID: request.ProgramID, ProgramFingerprint: request.ProgramFingerprint, ControlBundleFingerprint: request.ControlBundleFingerprint, EntryID: request.EntryID, TargetID: string(request.Objective.TargetID), ObjectiveID: request.Objective.ID, DeliveryID: request.Objective.DeliveryID, RepositoryID: invocation.RepositoryID, GitCommonID: invocation.GitCommonID, BindingFingerprint: request.DelegationBindingFingerprint, + HumanIdentityProviderFingerprint: presentation.ProviderFingerprint, }) releaseOnError() if reprojectErr != nil { @@ -101,7 +107,8 @@ func prepareDelegation(ctx context.Context, request *surfaces.Request) (ports.Lo if request.Operation == surfaces.OperationExplain { return nil, nil, nil } - return nil, delegationRequiredResponse(*request), nil + response, responseErr := delegationRequiredResponse(*request) + return nil, response, responseErr } return nil, nil, fmt.Errorf("DELEGATION_DRIFT: authorization does not match the current run context") } @@ -145,11 +152,15 @@ func prepareDelegation(ctx context.Context, request *surfaces.Request) (ports.Lo return lock, nil, nil } -func delegationRequiredResponse(request surfaces.Request) *surfaces.Response { +func delegationRequiredResponse(request surfaces.Request) (*surfaces.Response, error) { + presentation, err := humanIdentityPresentationForRequest(request) + if err != nil { + return nil, err + } return &surfaces.Response{ SchemaVersion: surfaces.SchemaVersion, Operation: request.Operation, ProgramID: request.ProgramID, EntryID: request.EntryID, RunID: request.FlowID, Objective: request.Objective, - Delegation: &surfaces.DelegationRequired{Code: "DELEGATION_REQUIRED", RunID: request.FlowID, RequestFingerprint: request.DelegationRequestFingerprint, Authorities: append([]catalog.AuthorityClass(nil), request.DelegatedAuthorities...), Description: "Explicitly authorize " + request.ProgramID + "/" + request.EntryID + " for this exact run"}, - } + Delegation: &surfaces.DelegationRequired{Code: "DELEGATION_REQUIRED", RunID: request.FlowID, RequestFingerprint: request.DelegationRequestFingerprint, Authorities: append([]catalog.AuthorityClass(nil), request.DelegatedAuthorities...), Description: "Explicitly authorize " + request.ProgramID + "/" + request.EntryID + " for this exact run", HumanIdentity: presentation}, + }, nil } // preflightDelegatedProgramChange observes the selected program before any diff --git a/boatstack/cmd/boatstack-helper/flow_runtime.go b/boatstack/cmd/boatstack-helper/flow_runtime.go index 30da1ad..07f4ad9 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime.go @@ -263,6 +263,10 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, if description == "" { description = fmt.Sprintf("Run %s/%s to %s", options.programID, options.entryID, objective.TargetID) } + presentation, presentationErr := humanIdentityPresentationFromBoundConfig(filepath.Join(repository, ".boatstack", "project.json"), bundle.Source) + if presentationErr != nil { + return commandOptions{}, presentationErr + } delegationRequest := delegation.Request{ RunID: options.runID, ProgramID: options.programID, ProgramFingerprint: compiled.Fingerprint, ControlBundleFingerprint: bundleFingerprint, @@ -270,7 +274,8 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, InputFingerprints: []string{planFingerprint}, RepositoryID: invocation.RepositoryID, GitCommonID: invocation.GitCommonID, InitialWorktreeID: invocation.WorktreeID, InitialRef: invocation.Ref, BindingFingerprint: entry.Delegation.Fingerprint, RequestedAuthorities: append([]string(nil), entry.Delegation.Authorities...), - Description: description, + HumanIdentityProviderFingerprint: presentation.ProviderFingerprint, + Description: description, } layout, _, layoutErr := contextResolver.ResolveLayout(ctx, invocation) if layoutErr != nil { @@ -283,7 +288,7 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, if record, loadErr := delegation.Load(recordPath); loadErr == nil { bound := record.Request inputDrift := !options.activeFlowBound && strings.Join(bound.InputFingerprints, "\x00") != strings.Join(delegationRequest.InputFingerprints, "\x00") - if bound.RunID != delegationRequest.RunID || bound.ProgramID != delegationRequest.ProgramID || bound.ProgramFingerprint != delegationRequest.ProgramFingerprint || bound.ControlBundleFingerprint != delegationRequest.ControlBundleFingerprint || bound.EntryID != delegationRequest.EntryID || bound.TargetID != delegationRequest.TargetID || bound.ObjectiveID != delegationRequest.ObjectiveID || bound.DeliveryID != delegationRequest.DeliveryID || inputDrift || bound.RepositoryID != delegationRequest.RepositoryID || bound.GitCommonID != delegationRequest.GitCommonID || bound.BindingFingerprint != delegationRequest.BindingFingerprint || strings.Join(bound.RequestedAuthorities, "\x00") != strings.Join(delegationRequest.RequestedAuthorities, "\x00") || bound.Description != delegationRequest.Description { + if bound.RunID != delegationRequest.RunID || bound.ProgramID != delegationRequest.ProgramID || bound.ProgramFingerprint != delegationRequest.ProgramFingerprint || bound.ControlBundleFingerprint != delegationRequest.ControlBundleFingerprint || bound.EntryID != delegationRequest.EntryID || bound.TargetID != delegationRequest.TargetID || bound.ObjectiveID != delegationRequest.ObjectiveID || bound.DeliveryID != delegationRequest.DeliveryID || inputDrift || bound.RepositoryID != delegationRequest.RepositoryID || bound.GitCommonID != delegationRequest.GitCommonID || bound.BindingFingerprint != delegationRequest.BindingFingerprint || bound.HumanIdentityProviderFingerprint != delegationRequest.HumanIdentityProviderFingerprint || strings.Join(bound.RequestedAuthorities, "\x00") != strings.Join(delegationRequest.RequestedAuthorities, "\x00") || bound.Description != delegationRequest.Description { reprojected, reprojectErr := canReprojectDelegation(layout, invocation, bound, delegationRequest) if reprojectErr != nil { return commandOptions{}, reprojectErr diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index 5365ba6..e446eb3 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -249,7 +249,7 @@ func TestFreshFlowInitializationRejectsDirtyCanonicalConfigurationBeforeEffects( runFlowGit(t, repository, "add", ".") runFlowGit(t, repository, "commit", "-q", "-m", "fixture") - writeFixture(t, repository, ".boatstack/project.json", []byte(`{"schema_version":2,"project":{"name":"dirty","default_branch":"main","commands":{"test":"false"}},"policy":{"plan_approval":"human-or-autonomy","visual_evidence":"optional"},"hosts":["cli","codex","claude"]}`)) + writeFixture(t, repository, ".boatstack/project.json", []byte(`{"schema_version":3,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"dirty","default_branch":"main","commands":{"test":"false"}},"policy":{"plan_approval":"human-or-autonomy","visual_evidence":"optional"},"hosts":["cli","codex","claude"]}`)) questionRaw, err := captureRunOutput(t, "flow", "run", "--repo", repository, "--flow", "product-delivery", "--entry", "run", "--repository-authority", "--host", "codex", "--format", "json", @@ -504,7 +504,7 @@ func writeFlowArtifact(t *testing.T, repository string, document controlprogram. t.Helper() projectPath := filepath.Join(repository, ".boatstack", "project.json") if _, err := os.Stat(projectPath); os.IsNotExist(err) { - writeFixture(t, repository, ".boatstack/project.json", []byte(`{"schema_version":2,"project":{"name":"fixture","default_branch":"main","commands":{}},"policy":{"plan_approval":"human-or-autonomy","visual_evidence":"optional"},"hosts":["cli","codex","claude"]}`)) + writeFixture(t, repository, ".boatstack/project.json", []byte(`{"schema_version":3,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"fixture","default_branch":"main","commands":{}},"policy":{"plan_approval":"human-or-autonomy","visual_evidence":"optional"},"hosts":["cli","codex","claude"]}`)) } resolver, err := softwareflow.NewResolver(context.Background()) if err != nil { @@ -2487,6 +2487,23 @@ func TestDelegationIsRequiredAndRevocationWinsBetweenNextAndApply(t *testing.T) if err != nil || lock != nil || suspension == nil || suspension.Delegation == nil || suspension.Delegation.Code != "DELEGATION_REQUIRED" || suspension.Delegation.RequestFingerprint != bound.delegationRequestFingerprint { t.Fatalf("delegation suspension = lock=%v response=%#v err=%v", lock, suspension, err) } + if suspension.Delegation.HumanIdentity.Descriptor.Kind != "literal" || suspension.Delegation.HumanIdentity.Descriptor.Value != "operator" || suspension.Delegation.HumanIdentity.ProviderFingerprint != bound.delegationRequest.HumanIdentityProviderFingerprint { + t.Fatalf("delegation human identity = %#v, request = %#v", suspension.Delegation.HumanIdentity, bound.delegationRequest) + } + authority := request.Authority.Set(time.Now().UTC()) + for _, forbidden := range []catalog.AuthorityClass{catalog.AuthorityHuman, catalog.AuthorityAutonomy, catalog.AuthorityProvider} { + if authority[forbidden] { + t.Fatalf("identity presentation granted %s authority: %#v", forbidden, request.Authority) + } + } + restartedRequest, err := buildRequest(surfaces.OperationResolve, bound) + if err != nil { + t.Fatal(err) + } + _, restartedSuspension, err := prepareDelegation(context.Background(), &restartedRequest) + if err != nil || restartedSuspension == nil || restartedSuspension.Delegation == nil || !reflect.DeepEqual(restartedSuspension.Delegation.HumanIdentity, suspension.Delegation.HumanIdentity) || restartedSuspension.Delegation.RequestFingerprint != suspension.Delegation.RequestFingerprint { + t.Fatalf("restart changed delegation identity: first=%#v restarted=%#v err=%v", suspension.Delegation, restartedSuspension, err) + } explainRequest, err := buildRequest(surfaces.OperationExplain, bound) if err != nil { t.Fatal(err) @@ -2516,7 +2533,12 @@ func TestDelegationIsRequiredAndRevocationWinsBetweenNextAndApply(t *testing.T) t.Fatalf("explain created a delegation record: %v", err) } now := time.Now().UTC() - record := delegation.Record{Schema: delegation.Schema, SchemaRevision: delegation.SchemaRevision, Request: bound.delegationRequest, RequestFingerprint: bound.delegationRequestFingerprint, ReceiptID: "authorization-test", Actor: "human@example.com", AuthorizedAt: now, Revision: 1, Status: "active"} + record := delegation.Record{ + Schema: delegation.Schema, SchemaRevision: delegation.SchemaRevision, + Request: bound.delegationRequest, RequestFingerprint: bound.delegationRequestFingerprint, + ReceiptID: "authorization-test", Actor: "operator", ActorIdentityProviderFingerprint: bound.delegationRequest.HumanIdentityProviderFingerprint, + AuthorizedAt: now, Revision: 1, Status: "active", + } if err := effects.StoreDelegationRecord(recordPath, record); err != nil { t.Fatal(err) } @@ -2591,14 +2613,17 @@ func TestDelegationIsRequiredAndRevocationWinsBetweenNextAndApply(t *testing.T) t.Fatalf("expired delegation = lock=%v response=%#v err=%v", expiredLock, expiredSuspension, expiredErr) } renewedAt := time.Now().UTC() - renewed, changed, err := authorizeDelegation(&record, bound.delegationRequest, bound.delegationRequestFingerprint, record.Actor, time.Hour, renewedAt, false) + if _, _, staleErr := authorizeDelegation(&record, bound.delegationRequest, bound.delegationRequestFingerprint, strings.Repeat("0", 64), record.Actor, time.Hour, renewedAt, false); staleErr == nil || !strings.Contains(staleErr.Error(), "HUMAN_IDENTITY_DRIFT") { + t.Fatalf("stale identity provider authorization = %v", staleErr) + } + renewed, changed, err := authorizeDelegation(&record, bound.delegationRequest, bound.delegationRequestFingerprint, record.ActorIdentityProviderFingerprint, record.Actor, time.Hour, renewedAt, false) if err != nil || !changed || renewed.Revision != record.Revision+1 || renewed.ReceiptID == record.ReceiptID || !renewed.ExpiresAt.Equal(renewedAt.Add(time.Hour)) { t.Fatalf("renewed delegation = record=%#v changed=%v err=%v", renewed, changed, err) } - if idempotent, changedAgain, idempotentErr := authorizeDelegation(&renewed, bound.delegationRequest, bound.delegationRequestFingerprint, record.Actor, time.Hour, renewedAt.Add(time.Second), false); idempotentErr != nil || changedAgain || idempotent.ReceiptID != renewed.ReceiptID { + if idempotent, changedAgain, idempotentErr := authorizeDelegation(&renewed, bound.delegationRequest, bound.delegationRequestFingerprint, record.ActorIdentityProviderFingerprint, record.Actor, time.Hour, renewedAt.Add(time.Second), false); idempotentErr != nil || changedAgain || idempotent.ReceiptID != renewed.ReceiptID { t.Fatalf("idempotent renewal = record=%#v changed=%v err=%v", idempotent, changedAgain, idempotentErr) } - if _, _, conflictErr := authorizeDelegation(&renewed, bound.delegationRequest, bound.delegationRequestFingerprint, "other-actor", time.Hour, renewedAt, false); conflictErr == nil || !strings.Contains(conflictErr.Error(), "DELEGATION_CONFLICT") { + if _, _, conflictErr := authorizeDelegation(&renewed, bound.delegationRequest, bound.delegationRequestFingerprint, record.ActorIdentityProviderFingerprint, "other-actor", time.Hour, renewedAt, false); conflictErr == nil || !strings.Contains(conflictErr.Error(), "DELEGATION_CONFLICT") { t.Fatalf("conflicting renewal = %v", conflictErr) } if err := effects.StoreDelegationRecord(recordPath, renewed); err != nil { @@ -2687,7 +2712,7 @@ func TestExplicitAuthorizationCanReplaceRevokedPreReconciliationRequest(t *testi RunID: "run-example", ProgramID: "product-delivery", ProgramFingerprint: strings.Repeat("a", 64), ControlBundleFingerprint: strings.Repeat("b", 64), EntryID: "run", TargetID: "published-pr", ObjectiveID: "objective", DeliveryID: "delivery", InputFingerprints: []string{"plan"}, RepositoryID: "repository", GitCommonID: "common", InitialWorktreeID: "worktree", InitialRef: "refs/heads/main", - BindingFingerprint: strings.Repeat("c", 64), RequestedAuthorities: []string{"autonomy"}, Description: "Run product delivery", + BindingFingerprint: strings.Repeat("c", 64), HumanIdentityProviderFingerprint: strings.Repeat("f", 64), RequestedAuthorities: []string{"autonomy"}, Description: "Run product delivery", } priorFingerprint, err := prior.Fingerprint() if err != nil { @@ -2695,7 +2720,7 @@ func TestExplicitAuthorizationCanReplaceRevokedPreReconciliationRequest(t *testi } existing := delegation.Record{ Schema: delegation.Schema, SchemaRevision: delegation.SchemaRevision, Request: prior, RequestFingerprint: priorFingerprint, - ReceiptID: "authorization-prior", Actor: "operator", AuthorizedAt: time.Unix(1_700_000_000, 0).UTC(), Revision: 3, Status: "revoked", + ReceiptID: "authorization-prior", Actor: "operator", ActorIdentityProviderFingerprint: prior.HumanIdentityProviderFingerprint, AuthorizedAt: time.Unix(1_700_000_000, 0).UTC(), Revision: 3, Status: "revoked", } current := prior current.ProgramFingerprint, current.ControlBundleFingerprint = strings.Repeat("d", 64), strings.Repeat("e", 64) @@ -2704,11 +2729,11 @@ func TestExplicitAuthorizationCanReplaceRevokedPreReconciliationRequest(t *testi t.Fatal(err) } now := time.Unix(1_700_000_100, 0).UTC() - refreshed, changed, err := authorizeDelegation(&existing, current, currentFingerprint, "operator", 0, now, true) + refreshed, changed, err := authorizeDelegation(&existing, current, currentFingerprint, current.HumanIdentityProviderFingerprint, "operator", 0, now, true) if err != nil || !changed || refreshed.Status != "active" || refreshed.Revision != 4 || refreshed.RequestFingerprint != currentFingerprint || refreshed.ReceiptID == existing.ReceiptID { t.Fatalf("reprojected authorization = %#v changed=%t err=%v", refreshed, changed, err) } - if _, _, err := authorizeDelegation(&existing, current, currentFingerprint, "operator", 0, now, false); err == nil { + if _, _, err := authorizeDelegation(&existing, current, currentFingerprint, current.HumanIdentityProviderFingerprint, "operator", 0, now, false); err == nil { t.Fatal("revoked authority was replaced without an admitted reprojection") } } diff --git a/boatstack/cmd/boatstack-helper/human_identity.go b/boatstack/cmd/boatstack-helper/human_identity.go new file mode 100644 index 0000000..34004d1 --- /dev/null +++ b/boatstack/cmd/boatstack-helper/human_identity.go @@ -0,0 +1,145 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + + boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/humanidentity" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/surfaces" +) + +func humanIdentityPresentationForRequest(request surfaces.Request) (humanidentity.Presentation, error) { + if request.ControlBundle == nil { + return humanidentity.Presentation{}, fmt.Errorf("HUMAN_IDENTITY_UNBOUND: request has no verified control bundle") + } + snapshot := request.ControlBundle.Source + configPath := filepath.Join(request.Repository, ".boatstack", "project.json") + if request.TransitionID == "installation.initialize" { + if request.ControlBundle.Target == nil { + return humanidentity.Presentation{}, fmt.Errorf("HUMAN_IDENTITY_UNBOUND: initialization has no target control bundle") + } + snapshot = *request.ControlBundle.Target + value, ok := request.Parameters.Get("config_path") + if !ok { + return humanidentity.Presentation{}, fmt.Errorf("HUMAN_IDENTITY_UNBOUND: initialization has no configuration path") + } + configPath = value + } + return humanIdentityPresentationFromBoundConfig(configPath, snapshot) +} + +func humanIdentityPresentationFromBoundConfig(configPath string, snapshot boatstackruntime.ControlBundleSnapshot) (humanidentity.Presentation, error) { + var binding *boatstackruntime.ControlBundleFile + for index := range snapshot.Files { + if snapshot.Files[index].Path == ".boatstack/project.json" { + binding = &snapshot.Files[index] + break + } + } + if binding == nil || binding.Absent { + return humanidentity.Presentation{}, fmt.Errorf("HUMAN_IDENTITY_UNBOUND: verified project configuration is absent") + } + info, err := os.Lstat(configPath) + if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return humanidentity.Presentation{}, fmt.Errorf("HUMAN_IDENTITY_UNBOUND: project configuration is not a regular file") + } + raw, err := os.ReadFile(configPath) + if err != nil { + return humanidentity.Presentation{}, err + } + digest := sha256.Sum256(raw) + if hex.EncodeToString(digest[:]) != binding.SHA256 { + return humanidentity.Presentation{}, fmt.Errorf("HUMAN_IDENTITY_DRIFT: project configuration does not match the verified control bundle") + } + config, err := protocol.DecodeProjectConfig(raw) + if err != nil { + return humanidentity.Presentation{}, err + } + return humanidentity.NewPresentation(config.Identity.Human) +} + +func humanIdentityPresentationForRepository(repository string) (humanidentity.Presentation, error) { + presentation, _, err := humanIdentityPresentationAndFingerprintForRepository(repository) + return presentation, err +} + +func humanIdentityPresentationAndFingerprintForRepository(repository string) (humanidentity.Presentation, string, error) { + raw, err := os.ReadFile(filepath.Join(repository, ".boatstack", "project.json")) + if err != nil { + return humanidentity.Presentation{}, "", err + } + config, fingerprint, err := protocol.ProjectConfigFingerprint(raw) + if err != nil { + return humanidentity.Presentation{}, "", err + } + presentation, err := humanidentity.NewPresentation(config.Identity.Human) + return presentation, fingerprint, err +} + +func attachHumanIdentity(request surfaces.Request, response *surfaces.Response) error { + if response == nil || response.Question == nil || !questionRequiresHuman(*response.Question) { + return nil + } + // Before installation there is no repository-selected identity descriptor + // to bind. The bootstrap caller must supply an explicit actor; once a + // candidate or installed configuration exists, every human question below + // is required to carry its verified descriptor. + if request.ControlBundle == nil && response.Question.TransitionID == "installation.initialize" { + return nil + } + var presentation humanidentity.Presentation + var err error + if request.ControlBundle != nil { + presentation, err = humanIdentityPresentationForRequest(request) + } else if request.ProgramID == "" && response.Snapshot != nil { + var fingerprint string + presentation, fingerprint, err = humanIdentityPresentationAndFingerprintForRepository(request.Repository) + if err == nil { + bound := false + for _, evidence := range response.Snapshot.Configuration.Evidence { + if evidence.Fingerprint == fingerprint { + bound = true + break + } + } + if !bound { + return fmt.Errorf("HUMAN_IDENTITY_DRIFT: project configuration does not match the observed configuration") + } + } + } else { + err = fmt.Errorf("HUMAN_IDENTITY_UNBOUND: human authority question has no verified project configuration") + } + if err != nil { + return err + } + response.Question.HumanIdentity = &presentation + return nil +} + +func questionRequiresHuman(question surfaces.Question) bool { + for _, authority := range append(append([]catalog.AuthorityClass(nil), question.Authority...), question.AuthorityAll...) { + if authority == catalog.AuthorityHuman { + return true + } + } + return false +} + +func renderHumanIdentity(presentation humanidentity.Presentation) { + fmt.Printf("human_identity_provider=%s kind=%s\n", presentation.ProviderFingerprint, presentation.Descriptor.Kind) + if presentation.Descriptor.Kind == humanidentity.KindLiteral { + fmt.Printf("suggested_human_actor=%s\n", presentation.Descriptor.Value) + return + } + fmt.Printf("human_identity_command=%q", presentation.Descriptor.Command) + for _, argument := range presentation.Descriptor.Args { + fmt.Printf(" %q", argument) + } + fmt.Println() +} diff --git a/boatstack/cmd/boatstack-helper/human_identity_test.go b/boatstack/cmd/boatstack-helper/human_identity_test.go new file mode 100644 index 0000000..cd3d76b --- /dev/null +++ b/boatstack/cmd/boatstack-helper/human_identity_test.go @@ -0,0 +1,111 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/humanidentity" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/surfaces" +) + +func TestHumanIdentityPresentationIsBoundToVerifiedConfiguration(t *testing.T) { + repository := t.TempDir() + configRaw := []byte(`{"schema_version":3,"identity":{"human":{"kind":"command","command":"gh","args":["api","user","--jq",".login"]}},"project":{"name":"fixture","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli","codex"]}`) + configPath := filepath.Join(repository, ".boatstack", "project.json") + if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(configPath, configRaw, 0o600); err != nil { + t.Fatal(err) + } + snapshot, err := boatstackruntime.NewControlBundleSnapshot(map[string][]byte{".boatstack/project.json": configRaw}) + if err != nil { + t.Fatal(err) + } + contract, err := boatstackruntime.NewControlBundleContract(snapshot, nil, "") + if err != nil { + t.Fatal(err) + } + request := surfaces.Request{Repository: repository, ControlBundle: &contract} + presentation, err := humanIdentityPresentationForRequest(request) + if err != nil { + t.Fatal(err) + } + wantDescriptor := humanidentity.Descriptor{Kind: humanidentity.KindCommand, Command: "gh", Args: []string{"api", "user", "--jq", ".login"}} + if !reflect.DeepEqual(presentation.Descriptor, wantDescriptor) || presentation.Validate() != nil { + t.Fatalf("presentation = %#v", presentation) + } + response := surfaces.Response{Question: &surfaces.Question{Authority: []catalog.AuthorityClass{catalog.AuthorityHuman}}} + if err := attachHumanIdentity(request, &response); err != nil || response.Question.HumanIdentity == nil || !reflect.DeepEqual(*response.Question.HumanIdentity, presentation) { + t.Fatalf("attached identity = %#v, err=%v", response.Question.HumanIdentity, err) + } + + if err := os.WriteFile(configPath, []byte(strings.ReplaceAll(string(configRaw), ".login", ".name")), 0o600); err != nil { + t.Fatal(err) + } + if _, err := humanIdentityPresentationForRequest(request); err == nil || !strings.Contains(err.Error(), "HUMAN_IDENTITY_DRIFT") { + t.Fatalf("changed configuration was not rejected: %v", err) + } +} + +func TestHumanIdentityIsAttachedOnlyToHumanAuthorityQuestions(t *testing.T) { + response := surfaces.Response{Question: &surfaces.Question{Authority: []catalog.AuthorityClass{catalog.AuthorityRepository}}} + if err := attachHumanIdentity(surfaces.Request{}, &response); err != nil || response.Question.HumanIdentity != nil { + t.Fatalf("non-human question gained identity: %#v err=%v", response.Question.HumanIdentity, err) + } +} + +func TestPreconfigurationInitializationUsesOnlyExplicitActorBootstrap(t *testing.T) { + response := surfaces.Response{Question: &surfaces.Question{TransitionID: "installation.initialize", Authority: []catalog.AuthorityClass{catalog.AuthorityHuman}}} + if err := attachHumanIdentity(surfaces.Request{}, &response); err != nil || response.Question.HumanIdentity != nil { + t.Fatalf("preconfiguration bootstrap identity = %#v err=%v", response.Question.HumanIdentity, err) + } + response.Question.TransitionID = "plan.approve" + if err := attachHumanIdentity(surfaces.Request{}, &response); err == nil || !strings.Contains(err.Error(), "HUMAN_IDENTITY_UNBOUND") { + t.Fatalf("configured human boundary did not fail closed: %v", err) + } +} + +func TestHumanIdentityRenderingPreservesStructuredArgvWithoutExecutingIt(t *testing.T) { + marker := filepath.Join(t.TempDir(), "executed") + descriptor := humanidentity.Descriptor{Kind: humanidentity.KindCommand, Command: "touch", Args: []string{marker}} + presentation, err := humanidentity.NewPresentation(descriptor) + if err != nil { + t.Fatal(err) + } + response := surfaces.Response{Delegation: &surfaces.DelegationRequired{ + Code: "DELEGATION_REQUIRED", RunID: "run-example", RequestFingerprint: strings.Repeat("a", 64), + Authorities: []catalog.AuthorityClass{catalog.AuthorityAutonomy}, Description: "authorize exact run", HumanIdentity: presentation, + }} + raw, err := json.Marshal(response) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), `"command":"touch","args":["`+marker+`"]`) { + t.Fatalf("structured JSON lost command argv: %s", raw) + } + output, err := captureStdout(t, func() error { return renderResponse(response, "text") }) + if err != nil || !strings.Contains(string(output), `human_identity_command="touch" "`+marker+`"`) { + t.Fatalf("text output = %q, err=%v", output, err) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("rendering executed identity command: %v", err) + } +} + +func TestAuthorizationReceiptIdentityBindsIdentityProvider(t *testing.T) { + now := time.Unix(1_700_000_000, 0).UTC() + requestFingerprint := strings.Repeat("a", 64) + first := authorizationReceiptID(requestFingerprint, "operator", strings.Repeat("b", 64), 1, now) + second := authorizationReceiptID(requestFingerprint, "operator", strings.Repeat("c", 64), 1, now) + if first == second { + t.Fatal("identity provider change preserved authorization receipt ID") + } +} diff --git a/boatstack/cmd/boatstack-helper/input_command.go b/boatstack/cmd/boatstack-helper/input_command.go index 232a5ad..c81a92c 100644 --- a/boatstack/cmd/boatstack-helper/input_command.go +++ b/boatstack/cmd/boatstack-helper/input_command.go @@ -15,6 +15,7 @@ import ( softwareflow "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery" boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/effects" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/humanidentity" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/plant" "github.com/operatorstack/boatstack/boatstack/invocation" ) @@ -91,17 +92,27 @@ func runFlowInput(arguments []string) error { if request.ProgramFingerprint != compiled.Fingerprint || request.EntryID != options.entryID { return fmt.Errorf("FLOW_INPUT_REQUEST_MISMATCH: request does not belong to the selected program and entry") } + if request.ControlBundleFingerprint != "" && request.ControlBundleFingerprint != runtimeContext.controlBundle.Fingerprint { + return fmt.Errorf("HUMAN_IDENTITY_DRIFT: input request bundle %s does not match current verified bundle %s", request.ControlBundleFingerprint, runtimeContext.controlBundle.Fingerprint) + } if action == "show" { receipts, loadErr := store.LoadReceipts(options.runID, request.TransitionID) if loadErr != nil { return loadErr } - return encodeFlowInputResult(map[string]any{"request": request, "receipts": receipts}, options.format) + presentation, identityErr := humanIdentityPresentationFromBoundConfig(filepath.Join(repository, ".boatstack", "project.json"), runtimeContext.controlBundle.Source) + if identityErr != nil { + return identityErr + } + return encodeFlowInputResult(map[string]any{"request": request, "receipts": receipts, "human_identity": presentation}, options.format) } if action == "supersede" { if options.reason == "" || options.human == "" || options.host == "" { return fmt.Errorf("--reason, --human, and --host are required") } + if err := humanidentity.ValidateActor(options.human); err != nil { + return err + } if runtimeContext.executionScopeFingerprint != request.ExecutionScopeFingerprint { return fmt.Errorf("FLOW_INPUT_REQUEST_MISMATCH: execution scope changed after suspension") } @@ -140,6 +151,9 @@ func runFlowInput(arguments []string) error { if options.answerPath == "" || options.human == "" || options.host == "" { return fmt.Errorf("--answer, --human, and --host are required") } + if err := humanidentity.ValidateActor(options.human); err != nil { + return err + } answers, err := loadFlowInputAnswers(options.answerPath) if err != nil { return err @@ -153,6 +167,7 @@ func runFlowInput(arguments []string) error { type flowInputRuntimeContext struct { executionScopeFingerprint string + controlBundle *boatstackruntime.ControlBundleContract } func loadFlowInputContext(ctx context.Context, options flowInputOptions) (controlprogram.Compiled, invocation.Store, flowInputRuntimeContext, error) { @@ -188,7 +203,11 @@ func loadFlowInputContext(ctx context.Context, options flowInputOptions) (contro if err != nil { return controlprogram.Compiled{}, invocation.Store{}, flowInputRuntimeContext{}, err } - return compiled, invocation.Store{Root: layout.FlowRoot, Writer: effects.NewRuntimeStore()}, flowInputRuntimeContext{executionScopeFingerprint: executionScopeFingerprint}, nil + controlBundle, _, err := bindControlBundle(ctx, options.repository, "", nil) + if err != nil { + return controlprogram.Compiled{}, invocation.Store{}, flowInputRuntimeContext{}, err + } + return compiled, invocation.Store{Root: layout.FlowRoot, Writer: effects.NewRuntimeStore()}, flowInputRuntimeContext{executionScopeFingerprint: executionScopeFingerprint, controlBundle: controlBundle}, nil } func loadFlowInputAnswers(path string) (map[string]string, error) { diff --git a/boatstack/cmd/boatstack-helper/input_command_test.go b/boatstack/cmd/boatstack-helper/input_command_test.go index a4ec202..3397d5d 100644 --- a/boatstack/cmd/boatstack-helper/input_command_test.go +++ b/boatstack/cmd/boatstack-helper/input_command_test.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "context" "encoding/json" "os" @@ -65,6 +66,51 @@ func TestFlowInputAnswerResumesSameRunAndConflictsFailClosed(t *testing.T) { } } +func TestFlowInputAnswerRejectsIdentityControlBundleDriftBeforeReceipt(t *testing.T) { + repository := flowRepositoryWithHumanSlice(t) + runFlowGit(t, repository, "init", "-q") + writeFixture(t, repository, ".boatstack/plans/inbox/delivery-one.md", []byte("plan")) + runFlowGit(t, repository, "add", ".") + runFlowGit(t, repository, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "-q", "-m", "fixture") + writeAdmittedFlowProgramState(t, repository, strings.Repeat("f", 64)) + + suspended, err := bindFlowEntry(context.Background(), commandOptions{ + repository: repository, programID: "product-delivery", entryID: "run", host: "codex", transitionID: "delivery.slice.advance", + }) + if err != nil || suspended.inputRequest == nil { + t.Fatalf("suspension = %#v, err=%v", suspended.inputRequest, err) + } + answerPath := filepath.Join(t.TempDir(), "answer.json") + if err := os.WriteFile(answerPath, []byte(`{"slice_id":"slice-one"}`), 0o600); err != nil { + t.Fatal(err) + } + arguments := []string{ + "answer", "--repo", repository, "--flow", "product-delivery", "--entry", "run", "--run-id", suspended.runID, + "--request-fingerprint", suspended.inputRequest.Fingerprint, "--answer", answerPath, "--human", "operator", "--host", "codex", "--format", "json", + } + configPath := filepath.Join(repository, ".boatstack", "project.json") + original, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + drifted := bytes.Replace(original, []byte(`"value":"operator"`), []byte(`"value":"other-operator"`), 1) + if bytes.Equal(drifted, original) { + t.Fatal("fixture project configuration has no literal identity") + } + if err := os.WriteFile(configPath, drifted, 0o600); err != nil { + t.Fatal(err) + } + if _, err := captureStdout(t, func() error { return runFlowInput(arguments) }); err == nil || !strings.Contains(err.Error(), "HUMAN_IDENTITY_DRIFT") { + t.Fatalf("drifted answer result = %v", err) + } + if err := os.WriteFile(configPath, original, 0o600); err != nil { + t.Fatal(err) + } + if _, err := captureStdout(t, func() error { return runFlowInput(arguments) }); err != nil { + t.Fatalf("failed drift attempt contaminated immutable input receipts: %v", err) + } +} + func TestRejectedHostAnswerCanBeSupersededWithoutMutation(t *testing.T) { // control-law: semantic rejection preserves the original request and receipt // while a fresh request generation can collect a corrected free-form value. diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index 731b187..14e268a 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -25,6 +25,7 @@ import ( boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/delegation" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/humanidentity" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/surfaces" @@ -168,6 +169,9 @@ func run(arguments []string) error { return err } if programChangeResponse != nil { + if err := attachHumanIdentity(request, programChangeResponse); err != nil { + return err + } return renderResponse(*programChangeResponse, options.format) } delegationLock, delegationResponse, err := prepareDelegation(context.Background(), &request) @@ -212,6 +216,9 @@ func run(arguments []string) error { } resolveRequest.Prescription = protocol.Prescription{} resolved, resolveErr := kernel.Handle(context.Background(), resolveRequest) + if identityErr := attachHumanIdentity(resolveRequest, &resolved); identityErr != nil { + return identityErr + } if resolveErr != nil || resolved.Prescription == nil { if renderErr := renderResponse(resolved, options.format); renderErr != nil { return renderErr @@ -238,6 +245,9 @@ func run(arguments []string) error { handleErr = settleErr } } + if err := attachHumanIdentity(request, &response); err != nil { + return err + } if command == "events" && options.follow { if options.format != "jsonl" { return fmt.Errorf("events --follow requires --format jsonl") @@ -289,6 +299,9 @@ func runRPC() error { return err } if programChangeResponse != nil { + if err := attachHumanIdentity(request, programChangeResponse); err != nil { + return err + } encoder := json.NewEncoder(os.Stdout) encoder.SetIndent("", " ") return encoder.Encode(programChangeResponse) @@ -345,6 +358,9 @@ func runRPC() error { handleErr = settleErr } } + if err := attachHumanIdentity(request, &response); err != nil { + return err + } encoder := json.NewEncoder(os.Stdout) encoder.SetIndent("", " ") if err := encoder.Encode(response); err != nil { @@ -478,6 +494,11 @@ func parseOptions(command string, arguments []string, transition catalog.Transit if flags.NArg() != 0 { return commandOptions{}, fmt.Errorf("unexpected positional arguments: %s", strings.Join(flags.Args(), " ")) } + if options.humanActor != "" { + if err := humanidentity.ValidateActor(options.humanActor); err != nil { + return commandOptions{}, err + } + } switch command { case "init": if err := populateInitParameters(&options); err != nil { @@ -635,9 +656,6 @@ func populateInitParameters(options *commandOptions) error { } options.parameters = append(options.parameters, "config_sha256="+fingerprint) } - if options.humanActor == "" { - return fmt.Errorf("init requires explicit --human ") - } return nil } @@ -880,6 +898,9 @@ func loadAuthority(options commandOptions, correlation string, objective model.O bundle.Receipts = append(bundle.Receipts, receipt) } if options.humanActor != "" { + if err := humanidentity.ValidateActor(options.humanActor); err != nil { + return protocol.AuthorityBundle{}, err + } parameterRaw, err := json.Marshal(parameters.Canonical()) if err != nil { return protocol.AuthorityBundle{}, err @@ -965,6 +986,18 @@ func renderResponse(response surfaces.Response, format string) error { } return nil } + if response.Delegation != nil { + fmt.Printf("SUSPENDED: %s run=%s request=%s authorities=%v\n%s\n", response.Delegation.Code, response.Delegation.RunID, response.Delegation.RequestFingerprint, response.Delegation.Authorities, response.Delegation.Description) + renderHumanIdentity(response.Delegation.HumanIdentity) + return nil + } + if response.Question != nil { + fmt.Printf("QUESTION: %s run=%s transition=%s\n%s\n", response.Question.ID, response.Question.RunID, response.Question.TransitionID, response.Question.Prompt) + if response.Question.HumanIdentity != nil { + renderHumanIdentity(*response.Question.HumanIdentity) + } + return nil + } if response.CommitRequired != nil { fmt.Printf("SUSPENDED: %s run=%s revision=%s bundle=%s\n%s\n", response.CommitRequired.Code, response.CommitRequired.RunID, response.CommitRequired.Revision, response.CommitRequired.ControlBundleFingerprint, response.CommitRequired.Description) return nil diff --git a/boatstack/cmd/boatstack-helper/main_test.go b/boatstack/cmd/boatstack-helper/main_test.go index d0e416d..f352606 100644 --- a/boatstack/cmd/boatstack-helper/main_test.go +++ b/boatstack/cmd/boatstack-helper/main_test.go @@ -165,6 +165,24 @@ func TestHumanPublicationConfirmationBindsExactPreviewFingerprint(t *testing.T) } } +func TestHumanAuthorityNeverFallsBackToEnvironmentOrGitIdentity(t *testing.T) { + t.Setenv("USER", "implicit-user") + t.Setenv("LOGNAME", "implicit-logname") + t.Setenv("USERNAME", "implicit-username") + authority, err := loadAuthority(commandOptions{}, "correlation", model.Objective{ID: "objective"}, nil, time.Now().UTC()) + if err != nil { + t.Fatal(err) + } + for _, receipt := range authority.Receipts { + if receipt.Class == catalog.AuthorityHuman { + t.Fatalf("environment identity created human authority: %#v", receipt) + } + } + if _, err := loadAuthority(commandOptions{humanActor: "invalid actor"}, "correlation", model.Objective{ID: "objective"}, nil, time.Now().UTC()); err == nil { + t.Fatal("invalid explicit actor was accepted") + } +} + func TestRawCLIReconstructsExactCapabilityPrescription(t *testing.T) { options := commandOptions{ transitionID: "installation.update", host: "cli", repository: ".", prescriptionID: "prx-test", diff --git a/boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go b/boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go index c4bd26d..8e96f0d 100644 --- a/boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go +++ b/boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go @@ -57,7 +57,7 @@ func TestExactProductDeliveryFlowReachesPublishedPRWithFakeProvider(t *testing.T runFlowGit(t, repository, "init", "-q", "-b", "main") runFlowGit(t, repository, "config", "user.email", "fixture@example.invalid") runFlowGit(t, repository, "config", "user.name", "Fixture") - writeFixture(t, repository, ".boatstack/project.json", []byte(`{"schema_version":2,"project":{"name":"todo","default_branch":"main","commands":{"build":"true","test":"true"}},"policy":{"plan_approval":"human-or-autonomy","visual_evidence":"optional","external_effect_authority":"human-or-autonomy-plus-provider","independent_review_for_high_risk":false},"hosts":["cli","codex","claude"]}`)) + writeFixture(t, repository, ".boatstack/project.json", []byte(`{"schema_version":3,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"todo","default_branch":"main","commands":{"build":"true","test":"true"}},"policy":{"plan_approval":"human-or-autonomy","visual_evidence":"optional","external_effect_authority":"human-or-autonomy-plus-provider","independent_review_for_high_risk":false},"hosts":["cli","codex","claude"]}`)) writeFixture(t, repository, ".boatstack/plans/inbox/todo.md", []byte("# Add one todo\n")) for path, content := range assets { writeFixture(t, repository, path, content) @@ -204,7 +204,9 @@ func TestExactProductDeliveryFlowReachesPublishedPRWithFakeProvider(t *testing.T if _, err := captureStdout(t, func() error { return runFlowAuthorize([]string{ "--repo", repository, "--flow", "product-delivery", "--entry", "run", "--run-id", runID, - "--request-fingerprint", delegated.Delegation.RequestFingerprint, "--human", "operator", "--host", "codex", + "--request-fingerprint", delegated.Delegation.RequestFingerprint, + "--human-identity-provider-fingerprint", delegated.Delegation.HumanIdentity.ProviderFingerprint, + "--human", "operator", "--host", "codex", }) }); err != nil { t.Fatal(err) diff --git a/boatstack/distribution/standard_test.go b/boatstack/distribution/standard_test.go index 9ed691a..5fada7f 100644 --- a/boatstack/distribution/standard_test.go +++ b/boatstack/distribution/standard_test.go @@ -14,6 +14,7 @@ import ( "testing" "github.com/operatorstack/boatstack/boatstack/delivery" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/humanidentity" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" ) @@ -60,6 +61,7 @@ func TestRepositoryScopedProgramsAreIndependentUnderConcurrency(t *testing.T) { x, y := extensions("fixture.echo"), extensions("fixture.second") candidateConfig := protocol.ProjectConfig{ SchemaVersion: protocol.ConfigSchemaVersion, + Identity: protocol.IdentitySettings{Human: humanidentity.Descriptor{Kind: humanidentity.KindLiteral, Value: "operator"}}, Project: protocol.ProjectSettings{Name: "fixture", DefaultBranch: "main", Commands: map[string]string{}}, Policy: protocol.PolicySettings{PlanApproval: "human", VisualEvidence: "optional"}, Hosts: []string{"cli"}, Extensions: []protocol.SubprocessExtensionSettings{x}, @@ -189,6 +191,7 @@ func repositoryFixture(t *testing.T, extensions []protocol.SubprocessExtensionSe if extensions != nil { configuration := protocol.ProjectConfig{ SchemaVersion: protocol.ConfigSchemaVersion, + Identity: protocol.IdentitySettings{Human: humanidentity.Descriptor{Kind: humanidentity.KindLiteral, Value: "operator"}}, Project: protocol.ProjectSettings{Name: "fixture", DefaultBranch: "main", Commands: map[string]string{}}, Policy: protocol.PolicySettings{PlanApproval: "human", VisualEvidence: "optional"}, Hosts: []string{"cli"}, Extensions: extensions, diff --git a/boatstack/flow/softwaredelivery/skills.go b/boatstack/flow/softwaredelivery/skills.go index 3004dfd..fb41a0c 100644 --- a/boatstack/flow/softwaredelivery/skills.go +++ b/boatstack/flow/softwaredelivery/skills.go @@ -62,6 +62,29 @@ func renderSkill(compiled controlprogram.Compiled, entry controlprogram.Entry, s entryInputProtocol := "" programReconciliation := "" publication := "" + humanIdentityProtocol := ` +Whenever Boatstack presents a human authority boundary, inspect its exact +` + "`human_identity`" + ` object before asking for approval or recording an actor. +The ` + "`provider_fingerprint`" + ` identifies the repository-selected identity +descriptor; it is provenance only and grants no authority. + +For a ` + "`literal`" + ` descriptor, use its validated ` + "`value`" + ` as the proposed +actor. For a ` + "`command`" + ` descriptor, execute the exact ` + "`command`" + ` and +` + "`args`" + ` directly through the host command tool. Do not join them into a shell +string, interpolate values, rewrite arguments, or use a shell evaluator. Require a +zero exit status and stdout of at most 1024 bytes. Remove at most one trailing LF or +CRLF, then require exactly one non-empty line with no NUL and an actor matching +` + "`^[A-Za-z0-9][A-Za-z0-9._-]*$`" + `. Stderr is diagnostic only. + +Visibly display the proposed actor, exact request or transition, requested +authority, and relevant fingerprint, then ask the human for explicit approval. +Identity resolution never counts as approval. If command resolution fails, ask the +user which actor to record; never infer one from the operating system, Git, host, +or external-provider session. Use the resulting actor only after explicit approval +at that exact boundary. Re-resolve if Boatstack reports identity or configuration +drift. Human identity never satisfies external-provider authority, and provider +authentication never satisfies human authority. +` startCommand := fmt.Sprintf("boatstack next --repo . --flow %s --entry %s --repository-authority --host %s --format json", compiled.Document.Program.ID, entry.ID, host) if entry.Delegation != nil { startCommand = fmt.Sprintf("boatstack flow run --repo . --flow %s --entry %s --repository-authority --host %s --format json", compiled.Document.Program.ID, entry.ID, host) @@ -231,7 +254,7 @@ After internal preconditions are committed, Boatstack returns a typed Display its exact run ID, request fingerprint, requested authorities, and description. Obtain one explicit human approval for that exact request, then run: -`+"`boatstack flow authorize --repo . --flow %s --entry %s --run-id --request-fingerprint --human --host %s`"+` +`+"`boatstack flow authorize --repo . --flow %s --entry %s --run-id --request-fingerprint --human-identity-provider-fingerprint --human --host %s`"+` After authorization, use `+"`boatstack flow run --repo . --flow %s --entry %s --run-id --repository-authority --host %s --format json`"+`. Do not request approval again after a restart or typed suspension. Resume the @@ -289,11 +312,12 @@ background while input is missing. Never synthesize authority. %s %s %s +%s Stop only when Boatstack reports the marked target, a typed blocker, refusal, unresolved recovery, or missing authority. This entry grants no merge or deploy authority. -`, slug, description, title(slug), compiled.Document.Program.ID, entry.ID, entry.Target, skillprojection.BootstrapContract(), startCommand, delegation, supersession, diagnostics, workProtocol, inputProtocol, entryInputProtocol, programReconciliation, publication)) +`, slug, description, title(slug), compiled.Document.Program.ID, entry.ID, entry.Target, skillprojection.BootstrapContract(), startCommand, humanIdentityProtocol, delegation, supersession, diagnostics, workProtocol, inputProtocol, entryInputProtocol, programReconciliation, publication)) } func declarativeProgram(operators []controlprogram.Operator) bool { diff --git a/boatstack/flow/softwaredelivery/skills_test.go b/boatstack/flow/softwaredelivery/skills_test.go index af01310..f111628 100644 --- a/boatstack/flow/softwaredelivery/skills_test.go +++ b/boatstack/flow/softwaredelivery/skills_test.go @@ -48,11 +48,20 @@ func TestGeneratedSkillsProjectOnlyDeclaredEntriesWithHostParity(t *testing.T) { "boatstack reconcile-update --repo . --flow product-delivery --entry run --run-id ", "do not request or reuse product delegation before reconciliation", "commit\nthose exact files separately before product work", "Ask\nfor product delegation only after Boatstack returns the new exact delegation", + "inspect its exact\n`human_identity`", "provider_fingerprint", "execute the exact `command` and", + "at most 1024 bytes", "proposed actor", "ask the human for explicit approval", + "Identity resolution never counts as approval", "never infer one from the operating system, Git, host", + "--human-identity-provider-fingerprint ", } { if !strings.Contains(value, contract) { t.Fatalf("generated skill lacks %q", contract) } } + for _, forbidden := range []string{"$USER", "LOGNAME", "whoami", "git config user", "automatic approval"} { + if strings.Contains(value, forbidden) { + t.Fatalf("generated skill contains implicit identity or authority fallback %q", forbidden) + } + } for path := range files { if strings.Contains(path, "autoplan") || strings.Contains(path, "boatstack-run") { t.Fatalf("undeclared entry generated: %s", path) diff --git a/boatstack/internal/softwaredelivery/delegation/record.go b/boatstack/internal/softwaredelivery/delegation/record.go index 30923fb..0fd6ab5 100644 --- a/boatstack/internal/softwaredelivery/delegation/record.go +++ b/boatstack/internal/softwaredelivery/delegation/record.go @@ -13,37 +13,40 @@ import ( "regexp" "sort" "time" + + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/humanidentity" ) const ( Schema = "run-delegation" - SchemaRevision = 2 + SchemaRevision = 3 ) var identity = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) var fingerprint = regexp.MustCompile(`^[0-9a-f]{64}$`) type Request struct { - RunID string `json:"run_id"` - ProgramID string `json:"program_id"` - ProgramFingerprint string `json:"program_fingerprint"` - ControlBundleFingerprint string `json:"control_bundle_fingerprint"` - EntryID string `json:"entry_id"` - TargetID string `json:"target_id"` - ObjectiveID string `json:"objective_id"` - DeliveryID string `json:"delivery_id"` - InputFingerprints []string `json:"input_fingerprints"` - RepositoryID string `json:"repository_id"` - GitCommonID string `json:"git_common_id"` - InitialWorktreeID string `json:"initial_worktree_id"` - InitialRef string `json:"initial_ref"` - BindingFingerprint string `json:"binding_fingerprint"` - RequestedAuthorities []string `json:"requested_authorities"` - Description string `json:"description"` + RunID string `json:"run_id"` + ProgramID string `json:"program_id"` + ProgramFingerprint string `json:"program_fingerprint"` + ControlBundleFingerprint string `json:"control_bundle_fingerprint"` + EntryID string `json:"entry_id"` + TargetID string `json:"target_id"` + ObjectiveID string `json:"objective_id"` + DeliveryID string `json:"delivery_id"` + InputFingerprints []string `json:"input_fingerprints"` + RepositoryID string `json:"repository_id"` + GitCommonID string `json:"git_common_id"` + InitialWorktreeID string `json:"initial_worktree_id"` + InitialRef string `json:"initial_ref"` + BindingFingerprint string `json:"binding_fingerprint"` + HumanIdentityProviderFingerprint string `json:"human_identity_provider_fingerprint"` + RequestedAuthorities []string `json:"requested_authorities"` + Description string `json:"description"` } func (r Request) Fingerprint() (string, error) { - if !identity.MatchString(r.RunID) || !identity.MatchString(r.ProgramID) || len(r.ProgramFingerprint) != 64 || len(r.ControlBundleFingerprint) != 64 || !identity.MatchString(r.EntryID) || !identity.MatchString(r.TargetID) || r.ObjectiveID == "" || r.DeliveryID == "" || r.RepositoryID == "" || r.GitCommonID == "" || r.InitialWorktreeID == "" || r.InitialRef == "" || len(r.BindingFingerprint) != 64 || len(r.RequestedAuthorities) == 0 || r.Description == "" { + if !identity.MatchString(r.RunID) || !identity.MatchString(r.ProgramID) || len(r.ProgramFingerprint) != 64 || len(r.ControlBundleFingerprint) != 64 || !identity.MatchString(r.EntryID) || !identity.MatchString(r.TargetID) || r.ObjectiveID == "" || r.DeliveryID == "" || r.RepositoryID == "" || r.GitCommonID == "" || r.InitialWorktreeID == "" || r.InitialRef == "" || len(r.BindingFingerprint) != 64 || !fingerprint.MatchString(r.HumanIdentityProviderFingerprint) || len(r.RequestedAuthorities) == 0 || r.Description == "" { return "", fmt.Errorf("DELEGATION_REQUEST_INVALID: request is incomplete") } r.InputFingerprints = append([]string(nil), r.InputFingerprints...) @@ -64,19 +67,20 @@ func (r Request) Fingerprint() (string, error) { } type Record struct { - Schema string `json:"schema"` - SchemaRevision int `json:"schema_revision"` - Request Request `json:"request"` - RequestFingerprint string `json:"request_fingerprint"` - ReceiptID string `json:"receipt_id"` - Actor string `json:"actor"` - AuthorizedAt time.Time `json:"authorized_at"` - ExpiresAt time.Time `json:"expires_at,omitempty"` - Revision uint64 `json:"revision"` - Status string `json:"status"` - RevokedAt time.Time `json:"revoked_at,omitempty"` - EndedAt time.Time `json:"ended_at,omitempty"` - EndReason string `json:"end_reason,omitempty"` + Schema string `json:"schema"` + SchemaRevision int `json:"schema_revision"` + Request Request `json:"request"` + RequestFingerprint string `json:"request_fingerprint"` + ReceiptID string `json:"receipt_id"` + Actor string `json:"actor"` + ActorIdentityProviderFingerprint string `json:"actor_identity_provider_fingerprint"` + AuthorizedAt time.Time `json:"authorized_at"` + ExpiresAt time.Time `json:"expires_at,omitempty"` + Revision uint64 `json:"revision"` + Status string `json:"status"` + RevokedAt time.Time `json:"revoked_at,omitempty"` + EndedAt time.Time `json:"ended_at,omitempty"` + EndReason string `json:"end_reason,omitempty"` } func Path(flowRoot, runID string) (string, error) { @@ -117,9 +121,12 @@ func Load(path string) (Record, error) { if err := decoder.Decode(&trailing); err != io.EOF { return Record{}, fmt.Errorf("DELEGATION_RECORD_INVALID: trailing JSON") } - if record.Schema != Schema || record.SchemaRevision != SchemaRevision || record.Revision == 0 || record.Status == "" || record.Actor == "" || record.ReceiptID == "" { + if record.Schema != Schema || record.SchemaRevision != SchemaRevision || record.Revision == 0 || record.Status == "" || record.Actor == "" || record.ReceiptID == "" || !fingerprint.MatchString(record.ActorIdentityProviderFingerprint) { return Record{}, fmt.Errorf("DELEGATION_RECORD_INVALID: record is incomplete") } + if err := humanidentity.ValidateActor(record.Actor); err != nil || record.ActorIdentityProviderFingerprint != record.Request.HumanIdentityProviderFingerprint { + return Record{}, fmt.Errorf("DELEGATION_RECORD_INVALID: actor identity provenance is invalid") + } fingerprint, err := record.Request.Fingerprint() if err != nil || fingerprint != record.RequestFingerprint { return Record{}, fmt.Errorf("DELEGATION_RECORD_INVALID: request fingerprint mismatch") diff --git a/boatstack/internal/softwaredelivery/delegation/record_test.go b/boatstack/internal/softwaredelivery/delegation/record_test.go index e8de6eb..210b391 100644 --- a/boatstack/internal/softwaredelivery/delegation/record_test.go +++ b/boatstack/internal/softwaredelivery/delegation/record_test.go @@ -1,9 +1,12 @@ package delegation_test import ( + "encoding/json" + "os" "path/filepath" "strings" "testing" + "time" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/delegation" ) @@ -13,7 +16,7 @@ func request() delegation.Request { RunID: "run-example", ProgramID: "program", ProgramFingerprint: strings.Repeat("a", 64), ControlBundleFingerprint: strings.Repeat("c", 64), EntryID: "run", TargetID: "done", ObjectiveID: "objective", DeliveryID: "delivery", InputFingerprints: []string{"b", "a"}, RepositoryID: "repository", GitCommonID: "common", InitialWorktreeID: "worktree", InitialRef: "refs/heads/main", - BindingFingerprint: strings.Repeat("b", 64), RequestedAuthorities: []string{"human", "autonomy"}, Description: "Run the program", + BindingFingerprint: strings.Repeat("b", 64), HumanIdentityProviderFingerprint: strings.Repeat("d", 64), RequestedAuthorities: []string{"human", "autonomy"}, Description: "Run the program", } } @@ -56,3 +59,42 @@ func TestRequestFingerprintCanonicalizesSetsAndBindsSemantics(t *testing.T) { t.Fatal("semantic request change preserved fingerprint") } } + +func TestRecordRejectsPriorSchemaAndIdentityProvenanceMismatch(t *testing.T) { + value := request() + requestFingerprint, err := value.Fingerprint() + if err != nil { + t.Fatal(err) + } + record := delegation.Record{ + Schema: delegation.Schema, SchemaRevision: delegation.SchemaRevision, + Request: value, RequestFingerprint: requestFingerprint, ReceiptID: "authorization-example", + Actor: "operator", ActorIdentityProviderFingerprint: value.HumanIdentityProviderFingerprint, + AuthorizedAt: time.Unix(1_700_000_000, 0).UTC(), Revision: 1, Status: "active", + } + write := func(value delegation.Record) string { + t.Helper() + path := filepath.Join(t.TempDir(), "record.json") + raw, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatal(err) + } + return path + } + if _, err := delegation.Load(write(record)); err != nil { + t.Fatal(err) + } + prior := record + prior.SchemaRevision-- + if _, err := delegation.Load(write(prior)); err == nil { + t.Fatal("prior delegation schema was accepted") + } + drifted := record + drifted.ActorIdentityProviderFingerprint = strings.Repeat("e", 64) + if _, err := delegation.Load(write(drifted)); err == nil { + t.Fatal("identity provenance mismatch was accepted") + } +} diff --git a/boatstack/internal/softwaredelivery/effects/cas_integration_test.go b/boatstack/internal/softwaredelivery/effects/cas_integration_test.go index 63779ac..0577271 100644 --- a/boatstack/internal/softwaredelivery/effects/cas_integration_test.go +++ b/boatstack/internal/softwaredelivery/effects/cas_integration_test.go @@ -50,7 +50,7 @@ func TestConcurrentApplyConsumesOneRevisionExactlyOnce(t *testing.T) { runtimeRaw, _ := os.ReadFile(executable) runtimeVersion := installTestRuntime(t, executable, runtimeRaw) configPath := filepath.Join(t.TempDir(), "project.json") - configRaw := []byte("{\"schema_version\":2,\"project\":{\"name\":\"cas\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") + configRaw := []byte("{\"schema_version\":3,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"operator\"}},\"project\":{\"name\":\"cas\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") if err := os.WriteFile(configPath, configRaw, 0o600); err != nil { t.Fatal(err) } @@ -184,7 +184,7 @@ func TestProgramChangeInvalidatesPriorPrescriptionBeforeEffects(t *testing.T) { runtimeRaw, _ := os.ReadFile(executable) runtimeVersion := installTestRuntime(t, executable, runtimeRaw) configPath := filepath.Join(t.TempDir(), "project.json") - configRaw := []byte("{\"schema_version\":2,\"project\":{\"name\":\"program-cas\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") + configRaw := []byte("{\"schema_version\":3,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"operator\"}},\"project\":{\"name\":\"program-cas\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") if err := os.WriteFile(configPath, configRaw, 0o600); err != nil { t.Fatal(err) } diff --git a/boatstack/internal/softwaredelivery/effects/command_boundary_test.go b/boatstack/internal/softwaredelivery/effects/command_boundary_test.go index 6fd077d..7c22871 100644 --- a/boatstack/internal/softwaredelivery/effects/command_boundary_test.go +++ b/boatstack/internal/softwaredelivery/effects/command_boundary_test.go @@ -51,7 +51,7 @@ func writeBoundaryConfig(t *testing.T, command string) ports.ControllerLayout { if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { t.Fatal(err) } - raw := []byte(`{"schema_version":2,"project":{"name":"boundary","default_branch":"main","commands":{"build":"` + command + `"}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"]}`) + raw := []byte(`{"schema_version":3,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"boundary","default_branch":"main","commands":{"build":"` + command + `"}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"]}`) if err := os.WriteFile(path, raw, 0o600); err != nil { t.Fatal(err) } diff --git a/boatstack/internal/softwaredelivery/effects/delegation_record_test.go b/boatstack/internal/softwaredelivery/effects/delegation_record_test.go index e9abb6f..76af698 100644 --- a/boatstack/internal/softwaredelivery/effects/delegation_record_test.go +++ b/boatstack/internal/softwaredelivery/effects/delegation_record_test.go @@ -15,7 +15,7 @@ func TestDelegationSupersessionArchiveIsImmutableAndIdempotent(t *testing.T) { RunID: "run-example", ProgramID: "program", ProgramFingerprint: strings.Repeat("a", 64), ControlBundleFingerprint: strings.Repeat("b", 64), EntryID: "run", TargetID: "done", ObjectiveID: "objective", DeliveryID: "delivery", InputFingerprints: []string{"input"}, RepositoryID: "repository", GitCommonID: "common", InitialWorktreeID: "worktree", InitialRef: "refs/heads/main", - BindingFingerprint: strings.Repeat("c", 64), RequestedAuthorities: []string{"autonomy"}, Description: "Run the program", + BindingFingerprint: strings.Repeat("c", 64), HumanIdentityProviderFingerprint: strings.Repeat("d", 64), RequestedAuthorities: []string{"autonomy"}, Description: "Run the program", } fingerprint, err := request.Fingerprint() if err != nil { @@ -23,7 +23,7 @@ func TestDelegationSupersessionArchiveIsImmutableAndIdempotent(t *testing.T) { } record := delegation.Record{ Schema: delegation.Schema, SchemaRevision: delegation.SchemaRevision, Request: request, RequestFingerprint: fingerprint, - ReceiptID: "authorization-one", Actor: "operator", AuthorizedAt: time.Unix(1_700_000_000, 0).UTC(), Revision: 1, Status: "revoked", + ReceiptID: "authorization-one", Actor: "operator", ActorIdentityProviderFingerprint: request.HumanIdentityProviderFingerprint, AuthorizedAt: time.Unix(1_700_000_000, 0).UTC(), Revision: 1, Status: "revoked", } path := filepath.Join(t.TempDir(), "prior.json") if err := ArchiveDelegationRecord(path, record); err != nil { diff --git a/boatstack/internal/softwaredelivery/effects/integration_test.go b/boatstack/internal/softwaredelivery/effects/integration_test.go index 021373a..4e1dfdd 100644 --- a/boatstack/internal/softwaredelivery/effects/integration_test.go +++ b/boatstack/internal/softwaredelivery/effects/integration_test.go @@ -237,7 +237,7 @@ func TestStaleControlBundleStopsBeforeManagedStateOrRuntimePin(t *testing.T) { runtimeRaw, _ := os.ReadFile(executable) runtimeVersion := installTestRuntime(t, executable, runtimeRaw) configPath := filepath.Join(t.TempDir(), "project.json") - configRaw := []byte("{\"schema_version\":2,\"project\":{\"name\":\"bundle\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") + configRaw := []byte("{\"schema_version\":3,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"operator\"}},\"project\":{\"name\":\"bundle\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") if err := os.WriteFile(configPath, configRaw, 0o600); err != nil { t.Fatal(err) } @@ -302,7 +302,7 @@ func TestControllerRejectsExactBundleRevisionDriftWithMatchingWorkingBytes(t *te runtimeRaw, _ := os.ReadFile(executable) runtimeVersion := installTestRuntime(t, executable, runtimeRaw) configPath := filepath.Join(t.TempDir(), "project.json") - configRaw := []byte("{\"schema_version\":2,\"project\":{\"name\":\"revision\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") + configRaw := []byte("{\"schema_version\":3,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"operator\"}},\"project\":{\"name\":\"revision\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") if err := os.WriteFile(configPath, configRaw, 0o600); err != nil { t.Fatal(err) } @@ -585,7 +585,7 @@ func TestExternalConfigurationAuthorityTransfersAcrossAttachAndDetach(t *testing executable, _ = filepath.EvalSymlinks(executable) runtimeRaw, _ := os.ReadFile(executable) runtimeVersion := installTestRuntime(t, executable, runtimeRaw) - initialConfig := []byte("{\"schema_version\":2,\"project\":{\"name\":\"external-initial\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") + initialConfig := []byte("{\"schema_version\":3,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"operator\"}},\"project\":{\"name\":\"external-initial\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") initialPath := filepath.Join(t.TempDir(), "initial.json") if err := os.WriteFile(initialPath, initialConfig, 0o600); err != nil { t.Fatal(err) @@ -616,7 +616,7 @@ func TestExternalConfigurationAuthorityTransfersAcrossAttachAndDetach(t *testing t.Fatalf("detached layout did not select external config: %#v", detachedLayout) } apply("engagement.begin", protocol.AuthorityBundle{}, true, nil) - updatedConfig := []byte("{\"schema_version\":2,\"project\":{\"name\":\"external-updated\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") + updatedConfig := []byte("{\"schema_version\":3,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"operator\"}},\"project\":{\"name\":\"external-updated\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") updatedPath := filepath.Join(t.TempDir(), "updated.json") if err := os.WriteFile(updatedPath, updatedConfig, 0o600); err != nil { t.Fatal(err) @@ -671,7 +671,7 @@ func TestProgramDriftRequiresAtomicInstallationReconciliation(t *testing.T) { runtimeRaw, _ := os.ReadFile(executable) runtimeVersion := installTestRuntime(t, executable, runtimeRaw) configPath := filepath.Join(t.TempDir(), "project.json") - configRaw := []byte("{\"schema_version\":2,\"project\":{\"name\":\"drift\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") + configRaw := []byte("{\"schema_version\":3,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"operator\"}},\"project\":{\"name\":\"drift\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") if err := os.WriteFile(configPath, configRaw, 0o600); err != nil { t.Fatal(err) } @@ -926,7 +926,7 @@ func TestReferenceExtensionUsesKernelAdmissionVerificationAndReceiptPath(t *test runtimeRaw, _ := os.ReadFile(executable) runtimeVersion := installTestRuntime(t, executable, runtimeRaw) configPath := filepath.Join(t.TempDir(), "project.json") - configRaw := []byte("{\"schema_version\":2,\"project\":{\"name\":\"extension\",\"default_branch\":\"main\",\"commands\":{\"build\":\"go version\",\"test\":\"go version\"}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") + configRaw := []byte("{\"schema_version\":3,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"operator\"}},\"project\":{\"name\":\"extension\",\"default_branch\":\"main\",\"commands\":{\"build\":\"go version\",\"test\":\"go version\"}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") if err := os.WriteFile(configPath, configRaw, 0o600); err != nil { t.Fatal(err) } @@ -1058,7 +1058,7 @@ func TestConcreteWorkflowPreservesConfigurationProofAndObjectiveTerminals(t *tes runtimeRaw, _ := os.ReadFile(executable) runtimeVersion := installTestRuntime(t, executable, runtimeRaw) configPath := filepath.Join(t.TempDir(), "project-v2.json") - configRaw := []byte("{\"schema_version\":2,\"project\":{\"name\":\"integration\",\"default_branch\":\"main\",\"commands\":{\"build\":\"go version\",\"test\":\"go version\"}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") + configRaw := []byte("{\"schema_version\":3,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"operator\"}},\"project\":{\"name\":\"integration\",\"default_branch\":\"main\",\"commands\":{\"build\":\"go version\",\"test\":\"go version\"}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") if err := os.WriteFile(configPath, configRaw, 0o600); err != nil { t.Fatal(err) } @@ -1071,7 +1071,7 @@ func TestConcreteWorkflowPreservesConfigurationProofAndObjectiveTerminals(t *tes apply(approvedObjective, "engagement.begin", authority(catalog.AuthorityRepository), nil) updatedConfigPath := filepath.Join(t.TempDir(), "project-v2-updated.json") - updatedConfig := []byte("{\"schema_version\":2,\"project\":{\"name\":\"integration-updated\",\"default_branch\":\"main\",\"commands\":{\"build\":\"go version\",\"test\":\"go version\"}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") + updatedConfig := []byte("{\"schema_version\":3,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"operator\"}},\"project\":{\"name\":\"integration-updated\",\"default_branch\":\"main\",\"commands\":{\"build\":\"go version\",\"test\":\"go version\"}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") if err := os.WriteFile(updatedConfigPath, updatedConfig, 0o600); err != nil { t.Fatal(err) } @@ -1192,7 +1192,7 @@ func TestWorkspaceCutTransfersAuthorityToExactDestinationWorktree(t *testing.T) runtimeRaw, _ := os.ReadFile(executable) runtimeVersion := installTestRuntime(t, executable, runtimeRaw) configSource := filepath.Join(t.TempDir(), "project-v2.json") - configRaw := []byte("{\"schema_version\":2,\"project\":{\"name\":\"workspace\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") + configRaw := []byte("{\"schema_version\":3,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"operator\"}},\"project\":{\"name\":\"workspace\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") if err := os.WriteFile(configSource, configRaw, 0o600); err != nil { t.Fatal(err) } diff --git a/boatstack/internal/softwaredelivery/effects/recovery_test.go b/boatstack/internal/softwaredelivery/effects/recovery_test.go index 164006c..a66b8f1 100644 --- a/boatstack/internal/softwaredelivery/effects/recovery_test.go +++ b/boatstack/internal/softwaredelivery/effects/recovery_test.go @@ -108,7 +108,7 @@ func TestRestartRecoveryRestoresPriorStateAndCommitsRecoveryRevision(t *testing. t.Fatal(err) } configPath := filepath.Join(t.TempDir(), "project.json") - configRaw := []byte("{\"schema_version\":2,\"project\":{\"name\":\"recovery\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") + configRaw := []byte("{\"schema_version\":3,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"operator\"}},\"project\":{\"name\":\"recovery\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") if err := os.WriteFile(configPath, configRaw, 0o600); err != nil { t.Fatal(err) } diff --git a/boatstack/internal/softwaredelivery/humanidentity/identity.go b/boatstack/internal/softwaredelivery/humanidentity/identity.go new file mode 100644 index 0000000..9e761b9 --- /dev/null +++ b/boatstack/internal/softwaredelivery/humanidentity/identity.go @@ -0,0 +1,221 @@ +// Package humanidentity owns the repository-selected human actor descriptor. +// It validates and fingerprints descriptor data but never executes commands. +package humanidentity + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "regexp" + "strings" +) + +const ( + MaxActorBytes = 1024 + MaxCommandBytes = 256 + MaxArgumentCount = 32 + MaxArgumentBytes = 1024 + MaxDescriptorArgvBytes = 8 << 10 + MaxCommandOutputBytes = 1024 +) + +const ( + KindLiteral = "literal" + KindCommand = "command" +) + +var actorPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) + +// Descriptor is the closed, domain-neutral human identity provider contract. +// Args must be present for command descriptors, including when it is empty. +type Descriptor struct { + Kind string `json:"kind"` + Value string `json:"value,omitempty"` + Command string `json:"command,omitempty"` + Args []string `json:"args,omitempty"` +} + +func (d *Descriptor) UnmarshalJSON(raw []byte) error { + var fields map[string]json.RawMessage + decoder := json.NewDecoder(bytes.NewReader(raw)) + if err := decoder.Decode(&fields); err != nil { + return fmt.Errorf("HUMAN_IDENTITY_DESCRIPTOR_INVALID: %w", err) + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + return fmt.Errorf("HUMAN_IDENTITY_DESCRIPTOR_INVALID: trailing JSON") + } + var kind string + if value, ok := fields["kind"]; !ok || json.Unmarshal(value, &kind) != nil { + return fmt.Errorf("HUMAN_IDENTITY_DESCRIPTOR_INVALID: kind is required") + } + decodeExact := func(names ...string) error { + if len(fields) != len(names) { + return fmt.Errorf("HUMAN_IDENTITY_DESCRIPTOR_INVALID: %s descriptor has unknown, missing, or inapplicable fields", kind) + } + for _, name := range names { + if _, ok := fields[name]; !ok { + return fmt.Errorf("HUMAN_IDENTITY_DESCRIPTOR_INVALID: %s descriptor is missing %s", kind, name) + } + } + return nil + } + var candidate Descriptor + switch kind { + case KindLiteral: + if err := decodeExact("kind", "value"); err != nil { + return err + } + candidate.Kind = kind + if err := json.Unmarshal(fields["value"], &candidate.Value); err != nil { + return fmt.Errorf("HUMAN_IDENTITY_DESCRIPTOR_INVALID: literal value must be a string") + } + case KindCommand: + if err := decodeExact("kind", "command", "args"); err != nil { + return err + } + candidate.Kind = kind + if err := json.Unmarshal(fields["command"], &candidate.Command); err != nil { + return fmt.Errorf("HUMAN_IDENTITY_DESCRIPTOR_INVALID: command must be a string") + } + if string(fields["args"]) == "null" || json.Unmarshal(fields["args"], &candidate.Args) != nil || candidate.Args == nil { + return fmt.Errorf("HUMAN_IDENTITY_DESCRIPTOR_INVALID: args must be an explicit string array") + } + default: + return fmt.Errorf("HUMAN_IDENTITY_DESCRIPTOR_INVALID: unsupported kind %q", kind) + } + if err := candidate.Validate(); err != nil { + return err + } + *d = candidate + return nil +} + +func (d Descriptor) MarshalJSON() ([]byte, error) { + canonical, err := d.Canonical() + if err != nil { + return nil, err + } + return json.Marshal(canonical) +} + +// Presentation is safe host-facing provenance. It proposes an actor-selection +// mechanism but grants no authority and proves no provider capability. +type Presentation struct { + ProviderFingerprint string `json:"provider_fingerprint"` + Descriptor Descriptor `json:"descriptor"` +} + +func ValidateActor(actor string) error { + if len(actor) == 0 || len(actor) > MaxActorBytes || !actorPattern.MatchString(actor) { + return fmt.Errorf("HUMAN_ACTOR_INVALID: actor must be 1-%d bytes and match %s", MaxActorBytes, actorPattern) + } + return nil +} + +func (d Descriptor) Validate() error { + switch d.Kind { + case KindLiteral: + if d.Command != "" || d.Args != nil { + return fmt.Errorf("HUMAN_IDENTITY_DESCRIPTOR_INVALID: literal descriptor contains command fields") + } + if err := ValidateActor(d.Value); err != nil { + return fmt.Errorf("HUMAN_IDENTITY_DESCRIPTOR_INVALID: %w", err) + } + case KindCommand: + if d.Value != "" || d.Command == "" || len(d.Command) > MaxCommandBytes || strings.TrimSpace(d.Command) == "" || strings.ContainsRune(d.Command, 0) || d.Args == nil { + return fmt.Errorf("HUMAN_IDENTITY_DESCRIPTOR_INVALID: command descriptor requires a bounded command and explicit args") + } + if len(d.Args) > MaxArgumentCount { + return fmt.Errorf("HUMAN_IDENTITY_DESCRIPTOR_INVALID: command descriptor exceeds %d arguments", MaxArgumentCount) + } + total := len(d.Command) + for _, argument := range d.Args { + if len(argument) > MaxArgumentBytes || strings.ContainsRune(argument, 0) { + return fmt.Errorf("HUMAN_IDENTITY_DESCRIPTOR_INVALID: command argument is invalid") + } + total += len(argument) + } + if total > MaxDescriptorArgvBytes { + return fmt.Errorf("HUMAN_IDENTITY_DESCRIPTOR_INVALID: command and args exceed %d bytes", MaxDescriptorArgvBytes) + } + default: + return fmt.Errorf("HUMAN_IDENTITY_DESCRIPTOR_INVALID: unsupported kind %q", d.Kind) + } + return nil +} + +// Canonical returns the representation used for provider fingerprinting. +func (d Descriptor) Canonical() (any, error) { + if err := d.Validate(); err != nil { + return nil, err + } + if d.Kind == KindLiteral { + return struct { + Kind string `json:"kind"` + Value string `json:"value"` + }{Kind: KindLiteral, Value: d.Value}, nil + } + return struct { + Kind string `json:"kind"` + Command string `json:"command"` + Args []string `json:"args"` + }{Kind: KindCommand, Command: d.Command, Args: append([]string{}, d.Args...)}, nil +} + +func (d Descriptor) Fingerprint() (string, error) { + canonical, err := d.Canonical() + if err != nil { + return "", err + } + raw, err := json.Marshal(canonical) + if err != nil { + return "", err + } + digest := sha256.Sum256(raw) + return hex.EncodeToString(digest[:]), nil +} + +func NewPresentation(descriptor Descriptor) (Presentation, error) { + fingerprint, err := descriptor.Fingerprint() + if err != nil { + return Presentation{}, err + } + return Presentation{ProviderFingerprint: fingerprint, Descriptor: descriptor}, nil +} + +func (p Presentation) Validate() error { + fingerprint, err := p.Descriptor.Fingerprint() + if err != nil || p.ProviderFingerprint != fingerprint { + return fmt.Errorf("HUMAN_IDENTITY_PRESENTATION_INVALID: provider fingerprint does not match descriptor") + } + return nil +} + +// InterpretCommandOutput applies the host contract to already captured output. +// It does not start or resolve an executable. +func InterpretCommandOutput(exitStatus int, stdout []byte) (string, error) { + if exitStatus != 0 { + return "", fmt.Errorf("HUMAN_IDENTITY_RESOLUTION_FAILED: command exited with status %d", exitStatus) + } + if len(stdout) == 0 || len(stdout) > MaxCommandOutputBytes { + return "", fmt.Errorf("HUMAN_IDENTITY_RESOLUTION_FAILED: stdout must be 1-%d bytes", MaxCommandOutputBytes) + } + value := append([]byte(nil), stdout...) + if bytes.HasSuffix(value, []byte("\r\n")) { + value = value[:len(value)-2] + } else if bytes.HasSuffix(value, []byte("\n")) { + value = value[:len(value)-1] + } + if len(value) == 0 || bytes.ContainsAny(value, "\r\n\x00") { + return "", fmt.Errorf("HUMAN_IDENTITY_RESOLUTION_FAILED: stdout must contain exactly one non-empty logical line") + } + actor := string(value) + if err := ValidateActor(actor); err != nil { + return "", fmt.Errorf("HUMAN_IDENTITY_RESOLUTION_FAILED: %w", err) + } + return actor, nil +} diff --git a/boatstack/internal/softwaredelivery/humanidentity/identity_test.go b/boatstack/internal/softwaredelivery/humanidentity/identity_test.go new file mode 100644 index 0000000..65b75f7 --- /dev/null +++ b/boatstack/internal/softwaredelivery/humanidentity/identity_test.go @@ -0,0 +1,99 @@ +package humanidentity_test + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/humanidentity" +) + +func TestLiteralAndCommandDescriptorsHaveDeterministicDistinctFingerprints(t *testing.T) { + literal := humanidentity.Descriptor{Kind: humanidentity.KindLiteral, Value: "example-operator"} + command := humanidentity.Descriptor{Kind: humanidentity.KindCommand, Command: "gh", Args: []string{"api", "user", "--jq", ".login"}} + for _, descriptor := range []humanidentity.Descriptor{literal, command} { + first, err := descriptor.Fingerprint() + if err != nil { + t.Fatal(err) + } + second, err := descriptor.Fingerprint() + if err != nil || first != second || len(first) != 64 { + t.Fatalf("nondeterministic fingerprint first=%q second=%q err=%v", first, second, err) + } + presentation, err := humanidentity.NewPresentation(descriptor) + if err != nil || presentation.ProviderFingerprint != first || presentation.Validate() != nil { + t.Fatalf("presentation = %#v, err=%v", presentation, err) + } + } + literalFingerprint, _ := literal.Fingerprint() + changedFingerprint, _ := (humanidentity.Descriptor{Kind: humanidentity.KindLiteral, Value: "another-actor"}).Fingerprint() + commandFingerprint, _ := command.Fingerprint() + if literalFingerprint == changedFingerprint || literalFingerprint == commandFingerprint { + t.Fatal("descriptor change preserved provider fingerprint") + } +} + +func TestCommandDescriptorJSONPreservesExplicitEmptyArgv(t *testing.T) { + raw, err := json.Marshal(humanidentity.Descriptor{Kind: humanidentity.KindCommand, Command: "company-identity", Args: []string{}}) + if err != nil { + t.Fatal(err) + } + if string(raw) != `{"kind":"command","command":"company-identity","args":[]}` { + t.Fatalf("command JSON = %s", raw) + } + var decoded humanidentity.Descriptor + if err := json.Unmarshal(raw, &decoded); err != nil || decoded.Args == nil { + t.Fatalf("decoded command = %#v, err=%v", decoded, err) + } +} + +func TestDescriptorValidationRejectsMalformedUnionAndBounds(t *testing.T) { + tooManyArgs := make([]string, humanidentity.MaxArgumentCount+1) + for index := range tooManyArgs { + tooManyArgs[index] = "x" + } + tests := []humanidentity.Descriptor{ + {}, + {Kind: "github", Value: "example-operator"}, + {Kind: humanidentity.KindLiteral}, + {Kind: humanidentity.KindLiteral, Value: "bad actor"}, + {Kind: humanidentity.KindLiteral, Value: "actor", Command: "gh"}, + {Kind: humanidentity.KindCommand, Args: []string{}}, + {Kind: humanidentity.KindCommand, Command: "gh"}, + {Kind: humanidentity.KindCommand, Command: "gh", Args: []string{"bad\x00argument"}}, + {Kind: humanidentity.KindCommand, Command: "gh", Args: tooManyArgs}, + {Kind: humanidentity.KindCommand, Command: strings.Repeat("x", humanidentity.MaxCommandBytes+1), Args: []string{}}, + {Kind: humanidentity.KindCommand, Command: "gh", Args: []string{strings.Repeat("x", humanidentity.MaxArgumentBytes+1)}}, + } + for _, descriptor := range tests { + if err := descriptor.Validate(); err == nil { + t.Fatalf("malformed descriptor was accepted: %#v", descriptor) + } + } +} + +func TestInterpretCommandOutputIsPureAndStrict(t *testing.T) { + for _, output := range [][]byte{[]byte("example-operator"), []byte("example-operator\n"), []byte("example-operator\r\n")} { + actor, err := humanidentity.InterpretCommandOutput(0, output) + if err != nil || actor != "example-operator" { + t.Fatalf("output %q => actor=%q err=%v", output, actor, err) + } + } + for _, test := range []struct { + status int + value []byte + }{ + {1, []byte("example-operator\n")}, + {0, nil}, + {0, []byte("\n")}, + {0, []byte("example-operator\nother")}, + {0, []byte("example-operator\n\n")}, + {0, []byte("example-operator\x00")}, + {0, []byte("bad actor\n")}, + {0, []byte(strings.Repeat("x", humanidentity.MaxCommandOutputBytes+1))}, + } { + if _, err := humanidentity.InterpretCommandOutput(test.status, test.value); err == nil { + t.Fatalf("invalid command output status=%d value=%q was accepted", test.status, test.value) + } + } +} diff --git a/boatstack/internal/softwaredelivery/protocol/config.go b/boatstack/internal/softwaredelivery/protocol/config.go index 15a37aa..3ec826d 100644 --- a/boatstack/internal/softwaredelivery/protocol/config.go +++ b/boatstack/internal/softwaredelivery/protocol/config.go @@ -11,10 +11,11 @@ import ( "regexp" "sort" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/humanidentity" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" ) -const ConfigSchemaVersion = 2 +const ConfigSchemaVersion = 3 type ProjectSettings struct { Name string `json:"name"` @@ -45,8 +46,13 @@ type SubprocessExtensionSettings struct { StderrBytes int64 `json:"stderr_bytes,omitempty"` } +type IdentitySettings struct { + Human humanidentity.Descriptor `json:"human"` +} + type ProjectConfig struct { SchemaVersion int `json:"schema_version"` + Identity IdentitySettings `json:"identity"` Project ProjectSettings `json:"project"` Policy PolicySettings `json:"policy"` Hosts []string `json:"hosts"` @@ -86,7 +92,7 @@ func DecodeProjectConfig(value []byte) (ProjectConfig, error) { return config, nil } -// ProjectConfigFingerprint binds configuration authority to strict schema-2 +// ProjectConfigFingerprint binds configuration authority to strict schema-3 // semantics rather than checkout-specific JSON bytes. Formatting, object-key // order, line endings, the defaulted external-effect policy, and host ordering // therefore cannot make an otherwise identical configuration stale. @@ -133,7 +139,10 @@ func ProjectConfigFingerprint(value []byte) (ProjectConfig, string, error) { func (c ProjectConfig) Validate() error { if c.SchemaVersion != ConfigSchemaVersion || c.Project.Name == "" || c.Project.DefaultBranch == "" || c.Project.Commands == nil { - return fmt.Errorf("Boatstack project configuration requires schema 2, project name, default branch, and commands") + return fmt.Errorf("Boatstack project configuration requires schema 3, project name, default branch, commands, and human identity") + } + if err := c.Identity.Human.Validate(); err != nil { + return err } if err := ValidateGitBranch(c.Project.DefaultBranch); err != nil { return fmt.Errorf("invalid default branch: %w", err) diff --git a/boatstack/internal/softwaredelivery/protocol/config_test.go b/boatstack/internal/softwaredelivery/protocol/config_test.go index e555307..bda719f 100644 --- a/boatstack/internal/softwaredelivery/protocol/config_test.go +++ b/boatstack/internal/softwaredelivery/protocol/config_test.go @@ -5,18 +5,27 @@ import ( "path/filepath" "strings" "testing" + + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/humanidentity" ) +const literalIdentityJSON = `"identity":{"human":{"kind":"literal","value":"operator"}},` + +func literalIdentity() IdentitySettings { + return IdentitySettings{Human: humanidentity.Descriptor{Kind: humanidentity.KindLiteral, Value: "operator"}} +} + func TestProjectConfigurationIsStrictAndVersioned(t *testing.T) { - valid := []byte(`{"schema_version":2,"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli","codex"]}`) + valid := []byte(`{"schema_version":3,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli","codex"]}`) if _, err := DecodeProjectConfig(valid); err != nil { t.Fatal(err) } invalid := [][]byte{ - []byte(`{"schema_version":1,"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"]}`), - []byte(`{"schema_version":2,"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["unknown"]}`), - []byte(`{"schema_version":2,"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"],"legacy":true}`), - []byte(`{"schema_version":2,"project":{"name":"product","default_branch":"--upload-pack=bad","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"]}`), + []byte(`{"schema_version":2,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"]}`), + []byte(`{"schema_version":3,"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"]}`), + []byte(`{"schema_version":3,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["unknown"]}`), + []byte(`{"schema_version":3,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"],"legacy":true}`), + []byte(`{"schema_version":3,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"product","default_branch":"--upload-pack=bad","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"]}`), } for _, value := range invalid { if _, err := DecodeProjectConfig(value); err == nil { @@ -30,6 +39,7 @@ func TestRepositorySubprocessExtensionsAreStrictAndSemanticallyFingerprinted(t * executable := filepath.Join(t.TempDir(), "extension") base := ProjectConfig{ SchemaVersion: ConfigSchemaVersion, + Identity: literalIdentity(), Project: ProjectSettings{Name: "product", DefaultBranch: "main", Commands: map[string]string{}}, Policy: PolicySettings{PlanApproval: "human", VisualEvidence: "optional"}, Hosts: []string{"cli", "sdk"}, @@ -82,8 +92,8 @@ func TestRepositorySubprocessExtensionsAreStrictAndSemanticallyFingerprinted(t * } func TestProjectConfigurationFingerprintIsSemanticAndStrict(t *testing.T) { - one := []byte("{\n \"schema_version\": 2,\n \"project\": {\"name\": \"product\", \"default_branch\": \"main\", \"commands\": {\"test\": \"go test ./...\"}},\n \"policy\": {\"plan_approval\": \"human\", \"visual_evidence\": \"optional\"},\n \"hosts\": [\"codex\", \"cli\"]\n}\n") - two := []byte("{\r\n\"hosts\":[\"cli\",\"codex\"],\r\n\"policy\":{\"external_effect_authority\":\"human-or-autonomy-plus-provider\",\"visual_evidence\":\"optional\",\"plan_approval\":\"human\"},\r\n\"project\":{\"commands\":{\"test\":\"go test ./...\"},\"default_branch\":\"main\",\"name\":\"product\"},\r\n\"schema_version\":2\r\n}\r\n") + one := []byte("{\n \"schema_version\": 3,\n \"identity\": {\"human\": {\"kind\": \"literal\", \"value\": \"operator\"}},\n \"project\": {\"name\": \"product\", \"default_branch\": \"main\", \"commands\": {\"test\": \"go test ./...\"}},\n \"policy\": {\"plan_approval\": \"human\", \"visual_evidence\": \"optional\"},\n \"hosts\": [\"codex\", \"cli\"]\n}\n") + two := []byte("{\r\n\"hosts\":[\"cli\",\"codex\"],\r\n\"identity\":{\"human\":{\"value\":\"operator\",\"kind\":\"literal\"}},\r\n\"policy\":{\"external_effect_authority\":\"human-or-autonomy-plus-provider\",\"visual_evidence\":\"optional\",\"plan_approval\":\"human\"},\r\n\"project\":{\"commands\":{\"test\":\"go test ./...\"},\"default_branch\":\"main\",\"name\":\"product\"},\r\n\"schema_version\":3\r\n}\r\n") _, oneFingerprint, err := ProjectConfigFingerprint(one) if err != nil { t.Fatal(err) @@ -96,7 +106,7 @@ func TestProjectConfigurationFingerprintIsSemanticAndStrict(t *testing.T) { t.Fatalf("representation changed semantic fingerprint: %s != %s", oneFingerprint, twoFingerprint) } - changed := []byte(`{"schema_version":2,"project":{"name":"product","default_branch":"main","commands":{"test":"go test ./..."}},"policy":{"plan_approval":"human","visual_evidence":"required"},"hosts":["cli","codex"]}`) + changed := []byte(`{"schema_version":3,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"product","default_branch":"main","commands":{"test":"go test ./..."}},"policy":{"plan_approval":"human","visual_evidence":"required"},"hosts":["cli","codex"]}`) _, changedFingerprint, err := ProjectConfigFingerprint(changed) if err != nil { t.Fatal(err) @@ -112,6 +122,35 @@ func TestProjectConfigurationFingerprintIsSemanticAndStrict(t *testing.T) { } } +func TestProjectConfigurationBindsHumanIdentityDescriptor(t *testing.T) { + literal := []byte(`{"schema_version":3,"identity":{"human":{"kind":"literal","value":"example-operator"}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"]}`) + command := []byte(`{"schema_version":3,"identity":{"human":{"kind":"command","command":"gh","args":["api","user","--jq",".login"]}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"]}`) + literalConfig, literalFingerprint, err := ProjectConfigFingerprint(literal) + if err != nil { + t.Fatal(err) + } + commandConfig, commandFingerprint, err := ProjectConfigFingerprint(command) + if err != nil { + t.Fatal(err) + } + if literalConfig.Identity.Human.Kind != humanidentity.KindLiteral || commandConfig.Identity.Human.Kind != humanidentity.KindCommand { + t.Fatalf("decoded identities literal=%#v command=%#v", literalConfig.Identity, commandConfig.Identity) + } + if literalFingerprint == commandFingerprint { + t.Fatal("identity descriptor change preserved project configuration fingerprint") + } + for _, invalid := range [][]byte{ + []byte(`{"schema_version":3,"identity":{"human":{"kind":"command","command":"gh"}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"]}`), + []byte(`{"schema_version":3,"identity":{"human":{"kind":"command","command":"gh","args":null}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"]}`), + []byte(`{"schema_version":3,"identity":{"human":{"kind":"literal","value":"actor","command":""}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"]}`), + []byte(`{"schema_version":3,"identity":{"human":{"kind":"literal","value":"actor","unknown":true}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"]}`), + } { + if _, err := DecodeProjectConfig(invalid); err == nil { + t.Fatalf("invalid identity config was accepted: %s", invalid) + } + } +} + func TestGitReferencesRejectOptionsAndRevisionExpressions(t *testing.T) { for _, value := range []string{"main", "feature/v2", "HEAD", "0123456789abcdef"} { if err := ValidateGitReference(value); err != nil { diff --git a/boatstack/internal/softwaredelivery/surfaces/protocol.go b/boatstack/internal/softwaredelivery/surfaces/protocol.go index b94921a..cf2d790 100644 --- a/boatstack/internal/softwaredelivery/surfaces/protocol.go +++ b/boatstack/internal/softwaredelivery/surfaces/protocol.go @@ -12,6 +12,7 @@ import ( boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/foregroundwork" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/humanidentity" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/supervisor" @@ -19,7 +20,7 @@ import ( general "github.com/operatorstack/boatstack/boatstack/kernel" ) -const SchemaVersion = 13 +const SchemaVersion = 14 var flowContextIdentity = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) var gitObjectIdentity = regexp.MustCompile(`^[0-9a-f]{40,64}$`) @@ -264,11 +265,12 @@ type Response struct { } type DelegationRequired struct { - Code string `json:"code"` - RunID string `json:"run_id"` - RequestFingerprint string `json:"request_fingerprint"` - Authorities []catalog.AuthorityClass `json:"authorities"` - Description string `json:"description"` + Code string `json:"code"` + RunID string `json:"run_id"` + RequestFingerprint string `json:"request_fingerprint"` + Authorities []catalog.AuthorityClass `json:"authorities"` + Description string `json:"description"` + HumanIdentity humanidentity.Presentation `json:"human_identity"` } // CommitRequired is a typed suspension at a repository revision boundary. @@ -286,13 +288,14 @@ type CommitRequired struct { // required evidence and resolving again with the same run identity resumes the // existing command context. type Question struct { - ID string `json:"id"` - RunID string `json:"run_id"` - TransitionID catalog.TransitionID `json:"transition_id"` - Prompt string `json:"prompt,omitempty"` - Parameters []catalog.ParameterSpec `json:"parameters,omitempty"` - Authority []catalog.AuthorityClass `json:"authority,omitempty"` - AuthorityAll []catalog.AuthorityClass `json:"authority_all,omitempty"` + ID string `json:"id"` + RunID string `json:"run_id"` + TransitionID catalog.TransitionID `json:"transition_id"` + Prompt string `json:"prompt,omitempty"` + Parameters []catalog.ParameterSpec `json:"parameters,omitempty"` + Authority []catalog.AuthorityClass `json:"authority,omitempty"` + AuthorityAll []catalog.AuthorityClass `json:"authority_all,omitempty"` + HumanIdentity *humanidentity.Presentation `json:"human_identity,omitempty"` } func QuestionFor(runID, snapshotFingerprint string, decision supervisor.Decision) *Question { diff --git a/boatstack/references/config-schema.md b/boatstack/references/config-schema.md index a11dca8..82fb5e1 100644 --- a/boatstack/references/config-schema.md +++ b/boatstack/references/config-schema.md @@ -1,11 +1,11 @@ # Configuration schema -Boatstack accepts only `.boatstack/project.json` schema version 2. The +Boatstack accepts only `.boatstack/project.json` schema version 3. The normative Go decoder is `internal/softwaredelivery/protocol.DecodeProjectConfig`; the public example is `project.example.json`. -Top-level keys are `schema_version`, `project`, `policy`, `hosts`, and optional +Top-level keys are `schema_version`, `identity`, `project`, `policy`, `hosts`, and optional `extensions`. Unknown keys and trailing JSON fail. Hosts are selected from `claude`, `cli`, `codex`, `cursor`, `gemini`, `mcp`, and `sdk`; `cli` is mandatory. @@ -17,7 +17,12 @@ stdout, and stderr limits. Repository configuration cannot replace the primary flow. A subprocess extension is a trusted executable boundary, not an OS sandbox. +`identity.human` is required. It is either a bounded `literal` actor or a +structured `command` plus exact `args`. Boatstack fingerprints and exposes the +descriptor but never executes it. Identity resolution proposes an actor; it +does not grant human or external-provider authority. + Configuration changes use `configuration.mutate` with `config_path` and -`config_sha256`. That fingerprint is the SHA-256 of the strict decoded schema-2 +`config_sha256`. That fingerprint is the SHA-256 of the strict decoded schema-3 value in canonical JSON form, not the source file's raw bytes; the CLI derives it when omitted. Never hand-edit controller state or reuse a V1 schema. diff --git a/boatstack/references/host-hook-contracts.md b/boatstack/references/host-hook-contracts.md index 15c99c3..843174f 100644 --- a/boatstack/references/host-hook-contracts.md +++ b/boatstack/references/host-hook-contracts.md @@ -1,13 +1,13 @@ # Host and hook contract -All hosts call `boatstack rpc` with one schema-2 JSON object and read one -schema-2 response. The decoder rejects unknown fields and trailing JSON. +All hosts call `boatstack rpc` with one schema-14 JSON object and read one +schema-14 response. The decoder rejects unknown fields and trailing JSON. Example read request: ```json { - "schema_version": 2, + "schema_version": 14, "operation": "resolve", "repository": "/absolute/worktree", "host": "codex", diff --git a/boatstack/sdk/sdk.go b/boatstack/sdk/sdk.go index 1c9764e..e9f052d 100644 --- a/boatstack/sdk/sdk.go +++ b/boatstack/sdk/sdk.go @@ -11,6 +11,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/delivery" "github.com/operatorstack/boatstack/boatstack/distribution" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/humanidentity" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/supervisor" @@ -37,6 +38,8 @@ type Request = surfaces.Request type Response = surfaces.Response type DoctorReport = surfaces.DoctorReport type ProgramChange = surfaces.ProgramChange +type HumanIdentityDescriptor = humanidentity.Descriptor +type HumanIdentityPresentation = humanidentity.Presentation type Objective = model.Objective type TargetID = model.TargetID type StateFacet = model.StateFacet diff --git a/docs/configuration.md b/docs/configuration.md index 87f0f7e..0105799 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,12 +1,19 @@ # Boatstack configuration `.boatstack/project.json` is the repository-owned policy input. Boatstack accepts only -schema version 2. Unknown top-level fields, unsupported policy values, duplicate +schema version 3. Unknown top-level fields, unsupported policy values, duplicate hosts, trailing JSON, and missing required fields fail closed. ```json { - "schema_version": 2, + "schema_version": 3, + "identity": { + "human": { + "kind": "command", + "command": "gh", + "args": ["api", "user", "--jq", ".login"] + } + }, "project": { "name": "example-product", "default_branch": "main", @@ -30,6 +37,7 @@ hosts, trailing JSON, and missing required fields fail closed. ## Required values - `project.name`, `project.default_branch`, and `project.commands`; +- `identity.human`, as either a literal or structured command descriptor; - `policy.plan_approval`: `human` or `human-or-autonomy`; - `policy.visual_evidence`: `off`, `optional`, or `required`; - at least the `cli` host. @@ -38,6 +46,35 @@ The only accepted external-effect authority policy is `human-or-autonomy-plus-provider`. Provider authority is an independent mandatory clause; it cannot be replaced by a human receipt. +## Human actor identity + +`identity.human` tells a host how to obtain the proposed actor label for a +human-authority request. It does not grant authority. A literal descriptor is: + +```json +{"kind": "literal", "value": "alice"} +``` + +A command descriptor contains an executable name and an exact argument array: + +```json +{"kind": "command", "command": "gh", "args": ["api", "user", "--jq", ".login"]} +``` + +Boatstack validates, fingerprints, and exposes this data but never executes the +command. A host may execute the exact command and arguments directly, without a +shell or interpolation. It accepts only a zero exit status and one non-empty +actor line of at most 1 KiB after removing at most one trailing LF or CRLF. If +resolution fails, the host must ask the user for an actor; it must not infer an +operating-system or Git identity. + +The host displays the resolved actor, exact request, and requested authority, +then asks for explicit approval. The authorization command still requires +`--human `. The descriptor fingerprint records how the actor was +proposed. It does not prove approval, identity ownership, provider permission, +or external-provider authority. In particular, resolving an actor through +`gh` does not create a GitHub provider receipt. + The canonical snapshot carries this policy projection as controlling evidence. `human` plan approval rejects autonomy receipts. `human-or-autonomy` accepts either class. When independent high-risk review is enabled, the observer derives @@ -99,7 +136,7 @@ document; an arbitrary fingerprint string is insufficient. To change configuration, write a candidate file elsewhere, then request `configuration.mutate`. The CLI derives `config_sha256` from the strict decoded -schema-2 value in canonical JSON form. Formatting, object-key order, and LF/CRLF +schema-3 value in canonical JSON form. Formatting, object-key order, and LF/CRLF checkout conversion therefore retain the same authority, while any controlling value change produces a new fingerprint. The kernel still copies the exact candidate bytes, installs state last, re-observes the tracked file, and accepts @@ -126,5 +163,5 @@ boatstack attach --repo . --human alice \ --param topology=detached --param config_authority=external ``` -V1 configuration schemas are intentionally unsupported. Reinstall or supply a +Earlier configuration schemas are intentionally unsupported. Reinstall or supply a new Boatstack document; no compatibility conversion runs. diff --git a/docs/control-program-ir.md b/docs/control-program-ir.md index b9091c1..95ca48f 100644 --- a/docs/control-program-ir.md +++ b/docs/control-program-ir.md @@ -32,12 +32,16 @@ boatstack next --repo . --flow product-delivery --entry run ``` If the entry requests delegation, `next` returns `DELEGATION_REQUIRED` with an -exact run and request fingerprint before managed state changes. A human can -authorize that exact request and continue it: +exact run, request fingerprint, and repository-selected human identity +descriptor before managed state changes. The host resolves a proposed actor, +shows that actor and the exact request, and asks for explicit approval. A human +can then authorize that exact request and continue it: ```sh boatstack flow authorize --repo . --flow product-delivery --entry run \ - --run-id --request-fingerprint --human + --run-id --request-fingerprint \ + --human-identity-provider-fingerprint \ + --human boatstack flow run --repo . --flow product-delivery --entry run --run-id boatstack flow revoke --repo . --run-id --human ``` diff --git a/docs/getting-started.md b/docs/getting-started.md index 479f517..3ccaf48 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -5,12 +5,18 @@ Run the checksum-verifying installer from the repository root: ```sh -/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/operatorstack/boatstack/main/install.sh)" +BOATSTACK_ACTOR=alice \ + /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/operatorstack/boatstack/main/install.sh)" boatstack doctor --repo . --format text ``` Windows users run `install.ps1` in PowerShell. The kernel creates `.boatstack/project.json`; review and commit that file before feature work. +`BOATSTACK_ACTOR` is explicit installation authority and becomes the default +literal `identity.human` in a generated configuration. The installer never +infers an actor from the operating system. Replace the literal descriptor with +a structured command descriptor when the repository should ask its host to +resolve the proposed actor. ## Configure one exact objective diff --git a/docs/product-delivery/authority-and-delegation.md b/docs/product-delivery/authority-and-delegation.md index c83d669..d9343f2 100644 --- a/docs/product-delivery/authority-and-delegation.md +++ b/docs/product-delivery/authority-and-delegation.md @@ -29,6 +29,14 @@ provider capability from the current repository identity and authenticated write permission. This is capability evidence, not another human approval. Repository files and `--authority-receipt` cannot create provider authority. +The repository also declares `identity.human` in `.boatstack/project.json`. +Boatstack exposes that literal or structured command descriptor and its +fingerprint to the host; it does not execute the command. The host resolves and +visibly presents a proposed actor, then asks for explicit approval of the exact +request. Resolution alone creates no human or autonomy authority. A command +that uses `gh` still creates no external-provider authority. Conversely, a +current provider receipt does not authorize a human delegation. + Publication is admitted only after the product worktree is clean and the preview binds its exact committed HEAD. A changed HEAD or worktree invalidates the preview before the external effect. diff --git a/install.ps1 b/install.ps1 index 19fcfe6..5f49a96 100644 --- a/install.ps1 +++ b/install.ps1 @@ -6,11 +6,14 @@ $ErrorActionPreference = "Stop" $Repository = if ($env:BOATSTACK_REPO) { $env:BOATSTACK_REPO } else { (Get-Location).Path } $Version = if ($env:BOATSTACK_VERSION) { $env:BOATSTACK_VERSION } else { "latest" } $Mode = if ($env:BOATSTACK_MODE) { $env:BOATSTACK_MODE } else { "install" } -$Actor = if ($env:BOATSTACK_ACTOR) { $env:BOATSTACK_ACTOR } elseif ($env:USERNAME) { $env:USERNAME } else { "operator" } +$Actor = if ($env:BOATSTACK_ACTOR) { $env:BOATSTACK_ACTOR } else { "" } $InstallDir = if ($env:BOATSTACK_INSTALL_DIR) { $env:BOATSTACK_INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA "Boatstack\bin" } $BoatstackHome = if ($env:BOATSTACK_HOME) { $env:BOATSTACK_HOME } else { Join-Path $env:LOCALAPPDATA "Boatstack" } if ($Mode -notin @("install", "update", "hydrate")) { throw "Boatstack supports BOATSTACK_MODE=install, update, or hydrate" } +if ($Mode -ne "hydrate" -and -not $Actor) { + throw "BOATSTACK_HUMAN_ACTOR_REQUIRED: install and update require an explicit BOATSTACK_ACTOR" +} if ($Mode -eq "hydrate") { if ($Version -eq "latest") { throw "BOATSTACK_RUNTIME_PIN_INVALID: hydrate requires an exact BOATSTACK_VERSION" } if (-not $env:BOATSTACK_EXPECTED_RUNTIME_SHA256 -or $env:BOATSTACK_EXPECTED_RUNTIME_SHA256 -notmatch '^[0-9A-Fa-f]{64}$') { @@ -113,7 +116,8 @@ try { if (-not $ConfigSource) { $ConfigSource = Join-Path $Temporary "project.json" $Config = [ordered]@{ - schema_version = 2 + schema_version = 3 + identity = [ordered]@{ human = [ordered]@{ kind = "literal"; value = $Actor } } project = [ordered]@{ name = "repository"; default_branch = $DefaultBranch; commands = [ordered]@{} } policy = [ordered]@{ plan_approval = "human"; visual_evidence = "optional" } hosts = @("cli", "cursor", "codex", "claude", "gemini", "mcp") diff --git a/install.sh b/install.sh index 4408c25..88897ba 100755 --- a/install.sh +++ b/install.sh @@ -8,7 +8,7 @@ set -euo pipefail repository="${BOATSTACK_REPO:-$PWD}" version="${BOATSTACK_VERSION:-latest}" mode="${BOATSTACK_MODE:-install}" -actor="${BOATSTACK_ACTOR:-${USER:-operator}}" +actor="${BOATSTACK_ACTOR:-}" install_dir="${BOATSTACK_INSTALL_DIR:-${HOME}/.local/bin}" boatstack_home="${BOATSTACK_HOME:-${XDG_DATA_HOME:-${HOME}/.local/share}/boatstack}" config_source="${BOATSTACK_CONFIG:-}" @@ -18,6 +18,11 @@ case "$mode" in *) echo "Boatstack supports BOATSTACK_MODE=install, update, or hydrate" >&2; exit 2 ;; esac +if [[ "$mode" != hydrate && -z "$actor" ]]; then + echo "BOATSTACK_HUMAN_ACTOR_REQUIRED: install and update require an explicit BOATSTACK_ACTOR" >&2 + exit 2 +fi + if [[ "$mode" == hydrate ]]; then [[ "$version" != latest ]] || { echo "BOATSTACK_RUNTIME_PIN_INVALID: hydrate requires an exact BOATSTACK_VERSION" >&2 @@ -138,7 +143,7 @@ if [[ "$mode" == install ]]; then config_source="$temporary/project.json" json_default_branch="${default_branch//\\/\\\\}" json_default_branch="${json_default_branch//\"/\\\"}" - printf '%s\n' "{\"schema_version\":2,\"project\":{\"name\":\"repository\",\"default_branch\":\"$json_default_branch\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\",\"cursor\",\"codex\",\"claude\",\"gemini\",\"mcp\"]}" > "$config_source" + printf '%s\n' "{\"schema_version\":3,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"$actor\"}},\"project\":{\"name\":\"repository\",\"default_branch\":\"$json_default_branch\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\",\"cursor\",\"codex\",\"claude\",\"gemini\",\"mcp\"]}" > "$config_source" fi "$runtime" init --repo "$repository" --human "$actor" --param "config_path=$config_source" --format text elif [[ "$mode" == update ]]; then diff --git a/project.example.json b/project.example.json index 4e947f6..64cd9d1 100644 --- a/project.example.json +++ b/project.example.json @@ -1,5 +1,12 @@ { - "schema_version": 2, + "schema_version": 3, + "identity": { + "human": { + "kind": "command", + "command": "gh", + "args": ["api", "user", "--jq", ".login"] + } + }, "project": { "name": "example-product", "default_branch": "main", diff --git a/release-notes/2026-08-16-human-actor-identity.md b/release-notes/2026-08-16-human-actor-identity.md new file mode 100644 index 0000000..d24b308 --- /dev/null +++ b/release-notes/2026-08-16-human-actor-identity.md @@ -0,0 +1,6 @@ +### Make human actor identity explicit + +Repositories now declare a literal or structured command descriptor for the +human actor that hosts present at authority boundaries. Boatstack fingerprints +and exposes the descriptor without executing it, while explicit approval and +external-provider authority remain separate requirements. From d4af5ba5ba2531154305321664a9e358eef68909 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sun, 16 Aug 2026 20:41:33 +0100 Subject: [PATCH 02/11] fix: bind identity to program changes --- .../cmd/boatstack-helper/delegation_command.go | 3 --- .../cmd/boatstack-helper/delegation_runtime.go | 3 +++ boatstack/cmd/boatstack-helper/human_identity.go | 16 +++++++++++++--- .../cmd/boatstack-helper/human_identity_test.go | 15 +++++++++++++++ boatstack/cmd/boatstack-helper/main.go | 9 +++------ boatstack/flow/softwaredelivery/skills.go | 4 ++++ boatstack/flow/softwaredelivery/skills_test.go | 2 ++ .../softwaredelivery/surfaces/protocol.go | 11 ++++++----- 8 files changed, 46 insertions(+), 17 deletions(-) diff --git a/boatstack/cmd/boatstack-helper/delegation_command.go b/boatstack/cmd/boatstack-helper/delegation_command.go index ff8aeb2..7728ea9 100644 --- a/boatstack/cmd/boatstack-helper/delegation_command.go +++ b/boatstack/cmd/boatstack-helper/delegation_command.go @@ -334,9 +334,6 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa return surfaces.Response{}, err } if programChangeResponse != nil { - if err := attachHumanIdentity(resolveRequest, programChangeResponse); err != nil { - return surfaces.Response{}, err - } return *programChangeResponse, nil } _, delegationResponse, err := prepareDelegation(ctx, &resolveRequest) diff --git a/boatstack/cmd/boatstack-helper/delegation_runtime.go b/boatstack/cmd/boatstack-helper/delegation_runtime.go index 1795fca..0a1d98f 100644 --- a/boatstack/cmd/boatstack-helper/delegation_runtime.go +++ b/boatstack/cmd/boatstack-helper/delegation_runtime.go @@ -198,6 +198,9 @@ func preflightDelegatedProgramChange(ctx context.Context, request surfaces.Reque return nil, nil } response.Operation = request.Operation + if err := attachHumanIdentity(request, &response); err != nil { + return nil, err + } return &response, nil } diff --git a/boatstack/cmd/boatstack-helper/human_identity.go b/boatstack/cmd/boatstack-helper/human_identity.go index 34004d1..4219dc4 100644 --- a/boatstack/cmd/boatstack-helper/human_identity.go +++ b/boatstack/cmd/boatstack-helper/human_identity.go @@ -83,14 +83,19 @@ func humanIdentityPresentationAndFingerprintForRepository(repository string) (hu } func attachHumanIdentity(request surfaces.Request, response *surfaces.Response) error { - if response == nil || response.Question == nil || !questionRequiresHuman(*response.Question) { + if response == nil { + return nil + } + programChangeRequiresHuman := response.ProgramChange != nil + questionRequiresIdentity := response.Question != nil && questionRequiresHuman(*response.Question) + if !programChangeRequiresHuman && !questionRequiresIdentity { return nil } // Before installation there is no repository-selected identity descriptor // to bind. The bootstrap caller must supply an explicit actor; once a // candidate or installed configuration exists, every human question below // is required to carry its verified descriptor. - if request.ControlBundle == nil && response.Question.TransitionID == "installation.initialize" { + if request.ControlBundle == nil && response.Question != nil && response.Question.TransitionID == "installation.initialize" { return nil } var presentation humanidentity.Presentation @@ -118,7 +123,12 @@ func attachHumanIdentity(request surfaces.Request, response *surfaces.Response) if err != nil { return err } - response.Question.HumanIdentity = &presentation + if questionRequiresIdentity { + response.Question.HumanIdentity = &presentation + } + if programChangeRequiresHuman { + response.ProgramChange.HumanIdentity = &presentation + } return nil } diff --git a/boatstack/cmd/boatstack-helper/human_identity_test.go b/boatstack/cmd/boatstack-helper/human_identity_test.go index cd3d76b..b6b0585 100644 --- a/boatstack/cmd/boatstack-helper/human_identity_test.go +++ b/boatstack/cmd/boatstack-helper/human_identity_test.go @@ -46,6 +46,13 @@ func TestHumanIdentityPresentationIsBoundToVerifiedConfiguration(t *testing.T) { if err := attachHumanIdentity(request, &response); err != nil || response.Question.HumanIdentity == nil || !reflect.DeepEqual(*response.Question.HumanIdentity, presentation) { t.Fatalf("attached identity = %#v, err=%v", response.Question.HumanIdentity, err) } + programChange := surfaces.Response{ProgramChange: &surfaces.ProgramChange{ + PriorProgramFingerprint: strings.Repeat("a", 64), CandidateProgramFingerprint: strings.Repeat("b", 64), + ProgramDeltaFingerprint: strings.Repeat("c", 64), RequiredTransition: "installation.reconcile-update", AcceptanceFlag: "--accept-program-change", + }} + if err := attachHumanIdentity(request, &programChange); err != nil || programChange.ProgramChange.HumanIdentity == nil || !reflect.DeepEqual(*programChange.ProgramChange.HumanIdentity, presentation) { + t.Fatalf("program-change identity = %#v, err=%v", programChange.ProgramChange.HumanIdentity, err) + } if err := os.WriteFile(configPath, []byte(strings.ReplaceAll(string(configRaw), ".login", ".name")), 0o600); err != nil { t.Fatal(err) @@ -98,6 +105,14 @@ func TestHumanIdentityRenderingPreservesStructuredArgvWithoutExecutingIt(t *test if _, err := os.Stat(marker); !os.IsNotExist(err) { t.Fatalf("rendering executed identity command: %v", err) } + programChange := surfaces.Response{Error: "PROGRAM_DRIFT", ProgramChange: &surfaces.ProgramChange{ + PriorProgramFingerprint: strings.Repeat("b", 64), CandidateProgramFingerprint: strings.Repeat("c", 64), + ProgramDeltaFingerprint: strings.Repeat("d", 64), RequiredTransition: "installation.reconcile-update", AcceptanceFlag: "--accept-program-change", HumanIdentity: &presentation, + }} + output, err = captureStdout(t, func() error { return renderResponse(programChange, "text") }) + if err != nil || !strings.Contains(string(output), `human_identity_command="touch" "`+marker+`"`) { + t.Fatalf("program-change text output = %q, err=%v", output, err) + } } func TestAuthorizationReceiptIdentityBindsIdentityProvider(t *testing.T) { diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index 14e268a..1f10ac7 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -169,9 +169,6 @@ func run(arguments []string) error { return err } if programChangeResponse != nil { - if err := attachHumanIdentity(request, programChangeResponse); err != nil { - return err - } return renderResponse(*programChangeResponse, options.format) } delegationLock, delegationResponse, err := prepareDelegation(context.Background(), &request) @@ -299,9 +296,6 @@ func runRPC() error { return err } if programChangeResponse != nil { - if err := attachHumanIdentity(request, programChangeResponse); err != nil { - return err - } encoder := json.NewEncoder(os.Stdout) encoder.SetIndent("", " ") return encoder.Encode(programChangeResponse) @@ -968,6 +962,9 @@ func renderResponse(response surfaces.Response, format string) error { fmt.Printf("program_change prior=%s candidate=%s delta=%s transition=%s accept=%s\n", response.ProgramChange.PriorProgramFingerprint, response.ProgramChange.CandidateProgramFingerprint, response.ProgramChange.ProgramDeltaFingerprint, response.ProgramChange.RequiredTransition, response.ProgramChange.AcceptanceFlag) + if response.ProgramChange.HumanIdentity != nil { + renderHumanIdentity(*response.ProgramChange.HumanIdentity) + } } return nil } diff --git a/boatstack/flow/softwaredelivery/skills.go b/boatstack/flow/softwaredelivery/skills.go index fb41a0c..c4d34a3 100644 --- a/boatstack/flow/softwaredelivery/skills.go +++ b/boatstack/flow/softwaredelivery/skills.go @@ -119,6 +119,10 @@ explicit human acceptance of that exact delta separately from delegation approval. Never infer acceptance from repository authority, autonomy, installation, or a previous program change. +Resolve the proposed actor from the exact `+"`program_change.human_identity`"+` +object using the human-identity protocol above. Do not ask the user to invent +an actor unless that descriptor's command resolution fails. + Continue only when the response names `+"`installation.reconcile-update`"+` and `+"`--accept-program-change`"+`, and the user accepts the displayed exact delta. Then run: diff --git a/boatstack/flow/softwaredelivery/skills_test.go b/boatstack/flow/softwaredelivery/skills_test.go index f111628..518210a 100644 --- a/boatstack/flow/softwaredelivery/skills_test.go +++ b/boatstack/flow/softwaredelivery/skills_test.go @@ -48,6 +48,7 @@ func TestGeneratedSkillsProjectOnlyDeclaredEntriesWithHostParity(t *testing.T) { "boatstack reconcile-update --repo . --flow product-delivery --entry run --run-id ", "do not request or reuse product delegation before reconciliation", "commit\nthose exact files separately before product work", "Ask\nfor product delegation only after Boatstack returns the new exact delegation", + "program_change.human_identity", "Do not ask the user to invent\nan actor unless", "inspect its exact\n`human_identity`", "provider_fingerprint", "execute the exact `command` and", "at most 1024 bytes", "proposed actor", "ask the human for explicit approval", "Identity resolution never counts as approval", "never infer one from the operating system, Git, host", @@ -90,6 +91,7 @@ func TestGeneratedSoftwareDeliverySkillMakesProgramDriftCoreachableWithoutImplic "UNRESOLVED", "solely because the selected compiled\nprogram differs", "exact prior program fingerprint", "candidate program fingerprint", "program-delta fingerprint", "Ask for\nexplicit human acceptance", "Never infer acceptance", "installation.reconcile-update", "--accept-program-change", + "program_change.human_identity", "Do not ask the user to invent\nan actor unless", "--human ", "program-change acceptance is true", "bound to the accepted bundle", "stop without\nperforming product effects", } { if !strings.Contains(codex, contract) { diff --git a/boatstack/internal/softwaredelivery/surfaces/protocol.go b/boatstack/internal/softwaredelivery/surfaces/protocol.go index cf2d790..f16a3f0 100644 --- a/boatstack/internal/softwaredelivery/surfaces/protocol.go +++ b/boatstack/internal/softwaredelivery/surfaces/protocol.go @@ -229,11 +229,12 @@ type DoctorReport struct { } type ProgramChange struct { - PriorProgramFingerprint string `json:"prior_program_fingerprint"` - CandidateProgramFingerprint string `json:"candidate_program_fingerprint"` - ProgramDeltaFingerprint string `json:"program_delta_fingerprint"` - RequiredTransition catalog.TransitionID `json:"required_transition"` - AcceptanceFlag string `json:"acceptance_flag"` + PriorProgramFingerprint string `json:"prior_program_fingerprint"` + CandidateProgramFingerprint string `json:"candidate_program_fingerprint"` + ProgramDeltaFingerprint string `json:"program_delta_fingerprint"` + RequiredTransition catalog.TransitionID `json:"required_transition"` + AcceptanceFlag string `json:"acceptance_flag"` + HumanIdentity *humanidentity.Presentation `json:"human_identity,omitempty"` } type Response struct { From 6b93ffa6706f17f7d6f6b0383941403ad1a6a605 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sun, 16 Aug 2026 20:45:53 +0100 Subject: [PATCH 03/11] fix: finalize identity on kernel responses --- .../cmd/boatstack-helper/delegation_command.go | 11 ++++------- .../cmd/boatstack-helper/delegation_runtime.go | 5 +---- boatstack/cmd/boatstack-helper/human_identity.go | 16 ++++++++++++++++ .../cmd/boatstack-helper/human_identity_test.go | 15 ++++++++++++--- boatstack/cmd/boatstack-helper/main.go | 11 ++++------- boatstack/cmd/boatstack-helper/work_command.go | 2 +- 6 files changed, 38 insertions(+), 22 deletions(-) diff --git a/boatstack/cmd/boatstack-helper/delegation_command.go b/boatstack/cmd/boatstack-helper/delegation_command.go index 7728ea9..1fd15a4 100644 --- a/boatstack/cmd/boatstack-helper/delegation_command.go +++ b/boatstack/cmd/boatstack-helper/delegation_command.go @@ -355,7 +355,7 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa resolveLease.Release() return surfaces.Response{}, err } - resolved, err := kernel.Handle(ctx, resolveRequest) + resolved, err := handleWithHumanIdentity(ctx, kernel, resolveRequest) resolveLease.Release() if settleErr := settleDelegationAtTarget(ctx, resolveRequest, resolved, kernel.TargetSatisfied(resolved.Snapshot, resolveRequest.Objective), false); settleErr != nil && err == nil { err = settleErr @@ -410,7 +410,7 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa resolveLease.Release() return surfaces.Response{}, err } - resolved, err = kernel.Handle(ctx, resolveRequest) + resolved, err = handleWithHumanIdentity(ctx, kernel, resolveRequest) resolveLease.Release() if settleErr := settleDelegationAtTarget(ctx, resolveRequest, resolved, kernel.TargetSatisfied(resolved.Snapshot, resolveRequest.Objective), false); settleErr != nil && err == nil { err = settleErr @@ -419,9 +419,6 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa return resolved, err } } - if err := attachHumanIdentity(resolveRequest, &resolved); err != nil { - return surfaces.Response{}, err - } applyRequest := resolveRequest applyRequest.Operation = surfaces.OperationApply applyRequest.TransitionID = resolved.Prescription.TransitionID @@ -455,7 +452,7 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa if err := verifyTrustedRequestControlBundle(ctx, applyRequest); err != nil { return surfaces.Response{}, err } - applied, err := kernel.Handle(ctx, applyRequest) + applied, err := handleWithHumanIdentity(ctx, kernel, applyRequest) targetSatisfied := kernel.TargetSatisfied(applied.Snapshot, applyRequest.Objective) if settleErr := settleDelegationAtTarget(ctx, applyRequest, applied, targetSatisfied, delegationLock != nil); settleErr != nil && err == nil { err = settleErr @@ -507,7 +504,7 @@ func stabilizeRepositoryPrescription(ctx context.Context, request surfaces.Reque if err != nil { return surfaces.Request{}, surfaces.Response{}, true, err } - stabilized, err := kernel.Handle(ctx, rebound) + stabilized, err := handleWithHumanIdentity(ctx, kernel, rebound) if err != nil { return rebound, stabilized, true, err } diff --git a/boatstack/cmd/boatstack-helper/delegation_runtime.go b/boatstack/cmd/boatstack-helper/delegation_runtime.go index 0a1d98f..4f8fa29 100644 --- a/boatstack/cmd/boatstack-helper/delegation_runtime.go +++ b/boatstack/cmd/boatstack-helper/delegation_runtime.go @@ -190,7 +190,7 @@ func preflightDelegatedProgramChange(ctx context.Context, request surfaces.Reque if err != nil { return nil, err } - response, err := kernel.Handle(ctx, probe) + response, err := handleWithHumanIdentity(ctx, kernel, probe) if err != nil { return nil, err } @@ -198,9 +198,6 @@ func preflightDelegatedProgramChange(ctx context.Context, request surfaces.Reque return nil, nil } response.Operation = request.Operation - if err := attachHumanIdentity(request, &response); err != nil { - return nil, err - } return &response, nil } diff --git a/boatstack/cmd/boatstack-helper/human_identity.go b/boatstack/cmd/boatstack-helper/human_identity.go index 4219dc4..9629347 100644 --- a/boatstack/cmd/boatstack-helper/human_identity.go +++ b/boatstack/cmd/boatstack-helper/human_identity.go @@ -1,6 +1,7 @@ package main import ( + "context" "crypto/sha256" "encoding/hex" "fmt" @@ -14,6 +15,21 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/surfaces" ) +type humanIdentityResponseHandler interface { + Handle(context.Context, surfaces.Request) (surfaces.Response, error) +} + +// handleWithHumanIdentity is the single user-facing Kernel response boundary. +// A response cannot leave the command layer before every human authority +// surface is bound to the verified repository identity descriptor. +func handleWithHumanIdentity(ctx context.Context, handler humanIdentityResponseHandler, request surfaces.Request) (surfaces.Response, error) { + response, err := handler.Handle(ctx, request) + if identityErr := attachHumanIdentity(request, &response); identityErr != nil { + return response, identityErr + } + return response, err +} + func humanIdentityPresentationForRequest(request surfaces.Request) (humanidentity.Presentation, error) { if request.ControlBundle == nil { return humanidentity.Presentation{}, fmt.Errorf("HUMAN_IDENTITY_UNBOUND: request has no verified control bundle") diff --git a/boatstack/cmd/boatstack-helper/human_identity_test.go b/boatstack/cmd/boatstack-helper/human_identity_test.go index b6b0585..0620729 100644 --- a/boatstack/cmd/boatstack-helper/human_identity_test.go +++ b/boatstack/cmd/boatstack-helper/human_identity_test.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/json" "os" "path/filepath" @@ -15,6 +16,14 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/surfaces" ) +type staticHumanIdentityResponseHandler struct { + response surfaces.Response +} + +func (handler staticHumanIdentityResponseHandler) Handle(context.Context, surfaces.Request) (surfaces.Response, error) { + return handler.response, nil +} + func TestHumanIdentityPresentationIsBoundToVerifiedConfiguration(t *testing.T) { repository := t.TempDir() configRaw := []byte(`{"schema_version":3,"identity":{"human":{"kind":"command","command":"gh","args":["api","user","--jq",".login"]}},"project":{"name":"fixture","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli","codex"]}`) @@ -46,11 +55,11 @@ func TestHumanIdentityPresentationIsBoundToVerifiedConfiguration(t *testing.T) { if err := attachHumanIdentity(request, &response); err != nil || response.Question.HumanIdentity == nil || !reflect.DeepEqual(*response.Question.HumanIdentity, presentation) { t.Fatalf("attached identity = %#v, err=%v", response.Question.HumanIdentity, err) } - programChange := surfaces.Response{ProgramChange: &surfaces.ProgramChange{ + programChange, err := handleWithHumanIdentity(context.Background(), staticHumanIdentityResponseHandler{response: surfaces.Response{ProgramChange: &surfaces.ProgramChange{ PriorProgramFingerprint: strings.Repeat("a", 64), CandidateProgramFingerprint: strings.Repeat("b", 64), ProgramDeltaFingerprint: strings.Repeat("c", 64), RequiredTransition: "installation.reconcile-update", AcceptanceFlag: "--accept-program-change", - }} - if err := attachHumanIdentity(request, &programChange); err != nil || programChange.ProgramChange.HumanIdentity == nil || !reflect.DeepEqual(*programChange.ProgramChange.HumanIdentity, presentation) { + }}}, request) + if err != nil || programChange.ProgramChange.HumanIdentity == nil || !reflect.DeepEqual(*programChange.ProgramChange.HumanIdentity, presentation) { t.Fatalf("program-change identity = %#v, err=%v", programChange.ProgramChange.HumanIdentity, err) } diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index 1f10ac7..ee3d544 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -212,10 +212,7 @@ func run(arguments []string) error { resolveRequest.FlowID = "" } resolveRequest.Prescription = protocol.Prescription{} - resolved, resolveErr := kernel.Handle(context.Background(), resolveRequest) - if identityErr := attachHumanIdentity(resolveRequest, &resolved); identityErr != nil { - return identityErr - } + resolved, resolveErr := handleWithHumanIdentity(context.Background(), kernel, resolveRequest) if resolveErr != nil || resolved.Prescription == nil { if renderErr := renderResponse(resolved, options.format); renderErr != nil { return renderErr @@ -230,7 +227,7 @@ func run(arguments []string) error { } request.Prescription = *resolved.Prescription } - response, handleErr := kernel.Handle(context.Background(), request) + response, handleErr := handleWithHumanIdentity(context.Background(), kernel, request) if handleErr == nil && operation == surfaces.OperationResolve { request, response, _, handleErr = stabilizeRepositoryPrescription(context.Background(), request, response) } @@ -340,7 +337,7 @@ func runRPC() error { if err != nil { return err } - response, handleErr := kernel.Handle(context.Background(), request) + response, handleErr := handleWithHumanIdentity(context.Background(), kernel, request) if handleErr == nil && request.Operation == surfaces.OperationResolve { request, response, _, handleErr = stabilizeRepositoryPrescription(context.Background(), request, response) } @@ -606,7 +603,7 @@ func followEvents(kernel boatstack.DeliveryController, request surfaces.Request) ticker := time.NewTicker(time.Second) defer ticker.Stop() for { - response, err := kernel.Handle(ctx, request) + response, err := handleWithHumanIdentity(ctx, kernel, request) if err != nil { return err } diff --git a/boatstack/cmd/boatstack-helper/work_command.go b/boatstack/cmd/boatstack-helper/work_command.go index 44c2ccb..775db9d 100644 --- a/boatstack/cmd/boatstack-helper/work_command.go +++ b/boatstack/cmd/boatstack-helper/work_command.go @@ -71,7 +71,7 @@ func runFlowWork(arguments []string) error { if err != nil { return err } - response, handleErr := kernel.Handle(context.Background(), request) + response, handleErr := handleWithHumanIdentity(context.Background(), kernel, request) if renderErr := renderResponse(response, options.format); renderErr != nil { return renderErr } From d35c89af0a59df169929191e2323025eeff6a229 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sun, 16 Aug 2026 21:23:16 +0100 Subject: [PATCH 04/11] test: make identity checks portable --- .github/workflows/ci.yml | 6 ++++++ boatstack/cmd/boatstack-helper/human_identity_test.go | 8 ++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac3cd6a..29b8438 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -223,6 +223,11 @@ jobs: $env:BOATSTACK_BINARY = $helper $env:BOATSTACK_BINARY_SHA256 = $digest $env:BOATSTACK_EXPECTED_RUNTIME_SHA256 = $digest + if ($mode -eq "install") { + $env:BOATSTACK_ACTOR = "installer-contract" + } else { + Remove-Item Env:BOATSTACK_ACTOR -ErrorAction SilentlyContinue + } & ./install.ps1 if ($LASTEXITCODE -ne 0) { throw "$mode installer failed" } @@ -238,6 +243,7 @@ jobs: throw "install did not initialize the repository runtime pin" } } + Remove-Item Env:BOATSTACK_ACTOR -ErrorAction SilentlyContinue $missingEvidenceRepository = Join-Path $root "missing-evidence-repository" New-Item -ItemType Directory -Force -Path $missingEvidenceRepository | Out-Null diff --git a/boatstack/cmd/boatstack-helper/human_identity_test.go b/boatstack/cmd/boatstack-helper/human_identity_test.go index 0620729..ab332f2 100644 --- a/boatstack/cmd/boatstack-helper/human_identity_test.go +++ b/boatstack/cmd/boatstack-helper/human_identity_test.go @@ -104,8 +104,12 @@ func TestHumanIdentityRenderingPreservesStructuredArgvWithoutExecutingIt(t *test if err != nil { t.Fatal(err) } - if !strings.Contains(string(raw), `"command":"touch","args":["`+marker+`"]`) { - t.Fatalf("structured JSON lost command argv: %s", raw) + var decoded surfaces.Response + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatal(err) + } + if decoded.Delegation == nil || decoded.Delegation.HumanIdentity.Descriptor.Command != "touch" || !reflect.DeepEqual(decoded.Delegation.HumanIdentity.Descriptor.Args, []string{marker}) { + t.Fatalf("structured JSON lost command argv: %#v", decoded.Delegation) } output, err := captureStdout(t, func() error { return renderResponse(response, "text") }) if err != nil || !strings.Contains(string(output), `human_identity_command="touch" "`+marker+`"`) { From 5d05b72970496310f68196cf2f85370c5e938b88 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sun, 16 Aug 2026 21:28:41 +0100 Subject: [PATCH 05/11] fix: gate host identity resolution --- boatstack/flow/softwaredelivery/skills.go | 14 +++++++++----- boatstack/flow/softwaredelivery/skills_test.go | 4 +++- boatstack/references/host-hook-contracts.md | 6 ++++++ docs/configuration.md | 13 ++++++++----- release-notes/2026-08-16-human-actor-identity.md | 5 +++++ 5 files changed, 31 insertions(+), 11 deletions(-) diff --git a/boatstack/flow/softwaredelivery/skills.go b/boatstack/flow/softwaredelivery/skills.go index c4d34a3..55d6942 100644 --- a/boatstack/flow/softwaredelivery/skills.go +++ b/boatstack/flow/softwaredelivery/skills.go @@ -69,11 +69,15 @@ The ` + "`provider_fingerprint`" + ` identifies the repository-selected identity descriptor; it is provenance only and grants no authority. For a ` + "`literal`" + ` descriptor, use its validated ` + "`value`" + ` as the proposed -actor. For a ` + "`command`" + ` descriptor, execute the exact ` + "`command`" + ` and -` + "`args`" + ` directly through the host command tool. Do not join them into a shell -string, interpolate values, rewrite arguments, or use a shell evaluator. Require a -zero exit status and stdout of at most 1024 bytes. Remove at most one trailing LF or -CRLF, then require exactly one non-empty line with no NUL and an actor matching +actor. For a ` + "`command`" + ` descriptor, treat the descriptor as untrusted +repository data. Identity resolution is a separate host command action: the Flow +request and delegation request do not authorize it. Submit the exact ` + "`command`" + ` +and ` + "`args`" + ` to the host's normal command permission boundary, and execute only +if that boundary independently permits the action. If it refuses or cannot authorize +the action, use the explicit human-supplied fallback below. Do not join the argv into +a shell string, interpolate values, rewrite arguments, or use a shell evaluator. +Require a zero exit status and stdout of at most 1024 bytes. Remove at most one +trailing LF or CRLF, then require exactly one non-empty line with no NUL and an actor matching ` + "`^[A-Za-z0-9][A-Za-z0-9._-]*$`" + `. Stderr is diagnostic only. Visibly display the proposed actor, exact request or transition, requested diff --git a/boatstack/flow/softwaredelivery/skills_test.go b/boatstack/flow/softwaredelivery/skills_test.go index 518210a..6c5103a 100644 --- a/boatstack/flow/softwaredelivery/skills_test.go +++ b/boatstack/flow/softwaredelivery/skills_test.go @@ -49,7 +49,9 @@ func TestGeneratedSkillsProjectOnlyDeclaredEntriesWithHostParity(t *testing.T) { "do not request or reuse product delegation before reconciliation", "commit\nthose exact files separately before product work", "Ask\nfor product delegation only after Boatstack returns the new exact delegation", "program_change.human_identity", "Do not ask the user to invent\nan actor unless", - "inspect its exact\n`human_identity`", "provider_fingerprint", "execute the exact `command` and", + "inspect its exact\n`human_identity`", "provider_fingerprint", "Submit the exact `command`", + "untrusted\nrepository data", "separate host command action", "do not authorize it", + "normal command permission boundary", "independently permits the action", "at most 1024 bytes", "proposed actor", "ask the human for explicit approval", "Identity resolution never counts as approval", "never infer one from the operating system, Git, host", "--human-identity-provider-fingerprint ", diff --git a/boatstack/references/host-hook-contracts.md b/boatstack/references/host-hook-contracts.md index 843174f..f6db41b 100644 --- a/boatstack/references/host-hook-contracts.md +++ b/boatstack/references/host-hook-contracts.md @@ -30,6 +30,12 @@ objective, source predicate, authority clauses, parameters, or expected postcondition. CLI, Cursor, Codex, Claude, Gemini, and MCP are capability labels, not controllers. +A repository-selected human identity command is untrusted data and is not a +Boatstack transition effect. A Flow request, identity presentation, or +delegation request does not grant permission to execute it. The host must submit +the exact structured argv to its own command permission boundary and use the +explicit actor fallback when that boundary does not independently permit it. + A host that claims it can complete external publication must expose a provider receipt issuer before the delivery begins. If that capability is absent, the host must declare that it can progress only to the authority-bearing diff --git a/docs/configuration.md b/docs/configuration.md index 0105799..ed5b9cd 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -62,11 +62,14 @@ A command descriptor contains an executable name and an exact argument array: ``` Boatstack validates, fingerprints, and exposes this data but never executes the -command. A host may execute the exact command and arguments directly, without a -shell or interpolation. It accepts only a zero exit status and one non-empty -actor line of at most 1 KiB after removing at most one trailing LF or CRLF. If -resolution fails, the host must ask the user for an actor; it must not infer an -operating-system or Git identity. +command. The descriptor is untrusted repository data. A Flow or delegation +request does not authorize its execution. A host may submit the exact command +and arguments to its own command permission boundary, without a shell or +interpolation, and execute them only when that boundary independently permits +the action. It accepts only a zero exit status and one non-empty actor line of +at most 1 KiB after removing at most one trailing LF or CRLF. If execution is +not permitted or resolution fails, the host must ask the user for an actor; it +must not infer an operating-system or Git identity. The host displays the resolved actor, exact request, and requested authority, then asks for explicit approval. The authorization command still requires diff --git a/release-notes/2026-08-16-human-actor-identity.md b/release-notes/2026-08-16-human-actor-identity.md index d24b308..c49bb5f 100644 --- a/release-notes/2026-08-16-human-actor-identity.md +++ b/release-notes/2026-08-16-human-actor-identity.md @@ -4,3 +4,8 @@ Repositories now declare a literal or structured command descriptor for the human actor that hosts present at authority boundaries. Boatstack fingerprints and exposes the descriptor without executing it, while explicit approval and external-provider authority remain separate requirements. + +This is a breaking alpha boundary. Schema-2 project configurations and +schema-2 delegation records are intentionally unsupported. Regenerate the +project installation and start a new run; there is no in-place migration or +compatibility reader. From ecb79d2d5e6734fbaca207998bbb1cc3619fdfba Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sun, 16 Aug 2026 21:56:23 +0100 Subject: [PATCH 06/11] fix: bind identity to authoritative configuration --- .../cmd/boatstack-helper/declarative_flow.go | 11 +- .../cmd/boatstack-helper/flow_runtime.go | 2 +- .../cmd/boatstack-helper/human_identity.go | 137 +---------- .../boatstack-helper/human_identity_test.go | 4 + .../cmd/boatstack-helper/input_command.go | 2 +- .../humanidentitybinding/binding.go | 215 ++++++++++++++++++ .../humanidentitybinding/binding_test.go | 136 +++++++++++ boatstack/sdk/human_identity_test.go | 54 +++++ boatstack/sdk/sdk.go | 7 +- 9 files changed, 432 insertions(+), 136 deletions(-) create mode 100644 boatstack/internal/softwaredelivery/humanidentitybinding/binding.go create mode 100644 boatstack/internal/softwaredelivery/humanidentitybinding/binding_test.go create mode 100644 boatstack/sdk/human_identity_test.go diff --git a/boatstack/cmd/boatstack-helper/declarative_flow.go b/boatstack/cmd/boatstack-helper/declarative_flow.go index 115f733..8dca66b 100644 --- a/boatstack/cmd/boatstack-helper/declarative_flow.go +++ b/boatstack/cmd/boatstack-helper/declarative_flow.go @@ -56,6 +56,7 @@ type declarativeRuntimeContext struct { state declarativeRunState statePath string store invocation.Store + controlBundle boatstackruntime.ControlBundleSnapshot executionScopeFingerprint string } @@ -188,7 +189,7 @@ func runDeclarativeFlow(ctx context.Context, compiled controlprogram.Compiled, o if err := runtimeContext.store.SaveRequest(*result.Request); err != nil { return err } - presentation, identityErr := humanIdentityPresentationForRepository(repository) + presentation, identityErr := humanIdentityPresentationForRepositoryBound(ctx, repository, options.host, "declarative-input", runtimeContext.controlBundle, nil) if identityErr != nil { return identityErr } @@ -202,7 +203,7 @@ func runDeclarativeFlow(ctx context.Context, compiled controlprogram.Compiled, o return fmt.Errorf("FLOW_INVOCATION_INCOMPLETE: declarative materialization produced no evidence") } if err := requireDeclarativeAuthority(transition, operator, options.humanActor); err != nil { - presentation, identityErr := humanIdentityPresentationForRepository(repository) + presentation, identityErr := humanIdentityPresentationForRepositoryBound(ctx, repository, options.host, "declarative-authority", runtimeContext.controlBundle, nil) if identityErr != nil { return identityErr } @@ -286,6 +287,10 @@ func requireDeclarativeAuthority(transition controlprogram.Transition, operator } func loadDeclarativeRuntimeContext(ctx context.Context, repository string, compiled controlprogram.Compiled, entry controlprogram.Entry, options commandOptions) (declarativeRuntimeContext, error) { + controlBundle, err := buildRepositoryControlBundle(ctx, repository) + if err != nil { + return declarativeRuntimeContext{}, err + } resolver, err := plant.NewResolver("") if err != nil { return declarativeRuntimeContext{}, err @@ -358,7 +363,7 @@ func loadDeclarativeRuntimeContext(ctx context.Context, repository string, compi } return declarativeRuntimeContext{ compiled: compiled, entry: entry, state: state, statePath: statePath, - store: invocation.Store{Root: layout.FlowRoot, Writer: effects.NewRuntimeStore()}, executionScopeFingerprint: scope, + store: invocation.Store{Root: layout.FlowRoot, Writer: effects.NewRuntimeStore()}, controlBundle: controlBundle, executionScopeFingerprint: scope, }, nil } diff --git a/boatstack/cmd/boatstack-helper/flow_runtime.go b/boatstack/cmd/boatstack-helper/flow_runtime.go index 07f4ad9..65b6503 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime.go @@ -263,7 +263,7 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, if description == "" { description = fmt.Sprintf("Run %s/%s to %s", options.programID, options.entryID, objective.TargetID) } - presentation, presentationErr := humanIdentityPresentationFromBoundConfig(filepath.Join(repository, ".boatstack", "project.json"), bundle.Source) + presentation, presentationErr := humanIdentityPresentationForRepositoryBound(ctx, repository, host, "flow-delegation-request", bundle.Source, nil) if presentationErr != nil { return commandOptions{}, presentationErr } diff --git a/boatstack/cmd/boatstack-helper/human_identity.go b/boatstack/cmd/boatstack-helper/human_identity.go index 9629347..aa455bc 100644 --- a/boatstack/cmd/boatstack-helper/human_identity.go +++ b/boatstack/cmd/boatstack-helper/human_identity.go @@ -2,16 +2,12 @@ package main import ( "context" - "crypto/sha256" - "encoding/hex" "fmt" - "os" - "path/filepath" boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" - "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/humanidentity" - "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/humanidentitybinding" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/surfaces" ) @@ -23,138 +19,19 @@ type humanIdentityResponseHandler interface { // A response cannot leave the command layer before every human authority // surface is bound to the verified repository identity descriptor. func handleWithHumanIdentity(ctx context.Context, handler humanIdentityResponseHandler, request surfaces.Request) (surfaces.Response, error) { - response, err := handler.Handle(ctx, request) - if identityErr := attachHumanIdentity(request, &response); identityErr != nil { - return response, identityErr - } - return response, err + return humanidentitybinding.Handle(ctx, "", handler, request) } func humanIdentityPresentationForRequest(request surfaces.Request) (humanidentity.Presentation, error) { - if request.ControlBundle == nil { - return humanidentity.Presentation{}, fmt.Errorf("HUMAN_IDENTITY_UNBOUND: request has no verified control bundle") - } - snapshot := request.ControlBundle.Source - configPath := filepath.Join(request.Repository, ".boatstack", "project.json") - if request.TransitionID == "installation.initialize" { - if request.ControlBundle.Target == nil { - return humanidentity.Presentation{}, fmt.Errorf("HUMAN_IDENTITY_UNBOUND: initialization has no target control bundle") - } - snapshot = *request.ControlBundle.Target - value, ok := request.Parameters.Get("config_path") - if !ok { - return humanidentity.Presentation{}, fmt.Errorf("HUMAN_IDENTITY_UNBOUND: initialization has no configuration path") - } - configPath = value - } - return humanIdentityPresentationFromBoundConfig(configPath, snapshot) + return humanidentitybinding.PresentationForRequest(context.Background(), "", request, nil) } -func humanIdentityPresentationFromBoundConfig(configPath string, snapshot boatstackruntime.ControlBundleSnapshot) (humanidentity.Presentation, error) { - var binding *boatstackruntime.ControlBundleFile - for index := range snapshot.Files { - if snapshot.Files[index].Path == ".boatstack/project.json" { - binding = &snapshot.Files[index] - break - } - } - if binding == nil || binding.Absent { - return humanidentity.Presentation{}, fmt.Errorf("HUMAN_IDENTITY_UNBOUND: verified project configuration is absent") - } - info, err := os.Lstat(configPath) - if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { - return humanidentity.Presentation{}, fmt.Errorf("HUMAN_IDENTITY_UNBOUND: project configuration is not a regular file") - } - raw, err := os.ReadFile(configPath) - if err != nil { - return humanidentity.Presentation{}, err - } - digest := sha256.Sum256(raw) - if hex.EncodeToString(digest[:]) != binding.SHA256 { - return humanidentity.Presentation{}, fmt.Errorf("HUMAN_IDENTITY_DRIFT: project configuration does not match the verified control bundle") - } - config, err := protocol.DecodeProjectConfig(raw) - if err != nil { - return humanidentity.Presentation{}, err - } - return humanidentity.NewPresentation(config.Identity.Human) -} - -func humanIdentityPresentationForRepository(repository string) (humanidentity.Presentation, error) { - presentation, _, err := humanIdentityPresentationAndFingerprintForRepository(repository) - return presentation, err -} - -func humanIdentityPresentationAndFingerprintForRepository(repository string) (humanidentity.Presentation, string, error) { - raw, err := os.ReadFile(filepath.Join(repository, ".boatstack", "project.json")) - if err != nil { - return humanidentity.Presentation{}, "", err - } - config, fingerprint, err := protocol.ProjectConfigFingerprint(raw) - if err != nil { - return humanidentity.Presentation{}, "", err - } - presentation, err := humanidentity.NewPresentation(config.Identity.Human) - return presentation, fingerprint, err +func humanIdentityPresentationForRepositoryBound(ctx context.Context, repository, host, correlation string, bundle boatstackruntime.ControlBundleSnapshot, observed *model.Snapshot) (humanidentity.Presentation, error) { + return humanidentitybinding.PresentationForRepository(ctx, "", repository, host, correlation, &bundle, observed) } func attachHumanIdentity(request surfaces.Request, response *surfaces.Response) error { - if response == nil { - return nil - } - programChangeRequiresHuman := response.ProgramChange != nil - questionRequiresIdentity := response.Question != nil && questionRequiresHuman(*response.Question) - if !programChangeRequiresHuman && !questionRequiresIdentity { - return nil - } - // Before installation there is no repository-selected identity descriptor - // to bind. The bootstrap caller must supply an explicit actor; once a - // candidate or installed configuration exists, every human question below - // is required to carry its verified descriptor. - if request.ControlBundle == nil && response.Question != nil && response.Question.TransitionID == "installation.initialize" { - return nil - } - var presentation humanidentity.Presentation - var err error - if request.ControlBundle != nil { - presentation, err = humanIdentityPresentationForRequest(request) - } else if request.ProgramID == "" && response.Snapshot != nil { - var fingerprint string - presentation, fingerprint, err = humanIdentityPresentationAndFingerprintForRepository(request.Repository) - if err == nil { - bound := false - for _, evidence := range response.Snapshot.Configuration.Evidence { - if evidence.Fingerprint == fingerprint { - bound = true - break - } - } - if !bound { - return fmt.Errorf("HUMAN_IDENTITY_DRIFT: project configuration does not match the observed configuration") - } - } - } else { - err = fmt.Errorf("HUMAN_IDENTITY_UNBOUND: human authority question has no verified project configuration") - } - if err != nil { - return err - } - if questionRequiresIdentity { - response.Question.HumanIdentity = &presentation - } - if programChangeRequiresHuman { - response.ProgramChange.HumanIdentity = &presentation - } - return nil -} - -func questionRequiresHuman(question surfaces.Question) bool { - for _, authority := range append(append([]catalog.AuthorityClass(nil), question.Authority...), question.AuthorityAll...) { - if authority == catalog.AuthorityHuman { - return true - } - } - return false + return humanidentitybinding.Attach(context.Background(), "", request, response) } func renderHumanIdentity(presentation humanidentity.Presentation) { diff --git a/boatstack/cmd/boatstack-helper/human_identity_test.go b/boatstack/cmd/boatstack-helper/human_identity_test.go index ab332f2..0572eff 100644 --- a/boatstack/cmd/boatstack-helper/human_identity_test.go +++ b/boatstack/cmd/boatstack-helper/human_identity_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "os" + "os/exec" "path/filepath" "reflect" "strings" @@ -26,6 +27,9 @@ func (handler staticHumanIdentityResponseHandler) Handle(context.Context, surfac func TestHumanIdentityPresentationIsBoundToVerifiedConfiguration(t *testing.T) { repository := t.TempDir() + if output, err := exec.Command("git", "init", "-q", repository).CombinedOutput(); err != nil { + t.Fatalf("git init: %v: %s", err, output) + } configRaw := []byte(`{"schema_version":3,"identity":{"human":{"kind":"command","command":"gh","args":["api","user","--jq",".login"]}},"project":{"name":"fixture","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli","codex"]}`) configPath := filepath.Join(repository, ".boatstack", "project.json") if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil { diff --git a/boatstack/cmd/boatstack-helper/input_command.go b/boatstack/cmd/boatstack-helper/input_command.go index c81a92c..d0a03c8 100644 --- a/boatstack/cmd/boatstack-helper/input_command.go +++ b/boatstack/cmd/boatstack-helper/input_command.go @@ -100,7 +100,7 @@ func runFlowInput(arguments []string) error { if loadErr != nil { return loadErr } - presentation, identityErr := humanIdentityPresentationFromBoundConfig(filepath.Join(repository, ".boatstack", "project.json"), runtimeContext.controlBundle.Source) + presentation, identityErr := humanIdentityPresentationForRepositoryBound(ctx, repository, options.host, "flow-input", runtimeContext.controlBundle.Source, nil) if identityErr != nil { return identityErr } diff --git a/boatstack/internal/softwaredelivery/humanidentitybinding/binding.go b/boatstack/internal/softwaredelivery/humanidentitybinding/binding.go new file mode 100644 index 0000000..46df780 --- /dev/null +++ b/boatstack/internal/softwaredelivery/humanidentitybinding/binding.go @@ -0,0 +1,215 @@ +// Package humanidentitybinding binds host-facing human identity presentations +// to the authoritative, verified project configuration. It never executes an +// identity descriptor command and grants no authority. +package humanidentitybinding + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + + boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/durable" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/humanidentity" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/plant" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/surfaces" +) + +type ResponseHandler interface { + Handle(context.Context, surfaces.Request) (surfaces.Response, error) +} + +// Handle is the shared host response boundary. Human questions and program +// changes cannot leave it without the repository's verified identity +// presentation, regardless of whether the caller is the CLI or public SDK. +func Handle(ctx context.Context, externalStateRoot string, handler ResponseHandler, request surfaces.Request) (surfaces.Response, error) { + response, err := handler.Handle(ctx, request) + if identityErr := Attach(ctx, externalStateRoot, request, &response); identityErr != nil { + return response, identityErr + } + return response, err +} + +// Attach adds identity provenance only to authority surfaces that need it. +func Attach(ctx context.Context, externalStateRoot string, request surfaces.Request, response *surfaces.Response) error { + if response == nil { + return nil + } + programChangeRequiresHuman := response.ProgramChange != nil + questionRequiresIdentity := response.Question != nil && questionRequiresHuman(*response.Question) + if !programChangeRequiresHuman && !questionRequiresIdentity { + return nil + } + // No repository-selected descriptor exists before initialization. The + // bootstrap caller must provide an explicit actor. + if request.ControlBundle == nil && response.Question != nil && response.Question.TransitionID == "installation.initialize" { + return nil + } + presentation, err := PresentationForRequest(ctx, externalStateRoot, request, response.Snapshot) + if err != nil { + return err + } + if questionRequiresIdentity { + response.Question.HumanIdentity = &presentation + } + if programChangeRequiresHuman { + response.ProgramChange.HumanIdentity = &presentation + } + return nil +} + +// PresentationForRequest resolves the descriptor selected by the exact +// configuration authority for this invocation. +func PresentationForRequest(ctx context.Context, externalStateRoot string, request surfaces.Request, observed *model.Snapshot) (humanidentity.Presentation, error) { + if request.TransitionID == "installation.initialize" { + if request.ControlBundle == nil || request.ControlBundle.Target == nil { + return humanidentity.Presentation{}, fmt.Errorf("HUMAN_IDENTITY_UNBOUND: initialization has no target control bundle") + } + configPath, ok := request.Parameters.Get("config_path") + if !ok { + return humanidentity.Presentation{}, fmt.Errorf("HUMAN_IDENTITY_UNBOUND: initialization has no configuration path") + } + return PresentationFromBoundConfig(configPath, *request.ControlBundle.Target) + } + var bundle *boatstackruntime.ControlBundleSnapshot + if request.ControlBundle != nil { + bundle = &request.ControlBundle.Source + } + return PresentationForRepository(ctx, externalStateRoot, request.Repository, request.Host, request.CorrelationID, bundle, observed) +} + +// PresentationForRepository resolves the controller layout before reading +// configuration. Repository and external configuration authority therefore +// select the same source used by observation and effects. +func PresentationForRepository(ctx context.Context, externalStateRoot, repository, host, correlation string, bundle *boatstackruntime.ControlBundleSnapshot, observed *model.Snapshot) (humanidentity.Presentation, error) { + if repository == "" { + return humanidentity.Presentation{}, fmt.Errorf("HUMAN_IDENTITY_UNBOUND: repository is required") + } + if host == "" { + host = "cli" + } + if correlation == "" { + correlation = "human-identity" + } + resolver, err := plant.NewResolver(externalStateRoot) + if err != nil { + return humanidentity.Presentation{}, err + } + invocation, err := resolver.ResolveInvocation(ctx, repository, host, correlation) + if err != nil { + return humanidentity.Presentation{}, err + } + layout, current, err := resolver.ResolveLayout(ctx, invocation) + if err != nil { + return humanidentity.Presentation{}, err + } + config, raw, fingerprint, err := readConfig(layout.ConfigPath) + if err != nil { + return humanidentity.Presentation{}, err + } + + trusted := false + if layout.ConfigAuthority == "repository" && bundle != nil { + if !bundleBindsRawConfig(*bundle, raw) { + return humanidentity.Presentation{}, fmt.Errorf("HUMAN_IDENTITY_DRIFT: project configuration does not match the verified control bundle") + } + trusted = true + } + if observed != nil { + if observed.Invocation.RepositoryID != current.RepositoryID || observed.Invocation.GitCommonID != current.GitCommonID || observed.Invocation.WorktreeID != current.WorktreeID { + return humanidentity.Presentation{}, fmt.Errorf("HUMAN_IDENTITY_DRIFT: observed configuration belongs to a different invocation") + } + if !snapshotBindsConfig(observed, fingerprint) { + return humanidentity.Presentation{}, fmt.Errorf("HUMAN_IDENTITY_DRIFT: project configuration does not match the observed configuration") + } + trusted = true + } + if !trusted { + stateRaw, readErr := os.ReadFile(layout.StatePath) + if readErr != nil { + if os.IsNotExist(readErr) { + return humanidentity.Presentation{}, fmt.Errorf("HUMAN_IDENTITY_UNBOUND: authoritative configuration has no verified state") + } + return humanidentity.Presentation{}, readErr + } + state, decodeErr := durable.DecodeState(stateRaw) + if decodeErr != nil { + return humanidentity.Presentation{}, decodeErr + } + if state.RepositoryID != current.RepositoryID || state.GitCommonID != current.GitCommonID || state.WorktreeID != current.WorktreeID { + return humanidentity.Presentation{}, fmt.Errorf("HUMAN_IDENTITY_DRIFT: verified durable state belongs to a different invocation") + } + if state.Configuration != model.ConfigurationVerified || state.ConfigFingerprint != fingerprint { + return humanidentity.Presentation{}, fmt.Errorf("HUMAN_IDENTITY_DRIFT: authoritative configuration does not match verified durable state") + } + } + return humanidentity.NewPresentation(config.Identity.Human) +} + +// PresentationFromBoundConfig verifies an exact candidate configuration +// against a control-bundle snapshot. This is used for initialization, before a +// controller layout can select an installed authority. +func PresentationFromBoundConfig(configPath string, snapshot boatstackruntime.ControlBundleSnapshot) (humanidentity.Presentation, error) { + config, raw, _, err := readConfig(configPath) + if err != nil { + return humanidentity.Presentation{}, err + } + if !bundleBindsRawConfig(snapshot, raw) { + return humanidentity.Presentation{}, fmt.Errorf("HUMAN_IDENTITY_DRIFT: project configuration does not match the verified control bundle") + } + return humanidentity.NewPresentation(config.Identity.Human) +} + +func readConfig(configPath string) (protocol.ProjectConfig, []byte, string, error) { + info, err := os.Lstat(configPath) + if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return protocol.ProjectConfig{}, nil, "", fmt.Errorf("HUMAN_IDENTITY_UNBOUND: project configuration is not a regular file") + } + raw, err := os.ReadFile(configPath) + if err != nil { + return protocol.ProjectConfig{}, nil, "", err + } + config, fingerprint, err := protocol.ProjectConfigFingerprint(raw) + if err != nil { + return protocol.ProjectConfig{}, nil, "", err + } + return config, raw, fingerprint, nil +} + +func bundleBindsRawConfig(snapshot boatstackruntime.ControlBundleSnapshot, raw []byte) bool { + digest := sha256.Sum256(raw) + expected := hex.EncodeToString(digest[:]) + for _, binding := range snapshot.Files { + if binding.Path == ".boatstack/project.json" { + return !binding.Absent && binding.SHA256 == expected + } + } + return false +} + +func snapshotBindsConfig(snapshot *model.Snapshot, fingerprint string) bool { + if snapshot == nil { + return false + } + for _, evidence := range snapshot.Configuration.Evidence { + if evidence.Fingerprint == fingerprint { + return true + } + } + return false +} + +func questionRequiresHuman(question surfaces.Question) bool { + authorities := append(append([]catalog.AuthorityClass(nil), question.Authority...), question.AuthorityAll...) + for _, authority := range authorities { + if authority == catalog.AuthorityHuman { + return true + } + } + return false +} diff --git a/boatstack/internal/softwaredelivery/humanidentitybinding/binding_test.go b/boatstack/internal/softwaredelivery/humanidentitybinding/binding_test.go new file mode 100644 index 0000000..fb839fb --- /dev/null +++ b/boatstack/internal/softwaredelivery/humanidentitybinding/binding_test.go @@ -0,0 +1,136 @@ +package humanidentitybinding + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/durable" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/plant" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" +) + +func TestPresentationUsesRepositoryConfigurationBoundByControlBundle(t *testing.T) { + repository := identityRepository(t) + raw := identityConfig("repository-actor") + writeIdentityFile(t, filepath.Join(repository, ".boatstack", "project.json"), raw) + bundle, err := boatstackruntime.NewControlBundleSnapshot(map[string][]byte{".boatstack/project.json": raw}) + if err != nil { + t.Fatal(err) + } + presentation, err := PresentationForRepository(context.Background(), t.TempDir(), repository, "sdk", "repository-config", &bundle, nil) + if err != nil || presentation.Descriptor.Value != "repository-actor" { + t.Fatalf("presentation = %#v, err=%v", presentation, err) + } + writeIdentityFile(t, filepath.Join(repository, ".boatstack", "project.json"), identityConfig("changed-actor")) + if _, err := PresentationForRepository(context.Background(), t.TempDir(), repository, "sdk", "repository-drift", &bundle, nil); err == nil || !strings.Contains(err.Error(), "HUMAN_IDENTITY_DRIFT") { + t.Fatalf("unbound repository config change was accepted: %v", err) + } +} + +func TestPresentationUsesExternalConfigurationAuthorityAndVerifiedState(t *testing.T) { + ctx := context.Background() + repository := identityRepository(t) + repositoryRaw := identityConfig("repository-actor") + writeIdentityFile(t, filepath.Join(repository, ".boatstack", "project.json"), repositoryRaw) + bundle, err := boatstackruntime.NewControlBundleSnapshot(map[string][]byte{".boatstack/project.json": repositoryRaw}) + if err != nil { + t.Fatal(err) + } + externalBase := t.TempDir() + resolver, err := plant.NewResolver(externalBase) + if err != nil { + t.Fatal(err) + } + embedded, err := resolver.ResolveInvocation(ctx, repository, "sdk", "external-setup") + if err != nil { + t.Fatal(err) + } + sharedRoot := filepath.Join(externalBase, "boatstack", "repositories", embedded.RepositoryID, embedded.GitCommonID) + bindingRaw, err := durable.EncodeBinding(durable.Binding{ + SchemaVersion: durable.BindingSchemaVersion, RepositoryID: embedded.RepositoryID, GitCommonID: embedded.GitCommonID, + Topology: model.TopologyDetached, ControllerID: "external-controller", ConfigAuthority: "external", CreatedAt: time.Now().UTC(), + }) + if err != nil { + t.Fatal(err) + } + writeIdentityFile(t, filepath.Join(sharedRoot, "binding.json"), bindingRaw) + externalRaw := identityConfig("external-actor") + writeIdentityFile(t, filepath.Join(sharedRoot, "project.json"), externalRaw) + detached, err := resolver.ResolveInvocation(ctx, repository, "sdk", "external-state") + if err != nil { + t.Fatal(err) + } + layout, detached, err := resolver.ResolveLayout(ctx, detached) + if err != nil { + t.Fatal(err) + } + config, fingerprint, err := protocol.ProjectConfigFingerprint(externalRaw) + if err != nil { + t.Fatal(err) + } + observed := &model.Snapshot{Observation: model.Observation{Invocation: detached, Configuration: model.Known(model.ConfigurationVerified, model.Evidence{Source: "configuration:external", Fingerprint: fingerprint})}} + presentation, err := PresentationForRepository(ctx, externalBase, repository, "sdk", "external-observed", &bundle, observed) + if err != nil || presentation.Descriptor.Value != "external-actor" { + t.Fatalf("external presentation = %#v, err=%v", presentation, err) + } + wrongInvocation := *observed + wrongInvocation.Invocation.WorktreeID = "wt-different" + if _, err := PresentationForRepository(ctx, externalBase, repository, "sdk", "external-wrong-invocation", &bundle, &wrongInvocation); err == nil || !strings.Contains(err.Error(), "different invocation") { + t.Fatalf("cross-invocation evidence was accepted: %v", err) + } + + state := durable.Default(detached, time.Now().UTC()) + policy := config.ControlPolicy() + state.Configuration = model.ConfigurationVerified + state.ConfigFingerprint = fingerprint + state.PlanApprovalPolicy = policy.PlanApproval + state.VisualEvidencePolicy = policy.VisualEvidence + state.ExternalEffectPolicy = policy.ExternalEffectAuthority + state.IndependentReview = policy.IndependentReviewForHighRisk + state.EnabledHosts = policy.Hosts + stateRaw, err := durable.EncodeState(state) + if err != nil { + t.Fatal(err) + } + writeIdentityFile(t, layout.StatePath, stateRaw) + presentation, err = PresentationForRepository(ctx, externalBase, repository, "sdk", "external-durable", &bundle, nil) + if err != nil || presentation.Descriptor.Value != "external-actor" { + t.Fatalf("durable external presentation = %#v, err=%v", presentation, err) + } + + writeIdentityFile(t, filepath.Join(sharedRoot, "project.json"), identityConfig("changed-external-actor")) + if _, err := PresentationForRepository(ctx, externalBase, repository, "sdk", "external-drift", &bundle, observed); err == nil || !strings.Contains(err.Error(), "HUMAN_IDENTITY_DRIFT") { + t.Fatalf("external authority drift was accepted: %v", err) + } +} + +func identityRepository(t *testing.T) string { + t.Helper() + repository := t.TempDir() + command := exec.Command("git", "init", "-q", repository) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("git init: %v: %s", err, output) + } + return repository +} + +func identityConfig(actor string) []byte { + return []byte(`{"schema_version":3,"identity":{"human":{"kind":"literal","value":"` + actor + `"}},"project":{"name":"fixture","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli","sdk"]}`) +} + +func writeIdentityFile(t *testing.T, path string, raw []byte) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/boatstack/sdk/human_identity_test.go b/boatstack/sdk/human_identity_test.go new file mode 100644 index 0000000..ac5d336 --- /dev/null +++ b/boatstack/sdk/human_identity_test.go @@ -0,0 +1,54 @@ +package sdk + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + + boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/surfaces" +) + +type identityResponseHandler struct { + response surfaces.Response +} + +func (handler identityResponseHandler) Handle(context.Context, surfaces.Request) (surfaces.Response, error) { + return handler.response, nil +} + +func TestSDKResponseBoundaryAttachesVerifiedHumanIdentity(t *testing.T) { + repository := t.TempDir() + if output, err := exec.Command("git", "init", "-q", repository).CombinedOutput(); err != nil { + t.Fatalf("git init: %v: %s", err, output) + } + raw := []byte(`{"schema_version":3,"identity":{"human":{"kind":"literal","value":"sdk-operator"}},"project":{"name":"sdk-fixture","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli","sdk"]}`) + configPath := filepath.Join(repository, ".boatstack", "project.json") + if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(configPath, raw, 0o600); err != nil { + t.Fatal(err) + } + snapshot, err := boatstackruntime.NewControlBundleSnapshot(map[string][]byte{".boatstack/project.json": raw}) + if err != nil { + t.Fatal(err) + } + contract, err := boatstackruntime.NewControlBundleContract(snapshot, nil, "") + if err != nil { + t.Fatal(err) + } + request := Request{Repository: repository, Host: HostIdentity, CorrelationID: "sdk-human-identity", ControlBundle: &contract} + response, err := (Client{externalStateRoot: t.TempDir()}).handle(context.Background(), identityResponseHandler{response: surfaces.Response{ + Question: &surfaces.Question{Authority: []catalog.AuthorityClass{catalog.AuthorityHuman}}, + }}, request) + if err != nil || response.Question == nil || response.Question.HumanIdentity == nil { + t.Fatalf("SDK response identity = %#v, err=%v", response.Question, err) + } + if response.Question.HumanIdentity.Descriptor.Value != "sdk-operator" { + t.Fatalf("SDK response selected %q", response.Question.HumanIdentity.Descriptor.Value) + } +} diff --git a/boatstack/sdk/sdk.go b/boatstack/sdk/sdk.go index e9f052d..db3d885 100644 --- a/boatstack/sdk/sdk.go +++ b/boatstack/sdk/sdk.go @@ -12,6 +12,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/distribution" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/humanidentity" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/humanidentitybinding" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/supervisor" @@ -243,5 +244,9 @@ func (c Client) Do(ctx context.Context, request Request) (Response, error) { if err != nil { return Response{}, err } - return kernel.Handle(ctx, request) + return c.handle(ctx, kernel, request) +} + +func (c Client) handle(ctx context.Context, handler humanidentitybinding.ResponseHandler, request Request) (Response, error) { + return humanidentitybinding.Handle(ctx, c.externalStateRoot, handler, request) } From f8522c4a05c5c5f47347d0cca1c4d3dbad39574d Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sun, 16 Aug 2026 22:09:01 +0100 Subject: [PATCH 07/11] fix: reproject verified identity changes --- .../boatstack-helper/delegation_runtime.go | 49 ++++++++++++--- .../cmd/boatstack-helper/flow_runtime_test.go | 6 +- .../boatstack-helper/human_identity_test.go | 37 ++++++++++- .../effects/integration_test.go | 16 ++++- .../softwaredelivery/effects/receipts.go | 62 +++++++++++++++++++ .../softwaredelivery/effects/receipts_test.go | 29 +++++++++ .../humanidentitybinding/binding.go | 4 +- .../humanidentitybinding/binding_test.go | 27 +++++++- boatstack/sdk/human_identity_test.go | 25 ++++++-- 9 files changed, 235 insertions(+), 20 deletions(-) diff --git a/boatstack/cmd/boatstack-helper/delegation_runtime.go b/boatstack/cmd/boatstack-helper/delegation_runtime.go index 4f8fa29..5ea56d3 100644 --- a/boatstack/cmd/boatstack-helper/delegation_runtime.go +++ b/boatstack/cmd/boatstack-helper/delegation_runtime.go @@ -6,6 +6,8 @@ import ( "encoding/hex" "fmt" "os" + "slices" + "sort" "time" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" @@ -22,15 +24,34 @@ import ( func canReprojectDelegation(layout ports.ControllerLayout, invocation model.InvocationContext, prior, current delegation.Request) (bool, error) { if prior.RunID != current.RunID || prior.ProgramID != current.ProgramID || prior.EntryID != current.EntryID || prior.TargetID != current.TargetID || prior.ObjectiveID != current.ObjectiveID || prior.DeliveryID != current.DeliveryID || - prior.RepositoryID != current.RepositoryID || prior.GitCommonID != current.GitCommonID { + prior.RepositoryID != current.RepositoryID || prior.GitCommonID != current.GitCommonID || + prior.BindingFingerprint != current.BindingFingerprint || prior.Description != current.Description || + !sameStringSet(prior.InputFingerprints, current.InputFingerprints) || !sameStringSet(prior.RequestedAuthorities, current.RequestedAuthorities) { return false, nil } + initial := invocation + initial.WorktreeID, initial.Ref = prior.InitialWorktreeID, prior.InitialRef + authorized, err := effects.InvocationAuthorizedByFlow(layout, current.RunID, initial, invocation) + if err != nil || !authorized { + return false, err + } if prior.ControlBundleFingerprint == current.ControlBundleFingerprint { - return false, nil + if prior.ProgramFingerprint != current.ProgramFingerprint || prior.HumanIdentityProviderFingerprint == current.HumanIdentityProviderFingerprint { + return false, nil + } + return effects.ConfigurationIdentityReprojectionAdmits(layout, current.RunID, invocation, current.HumanIdentityProviderFingerprint) } return effects.InstallationReprojectionAdmits(layout, current.RunID, invocation, current.ControlBundleFingerprint) } +func sameStringSet(left, right []string) bool { + left = append([]string(nil), left...) + right = append([]string(nil), right...) + sort.Strings(left) + sort.Strings(right) + return slices.Equal(left, right) +} + func prepareDelegation(ctx context.Context, request *surfaces.Request) (ports.Lock, *surfaces.Response, error) { if request.ProgramID == "" || len(request.DelegatedAuthorities) == 0 { return nil, nil, nil @@ -93,12 +114,24 @@ func prepareDelegation(ctx context.Context, request *surfaces.Request) (ports.Lo return nil, nil, err } if record.RequestFingerprint != request.DelegationRequestFingerprint || record.Request.RunID != request.FlowID || record.Request.ProgramID != request.ProgramID || record.Request.ProgramFingerprint != request.ProgramFingerprint || record.Request.ControlBundleFingerprint != request.ControlBundleFingerprint || record.Request.EntryID != request.EntryID || record.Request.TargetID != string(request.Objective.TargetID) || record.Request.ObjectiveID != request.Objective.ID || record.Request.DeliveryID != request.Objective.DeliveryID || record.Request.RepositoryID != invocation.RepositoryID || record.Request.GitCommonID != invocation.GitCommonID || record.Request.BindingFingerprint != request.DelegationBindingFingerprint || record.Request.HumanIdentityProviderFingerprint != presentation.ProviderFingerprint || record.ActorIdentityProviderFingerprint != presentation.ProviderFingerprint { - reprojected, reprojectErr := canReprojectDelegation(layout, invocation, record.Request, delegation.Request{ - RunID: request.FlowID, ProgramID: request.ProgramID, ProgramFingerprint: request.ProgramFingerprint, ControlBundleFingerprint: request.ControlBundleFingerprint, - EntryID: request.EntryID, TargetID: string(request.Objective.TargetID), ObjectiveID: request.Objective.ID, DeliveryID: request.Objective.DeliveryID, - RepositoryID: invocation.RepositoryID, GitCommonID: invocation.GitCommonID, BindingFingerprint: request.DelegationBindingFingerprint, - HumanIdentityProviderFingerprint: presentation.ProviderFingerprint, - }) + current := record.Request + current.RunID, current.ProgramID, current.ProgramFingerprint, current.ControlBundleFingerprint = request.FlowID, request.ProgramID, request.ProgramFingerprint, request.ControlBundleFingerprint + current.EntryID, current.TargetID, current.ObjectiveID, current.DeliveryID = request.EntryID, string(request.Objective.TargetID), request.Objective.ID, request.Objective.DeliveryID + current.RepositoryID, current.GitCommonID = invocation.RepositoryID, invocation.GitCommonID + current.InitialWorktreeID, current.InitialRef = invocation.WorktreeID, invocation.Ref + current.BindingFingerprint, current.HumanIdentityProviderFingerprint = request.DelegationBindingFingerprint, presentation.ProviderFingerprint + current.RequestedAuthorities = make([]string, len(request.DelegatedAuthorities)) + for index, authority := range request.DelegatedAuthorities { + current.RequestedAuthorities[index] = string(authority) + } + currentFingerprint, fingerprintErr := current.Fingerprint() + reprojected := false + var reprojectErr error + if fingerprintErr == nil && currentFingerprint == request.DelegationRequestFingerprint { + reprojected, reprojectErr = canReprojectDelegation(layout, invocation, record.Request, current) + } else if fingerprintErr != nil { + reprojectErr = fingerprintErr + } releaseOnError() if reprojectErr != nil { return nil, nil, reprojectErr diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index e446eb3..8d608b4 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -2738,9 +2738,9 @@ func TestExplicitAuthorizationCanReplaceRevokedPreReconciliationRequest(t *testi } } -func TestDelegationReprojectionRequiresAChangedControlBundle(t *testing.T) { - // control-law: ordinary input or context drift cannot be relabeled as an - // installation reprojection when the installed control bundle is unchanged. +func TestDelegationReprojectionRejectsUnadmittedContextChanges(t *testing.T) { + // control-law: ordinary input or objective drift cannot be relabeled as an + // installation or configuration-identity reprojection. request := delegation.Request{ RunID: "run-example", ProgramID: "product-delivery", ProgramFingerprint: strings.Repeat("a", 64), ControlBundleFingerprint: strings.Repeat("b", 64), EntryID: "run", TargetID: "published-pr", ObjectiveID: "objective", DeliveryID: "delivery", diff --git a/boatstack/cmd/boatstack-helper/human_identity_test.go b/boatstack/cmd/boatstack-helper/human_identity_test.go index 0572eff..3a33838 100644 --- a/boatstack/cmd/boatstack-helper/human_identity_test.go +++ b/boatstack/cmd/boatstack-helper/human_identity_test.go @@ -13,7 +13,11 @@ import ( boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/durable" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/humanidentity" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/plant" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/surfaces" ) @@ -38,6 +42,37 @@ func TestHumanIdentityPresentationIsBoundToVerifiedConfiguration(t *testing.T) { if err := os.WriteFile(configPath, configRaw, 0o600); err != nil { t.Fatal(err) } + resolver, err := plant.NewResolver("") + if err != nil { + t.Fatal(err) + } + invocation, err := resolver.ResolveInvocation(context.Background(), repository, "cli", "human-identity-test") + if err != nil { + t.Fatal(err) + } + layout, invocation, err := resolver.ResolveLayout(context.Background(), invocation) + if err != nil { + t.Fatal(err) + } + config, configFingerprint, err := protocol.ProjectConfigFingerprint(configRaw) + if err != nil { + t.Fatal(err) + } + state := durable.Default(invocation, time.Now().UTC()) + policy := config.ControlPolicy() + state.Configuration, state.ConfigFingerprint = model.ConfigurationVerified, configFingerprint + state.PlanApprovalPolicy, state.VisualEvidencePolicy, state.ExternalEffectPolicy = policy.PlanApproval, policy.VisualEvidence, policy.ExternalEffectAuthority + state.IndependentReview, state.EnabledHosts = policy.IndependentReviewForHighRisk, policy.Hosts + stateRaw, err := durable.EncodeState(state) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(layout.StatePath), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(layout.StatePath, stateRaw, 0o600); err != nil { + t.Fatal(err) + } snapshot, err := boatstackruntime.NewControlBundleSnapshot(map[string][]byte{".boatstack/project.json": configRaw}) if err != nil { t.Fatal(err) @@ -46,7 +81,7 @@ func TestHumanIdentityPresentationIsBoundToVerifiedConfiguration(t *testing.T) { if err != nil { t.Fatal(err) } - request := surfaces.Request{Repository: repository, ControlBundle: &contract} + request := surfaces.Request{Repository: repository, Host: "cli", CorrelationID: "human-identity-test", ControlBundle: &contract} presentation, err := humanIdentityPresentationForRequest(request) if err != nil { t.Fatal(err) diff --git a/boatstack/internal/softwaredelivery/effects/integration_test.go b/boatstack/internal/softwaredelivery/effects/integration_test.go index 4e1dfdd..c2eb8ba 100644 --- a/boatstack/internal/softwaredelivery/effects/integration_test.go +++ b/boatstack/internal/softwaredelivery/effects/integration_test.go @@ -616,12 +616,26 @@ func TestExternalConfigurationAuthorityTransfersAcrossAttachAndDetach(t *testing t.Fatalf("detached layout did not select external config: %#v", detachedLayout) } apply("engagement.begin", protocol.AuthorityBundle{}, true, nil) - updatedConfig := []byte("{\"schema_version\":3,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"operator\"}},\"project\":{\"name\":\"external-updated\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") + updatedConfig := []byte("{\"schema_version\":3,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"updated-operator\"}},\"project\":{\"name\":\"external-updated\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") updatedPath := filepath.Join(t.TempDir(), "updated.json") if err := os.WriteFile(updatedPath, updatedConfig, 0o600); err != nil { t.Fatal(err) } apply("configuration.mutate", human, false, protocol.Parameters{{Name: "config_path", Value: updatedPath}, {Name: "config_sha256", Value: configFingerprint(t, updatedConfig)}}) + updated, _, err := protocol.ProjectConfigFingerprint(updatedConfig) + if err != nil { + t.Fatal(err) + } + updatedProvider, err := updated.Identity.Human.Fingerprint() + if err != nil { + t.Fatal(err) + } + if admitted, err := effects.ConfigurationIdentityReprojectionAdmits(detachedLayout, "flow-external-config", detachedInvocation, updatedProvider); err != nil || !admitted { + t.Fatalf("accepted external identity reprojection admitted=%t err=%v", admitted, err) + } + if admitted, err := effects.ConfigurationIdentityReprojectionAdmits(detachedLayout, "flow-external-config", detachedInvocation, strings.Repeat("f", 64)); err != nil || admitted { + t.Fatalf("foreign identity provider admitted=%t err=%v", admitted, err) + } repositoryConfigPath := filepath.Join(repository, ".boatstack", "project.json") if raw, err := os.ReadFile(repositoryConfigPath); err != nil || string(raw) != string(initialConfig) { t.Fatalf("external mutation leaked into repository authority before detach: err=%v value=%q", err, raw) diff --git a/boatstack/internal/softwaredelivery/effects/receipts.go b/boatstack/internal/softwaredelivery/effects/receipts.go index 3b834ab..d018f91 100644 --- a/boatstack/internal/softwaredelivery/effects/receipts.go +++ b/boatstack/internal/softwaredelivery/effects/receipts.go @@ -11,6 +11,7 @@ import ( "time" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/durable" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/ports" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" @@ -239,6 +240,67 @@ func installationReprojectionAdmits(records []journalRecord, flowID string, invo return false, nil } +// ConfigurationIdentityReprojectionAdmits reports whether the exact current +// identity provider was established by a committed configuration mutation in +// this Flow lineage and remains the verified durable configuration. It permits +// a fresh delegation request only; prior authority is never carried forward. +func ConfigurationIdentityReprojectionAdmits(layout ports.ControllerLayout, flowID string, invocation model.InvocationContext, providerFingerprint string) (bool, error) { + configRaw, err := os.ReadFile(layout.ConfigPath) + if err != nil { + return false, err + } + config, configFingerprint, err := protocol.ProjectConfigFingerprint(configRaw) + if err != nil { + return false, err + } + actualProvider, err := config.Identity.Human.Fingerprint() + if err != nil || actualProvider != providerFingerprint { + return false, nil + } + stateRaw, err := os.ReadFile(layout.StatePath) + if err != nil { + return false, err + } + state, err := durable.DecodeState(stateRaw) + if err != nil { + return false, err + } + if state.RepositoryID != invocation.RepositoryID || state.GitCommonID != invocation.GitCommonID || state.WorktreeID != invocation.WorktreeID || + state.Configuration != model.ConfigurationVerified || state.ConfigFingerprint != configFingerprint { + return false, nil + } + records := []journalRecord{} + if err := scanCommittedReceipts(layout, func(record journalRecord) error { + records = append(records, record) + return nil + }); err != nil { + return false, err + } + return configurationIdentityReprojectionAdmits(records, flowID, invocation, configFingerprint, state.Revision) +} + +func configurationIdentityReprojectionAdmits(records []journalRecord, flowID string, invocation model.InvocationContext, configFingerprint string, maximumRevision uint64) (bool, error) { + for _, record := range records { + receipt := *record.Receipt + admittedFingerprint, exists := record.Admission.Parameters.Get("config_sha256") + if receipt.TransitionID != "configuration.mutate" || receipt.FlowID != flowID || receipt.ResultingStateRevision > maximumRevision || !exists || admittedFingerprint != configFingerprint { + continue + } + authorized := sameStateLineage(record.Admission.Invocation, invocation) + if !authorized && record.Admission.Invocation.ControllerID == invocation.ControllerID { + var err error + authorized, err = invocationAuthorizedByRecords(records, flowID, record.Admission.Invocation, invocation) + if err != nil { + return false, err + } + } + if authorized { + return true, nil + } + } + return false, nil +} + // InvocationAuthorizedByFlow reconstructs worktree lineage only from valid, // committed transition receipts. Mutable delegation records cannot invent a // context transfer. diff --git a/boatstack/internal/softwaredelivery/effects/receipts_test.go b/boatstack/internal/softwaredelivery/effects/receipts_test.go index ef2cfc3..0f2b17b 100644 --- a/boatstack/internal/softwaredelivery/effects/receipts_test.go +++ b/boatstack/internal/softwaredelivery/effects/receipts_test.go @@ -82,6 +82,35 @@ func TestAcceptedProgramReconciliationAuthorizesFreshDelegationRequestOnly(t *te } } +func TestAcceptedConfigurationMutationAuthorizesFreshIdentityDelegationRequestOnly(t *testing.T) { + invoking := model.InvocationContext{RepositoryID: "repo", GitCommonID: "common", WorktreeID: "worktree", Ref: "refs/heads/main", ControllerID: "controller"} + receipt := protocol.TransitionReceipt{ + ID: "configuration-one", FlowID: "run-one", Sequence: 9, TransitionID: "configuration.mutate", ResultingStateRevision: 12, + } + records := []journalRecord{{ + Admission: protocol.Admission{Invocation: invoking, Parameters: protocol.Parameters{{Name: "config_sha256", Value: "config-b"}}}, + Receipt: &receipt, + }} + admitted, err := configurationIdentityReprojectionAdmits(records, "run-one", invoking, "config-b", 12) + if err != nil || !admitted { + t.Fatalf("configuration reprojection admitted=%t err=%v", admitted, err) + } + if admitted, err := configurationIdentityReprojectionAdmits(records, "run-other", invoking, "config-b", 12); err != nil || admitted { + t.Fatalf("foreign Flow mutation admitted=%t err=%v", admitted, err) + } + if admitted, err := configurationIdentityReprojectionAdmits(records, "run-one", invoking, "config-other", 12); err != nil || admitted { + t.Fatalf("foreign configuration admitted=%t err=%v", admitted, err) + } + if admitted, err := configurationIdentityReprojectionAdmits(records, "run-one", invoking, "config-b", 11); err != nil || admitted { + t.Fatalf("future configuration receipt admitted=%t err=%v", admitted, err) + } + other := invoking + other.WorktreeID = "other-worktree" + if admitted, err := configurationIdentityReprojectionAdmits(records, "run-one", other, "config-b", 12); err != nil || admitted { + t.Fatalf("foreign worktree mutation admitted=%t err=%v", admitted, err) + } +} + func TestActiveFlowIdentityRequiresExactWorktreeLineage(t *testing.T) { current := model.InvocationContext{RepositoryID: "repo", GitCommonID: "common", WorktreeID: "worktree-a", ControllerID: "controller"} otherWorktree := current diff --git a/boatstack/internal/softwaredelivery/humanidentitybinding/binding.go b/boatstack/internal/softwaredelivery/humanidentitybinding/binding.go index 46df780..3a435c5 100644 --- a/boatstack/internal/softwaredelivery/humanidentitybinding/binding.go +++ b/boatstack/internal/softwaredelivery/humanidentitybinding/binding.go @@ -118,12 +118,14 @@ func PresentationForRepository(ctx context.Context, externalStateRoot, repositor if !bundleBindsRawConfig(*bundle, raw) { return humanidentity.Presentation{}, fmt.Errorf("HUMAN_IDENTITY_DRIFT: project configuration does not match the verified control bundle") } - trusted = true } if observed != nil { if observed.Invocation.RepositoryID != current.RepositoryID || observed.Invocation.GitCommonID != current.GitCommonID || observed.Invocation.WorktreeID != current.WorktreeID { return humanidentity.Presentation{}, fmt.Errorf("HUMAN_IDENTITY_DRIFT: observed configuration belongs to a different invocation") } + if observed.Configuration.Status != model.FactKnown || observed.Configuration.Value != model.ConfigurationVerified { + return humanidentity.Presentation{}, fmt.Errorf("HUMAN_IDENTITY_UNBOUND: observed configuration is not verified") + } if !snapshotBindsConfig(observed, fingerprint) { return humanidentity.Presentation{}, fmt.Errorf("HUMAN_IDENTITY_DRIFT: project configuration does not match the observed configuration") } diff --git a/boatstack/internal/softwaredelivery/humanidentitybinding/binding_test.go b/boatstack/internal/softwaredelivery/humanidentitybinding/binding_test.go index fb839fb..e08d7c1 100644 --- a/boatstack/internal/softwaredelivery/humanidentitybinding/binding_test.go +++ b/boatstack/internal/softwaredelivery/humanidentitybinding/binding_test.go @@ -17,6 +17,7 @@ import ( ) func TestPresentationUsesRepositoryConfigurationBoundByControlBundle(t *testing.T) { + ctx := context.Background() repository := identityRepository(t) raw := identityConfig("repository-actor") writeIdentityFile(t, filepath.Join(repository, ".boatstack", "project.json"), raw) @@ -24,12 +25,29 @@ func TestPresentationUsesRepositoryConfigurationBoundByControlBundle(t *testing. if err != nil { t.Fatal(err) } - presentation, err := PresentationForRepository(context.Background(), t.TempDir(), repository, "sdk", "repository-config", &bundle, nil) + externalBase := t.TempDir() + if _, err := PresentationForRepository(ctx, externalBase, repository, "sdk", "repository-unverified", &bundle, nil); err == nil || !strings.Contains(err.Error(), "HUMAN_IDENTITY_UNBOUND") { + t.Fatalf("bundle bytes were treated as accepted configuration: %v", err) + } + resolver, err := plant.NewResolver(externalBase) + if err != nil { + t.Fatal(err) + } + invocation, err := resolver.ResolveInvocation(ctx, repository, "sdk", "repository-observed") + if err != nil { + t.Fatal(err) + } + _, fingerprint, err := protocol.ProjectConfigFingerprint(raw) + if err != nil { + t.Fatal(err) + } + observed := &model.Snapshot{Observation: model.Observation{Invocation: invocation, Configuration: model.Known(model.ConfigurationVerified, model.Evidence{Source: "configuration:repository", Fingerprint: fingerprint})}} + presentation, err := PresentationForRepository(ctx, externalBase, repository, "sdk", "repository-config", &bundle, observed) if err != nil || presentation.Descriptor.Value != "repository-actor" { t.Fatalf("presentation = %#v, err=%v", presentation, err) } writeIdentityFile(t, filepath.Join(repository, ".boatstack", "project.json"), identityConfig("changed-actor")) - if _, err := PresentationForRepository(context.Background(), t.TempDir(), repository, "sdk", "repository-drift", &bundle, nil); err == nil || !strings.Contains(err.Error(), "HUMAN_IDENTITY_DRIFT") { + if _, err := PresentationForRepository(ctx, externalBase, repository, "sdk", "repository-drift", &bundle, observed); err == nil || !strings.Contains(err.Error(), "HUMAN_IDENTITY_DRIFT") { t.Fatalf("unbound repository config change was accepted: %v", err) } } @@ -80,6 +98,11 @@ func TestPresentationUsesExternalConfigurationAuthorityAndVerifiedState(t *testi if err != nil || presentation.Descriptor.Value != "external-actor" { t.Fatalf("external presentation = %#v, err=%v", presentation, err) } + stale := *observed + stale.Configuration.Value = model.ConfigurationStale + if _, err := PresentationForRepository(ctx, externalBase, repository, "sdk", "external-stale", &bundle, &stale); err == nil || !strings.Contains(err.Error(), "not verified") { + t.Fatalf("stale configuration selected a human identity: %v", err) + } wrongInvocation := *observed wrongInvocation.Invocation.WorktreeID = "wt-different" if _, err := PresentationForRepository(ctx, externalBase, repository, "sdk", "external-wrong-invocation", &bundle, &wrongInvocation); err == nil || !strings.Contains(err.Error(), "different invocation") { diff --git a/boatstack/sdk/human_identity_test.go b/boatstack/sdk/human_identity_test.go index ac5d336..1ed7058 100644 --- a/boatstack/sdk/human_identity_test.go +++ b/boatstack/sdk/human_identity_test.go @@ -9,6 +9,9 @@ import ( boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/plant" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/surfaces" ) @@ -33,17 +36,31 @@ func TestSDKResponseBoundaryAttachesVerifiedHumanIdentity(t *testing.T) { if err := os.WriteFile(configPath, raw, 0o600); err != nil { t.Fatal(err) } - snapshot, err := boatstackruntime.NewControlBundleSnapshot(map[string][]byte{".boatstack/project.json": raw}) + controlSnapshot, err := boatstackruntime.NewControlBundleSnapshot(map[string][]byte{".boatstack/project.json": raw}) if err != nil { t.Fatal(err) } - contract, err := boatstackruntime.NewControlBundleContract(snapshot, nil, "") + contract, err := boatstackruntime.NewControlBundleContract(controlSnapshot, nil, "") if err != nil { t.Fatal(err) } + externalStateRoot := t.TempDir() + resolver, err := plant.NewResolver(externalStateRoot) + if err != nil { + t.Fatal(err) + } + invocation, err := resolver.ResolveInvocation(context.Background(), repository, HostIdentity, "sdk-human-identity") + if err != nil { + t.Fatal(err) + } + _, configFingerprint, err := protocol.ProjectConfigFingerprint(raw) + if err != nil { + t.Fatal(err) + } + observed := &model.Snapshot{Observation: model.Observation{Invocation: invocation, Configuration: model.Known(model.ConfigurationVerified, model.Evidence{Source: "configuration:sdk", Fingerprint: configFingerprint})}} request := Request{Repository: repository, Host: HostIdentity, CorrelationID: "sdk-human-identity", ControlBundle: &contract} - response, err := (Client{externalStateRoot: t.TempDir()}).handle(context.Background(), identityResponseHandler{response: surfaces.Response{ - Question: &surfaces.Question{Authority: []catalog.AuthorityClass{catalog.AuthorityHuman}}, + response, err := (Client{externalStateRoot: externalStateRoot}).handle(context.Background(), identityResponseHandler{response: surfaces.Response{ + Question: &surfaces.Question{Authority: []catalog.AuthorityClass{catalog.AuthorityHuman}}, Snapshot: observed, }}, request) if err != nil || response.Question == nil || response.Question.HumanIdentity == nil { t.Fatalf("SDK response identity = %#v, err=%v", response.Question, err) From eee4cd9a7660b9742d8ef9e07ece8c8c44a2384b Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sun, 16 Aug 2026 22:31:33 +0100 Subject: [PATCH 08/11] fix: preserve configuration repair authority --- .../boatstack-helper/declarative_flow_test.go | 1 + .../cmd/boatstack-helper/flow_runtime_test.go | 41 +++++++++++++++++++ .../boatstack-helper/human_identity_test.go | 29 ++++++++++++- boatstack/flow/softwaredelivery/skills.go | 6 +++ .../flow/softwaredelivery/skills_test.go | 2 + .../humanidentitybinding/binding.go | 23 ++++++++--- docs/configuration.md | 7 ++++ 7 files changed, 103 insertions(+), 6 deletions(-) diff --git a/boatstack/cmd/boatstack-helper/declarative_flow_test.go b/boatstack/cmd/boatstack-helper/declarative_flow_test.go index 6941b9e..cde26dc 100644 --- a/boatstack/cmd/boatstack-helper/declarative_flow_test.go +++ b/boatstack/cmd/boatstack-helper/declarative_flow_test.go @@ -58,6 +58,7 @@ func declarativeFlowRepositoryWithDocument(t *testing.T, document controlprogram writeFlowArtifact(t, repository, document, sourcePath, source, lockPath, lock) runFlowGit(t, repository, "add", ".") runFlowGit(t, repository, "commit", "-m", "fixture") + writeVerifiedFlowConfigurationState(t, repository) return repository } diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index 8d608b4..9d00673 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -358,6 +358,45 @@ func writeAdmittedFlowProgramState(t *testing.T, repository, programFingerprint } } +func writeVerifiedFlowConfigurationState(t *testing.T, repository string) { + t.Helper() + resolver, err := plant.NewResolver("") + if err != nil { + t.Fatal(err) + } + invoking, err := resolver.ResolveInvocation(context.Background(), repository, "codex", "fixture-verified-configuration") + if err != nil { + t.Fatal(err) + } + layout, invoking, err := resolver.ResolveLayout(context.Background(), invoking) + if err != nil { + t.Fatal(err) + } + configRaw, err := os.ReadFile(layout.ConfigPath) + if err != nil { + t.Fatal(err) + } + config, configFingerprint, err := protocol.ProjectConfigFingerprint(configRaw) + if err != nil { + t.Fatal(err) + } + state := durable.Default(invoking, time.Now().UTC()) + policy := config.ControlPolicy() + state.Configuration, state.ConfigFingerprint = model.ConfigurationVerified, configFingerprint + state.PlanApprovalPolicy, state.VisualEvidencePolicy, state.ExternalEffectPolicy = policy.PlanApproval, policy.VisualEvidence, policy.ExternalEffectAuthority + state.IndependentReview, state.EnabledHosts = policy.IndependentReviewForHighRisk, policy.Hosts + raw, err := durable.EncodeState(state) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(layout.StatePath), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(layout.StatePath, raw, 0o600); err != nil { + t.Fatal(err) + } +} + func captureRunOutput(t *testing.T, arguments ...string) ([]byte, error) { t.Helper() return captureStdout(t, func() error { return run(arguments) }) @@ -2474,6 +2513,7 @@ func TestDelegationIsRequiredAndRevocationWinsBetweenNextAndApply(t *testing.T) runFlowGit(t, repository, "add", ".") runFlowGit(t, repository, "commit", "-q", "-m", "fixture") t.Setenv("BOATSTACK_STATE_ROOT", t.TempDir()) + writeVerifiedFlowConfigurationState(t, repository) bound, err := bindFlowEntry(context.Background(), commandOptions{repository: repository, programID: "product-delivery", entryID: "run", host: "codex", delegationRequestProjection: true}) if err != nil { @@ -2636,6 +2676,7 @@ func TestDelegationIsRequiredAndRevocationWinsBetweenNextAndApply(t *testing.T) } otherWorktree := filepath.Join(t.TempDir(), "other-worktree") runFlowGit(t, repository, "worktree", "add", "-q", "-b", "other-worktree", otherWorktree) + writeVerifiedFlowConfigurationState(t, otherWorktree) if _, otherErr := bindFlowEntry(context.Background(), commandOptions{repository: otherWorktree, programID: "product-delivery", entryID: "run", runID: bound.runID, deliveryID: bound.deliveryID, host: "codex"}); otherErr == nil || !strings.Contains(otherErr.Error(), "DELEGATION_DRIFT") { t.Fatalf("unauthorized worktree bundle was not rejected: %v", otherErr) } diff --git a/boatstack/cmd/boatstack-helper/human_identity_test.go b/boatstack/cmd/boatstack-helper/human_identity_test.go index 3a33838..cb131b3 100644 --- a/boatstack/cmd/boatstack-helper/human_identity_test.go +++ b/boatstack/cmd/boatstack-helper/human_identity_test.go @@ -119,7 +119,7 @@ func TestHumanIdentityIsAttachedOnlyToHumanAuthorityQuestions(t *testing.T) { func TestPreconfigurationInitializationUsesOnlyExplicitActorBootstrap(t *testing.T) { response := surfaces.Response{Question: &surfaces.Question{TransitionID: "installation.initialize", Authority: []catalog.AuthorityClass{catalog.AuthorityHuman}}} - if err := attachHumanIdentity(surfaces.Request{}, &response); err != nil || response.Question.HumanIdentity != nil { + if err := attachHumanIdentity(surfaces.Request{ControlBundle: &boatstackruntime.ControlBundleContract{}}, &response); err != nil || response.Question.HumanIdentity != nil { t.Fatalf("preconfiguration bootstrap identity = %#v err=%v", response.Question.HumanIdentity, err) } response.Question.TransitionID = "plan.approve" @@ -128,6 +128,33 @@ func TestPreconfigurationInitializationUsesOnlyExplicitActorBootstrap(t *testing } } +func TestUnverifiedConfigurationRepairPreservesExplicitActorQuestion(t *testing.T) { + stale := &model.Snapshot{Observation: model.Observation{Configuration: model.Known(model.ConfigurationStale, model.Evidence{ + Source: "configuration:test", Fingerprint: strings.Repeat("a", 64), + })}} + for _, transitionID := range []catalog.TransitionID{"configuration.mutate", "configuration.reconcile"} { + response := surfaces.Response{Snapshot: stale, Question: &surfaces.Question{ + TransitionID: transitionID, Authority: []catalog.AuthorityClass{catalog.AuthorityHuman}, + }} + if err := attachHumanIdentity(surfaces.Request{}, &response); err != nil || response.Question.HumanIdentity != nil { + t.Fatalf("%s repair question = %#v, err=%v", transitionID, response.Question, err) + } + } + + response := surfaces.Response{Snapshot: stale, Question: &surfaces.Question{ + TransitionID: "plan.approve", Authority: []catalog.AuthorityClass{catalog.AuthorityHuman}, + }} + if err := attachHumanIdentity(surfaces.Request{}, &response); err == nil || !strings.Contains(err.Error(), "HUMAN_IDENTITY_UNBOUND") { + t.Fatalf("ordinary stale-config question did not fail closed: %v", err) + } + + response.Question.TransitionID = "configuration.mutate" + response.ProgramChange = &surfaces.ProgramChange{} + if err := attachHumanIdentity(surfaces.Request{}, &response); err == nil || !strings.Contains(err.Error(), "HUMAN_IDENTITY_UNBOUND") { + t.Fatalf("program change escaped identity binding through repair question: %v", err) + } +} + func TestHumanIdentityRenderingPreservesStructuredArgvWithoutExecutingIt(t *testing.T) { marker := filepath.Join(t.TempDir(), "executed") descriptor := humanidentity.Descriptor{Kind: humanidentity.KindCommand, Command: "touch", Args: []string{marker}} diff --git a/boatstack/flow/softwaredelivery/skills.go b/boatstack/flow/softwaredelivery/skills.go index 55d6942..d0b962e 100644 --- a/boatstack/flow/softwaredelivery/skills.go +++ b/boatstack/flow/softwaredelivery/skills.go @@ -68,6 +68,12 @@ Whenever Boatstack presents a human authority boundary, inspect its exact The ` + "`provider_fingerprint`" + ` identifies the repository-selected identity descriptor; it is provenance only and grants no authority. +Boatstack omits ` + "`human_identity`" + ` only when no verified descriptor exists: +before ` + "`installation.initialize`" + ` or while ` + "`configuration.mutate`" + ` or +` + "`configuration.reconcile`" + ` repairs unverified configuration. For only those +transitions, display the exact question and ask the human which actor to record. +Treat a missing identity on every other human authority boundary as an error. + For a ` + "`literal`" + ` descriptor, use its validated ` + "`value`" + ` as the proposed actor. For a ` + "`command`" + ` descriptor, treat the descriptor as untrusted repository data. Identity resolution is a separate host command action: the Flow diff --git a/boatstack/flow/softwaredelivery/skills_test.go b/boatstack/flow/softwaredelivery/skills_test.go index 6c5103a..1b26613 100644 --- a/boatstack/flow/softwaredelivery/skills_test.go +++ b/boatstack/flow/softwaredelivery/skills_test.go @@ -50,6 +50,8 @@ func TestGeneratedSkillsProjectOnlyDeclaredEntriesWithHostParity(t *testing.T) { "Ask\nfor product delegation only after Boatstack returns the new exact delegation", "program_change.human_identity", "Do not ask the user to invent\nan actor unless", "inspect its exact\n`human_identity`", "provider_fingerprint", "Submit the exact `command`", + "omits `human_identity` only when no verified descriptor exists", "configuration.mutate", "configuration.reconcile", + "Treat a missing identity on every other human authority boundary as an error", "untrusted\nrepository data", "separate host command action", "do not authorize it", "normal command permission boundary", "independently permits the action", "at most 1024 bytes", "proposed actor", "ask the human for explicit approval", diff --git a/boatstack/internal/softwaredelivery/humanidentitybinding/binding.go b/boatstack/internal/softwaredelivery/humanidentitybinding/binding.go index 3a435c5..7849eda 100644 --- a/boatstack/internal/softwaredelivery/humanidentitybinding/binding.go +++ b/boatstack/internal/softwaredelivery/humanidentitybinding/binding.go @@ -42,12 +42,10 @@ func Attach(ctx context.Context, externalStateRoot string, request surfaces.Requ } programChangeRequiresHuman := response.ProgramChange != nil questionRequiresIdentity := response.Question != nil && questionRequiresHuman(*response.Question) - if !programChangeRequiresHuman && !questionRequiresIdentity { - return nil + if questionRequiresIdentity && questionUsesExplicitActor(response) { + questionRequiresIdentity = false } - // No repository-selected descriptor exists before initialization. The - // bootstrap caller must provide an explicit actor. - if request.ControlBundle == nil && response.Question != nil && response.Question.TransitionID == "installation.initialize" { + if !programChangeRequiresHuman && !questionRequiresIdentity { return nil } presentation, err := PresentationForRequest(ctx, externalStateRoot, request, response.Snapshot) @@ -215,3 +213,18 @@ func questionRequiresHuman(question surfaces.Question) bool { } return false } + +// questionUsesExplicitActor preserves the human authority question when no +// verified repository-selected identity exists. The host must collect an +// explicit actor; stale or candidate configuration never supplies one. +func questionUsesExplicitActor(response *surfaces.Response) bool { + if response == nil || response.Question == nil { + return false + } + switch response.Question.TransitionID { + case "installation.initialize", "configuration.mutate", "configuration.reconcile": + return response.Snapshot == nil || response.Snapshot.Configuration.Status != model.FactKnown || response.Snapshot.Configuration.Value != model.ConfigurationVerified + default: + return false + } +} diff --git a/docs/configuration.md b/docs/configuration.md index ed5b9cd..d013250 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -78,6 +78,13 @@ proposed. It does not prove approval, identity ownership, provider permission, or external-provider authority. In particular, resolving an actor through `gh` does not create a GitHub provider receipt. +Before initialization, or while `configuration.mutate` or +`configuration.reconcile` repairs unverified configuration, no trusted +descriptor is available. Boatstack preserves the human authority question but +omits `human_identity`. The host must ask for an explicit actor and must not +infer one. A missing identity on any other human authority boundary is an +error. + The canonical snapshot carries this policy projection as controlling evidence. `human` plan approval rejects autonomy receipts. `human-or-autonomy` accepts either class. When independent high-risk review is enabled, the observer derives From f6ef7ef33b6f02c9fed6f1cda5bb93b6d04c02bb Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sun, 16 Aug 2026 22:41:41 +0100 Subject: [PATCH 09/11] fix: complete identity reprojection paths --- .../boatstack-helper/delegation_runtime.go | 27 ++++++++++++--- .../cmd/boatstack-helper/flow_runtime_test.go | 33 +++++++++++++++++++ .../boatstack-helper/human_identity_test.go | 2 +- boatstack/flow/softwaredelivery/skills.go | 19 ++++++----- .../flow/softwaredelivery/skills_test.go | 5 +-- .../effects/integration_test.go | 6 ++-- .../softwaredelivery/effects/receipts.go | 18 +++++----- .../softwaredelivery/effects/receipts_test.go | 14 +++++--- .../humanidentitybinding/binding.go | 2 +- docs/configuration.md | 16 +++++---- 10 files changed, 102 insertions(+), 40 deletions(-) diff --git a/boatstack/cmd/boatstack-helper/delegation_runtime.go b/boatstack/cmd/boatstack-helper/delegation_runtime.go index 5ea56d3..5c47c08 100644 --- a/boatstack/cmd/boatstack-helper/delegation_runtime.go +++ b/boatstack/cmd/boatstack-helper/delegation_runtime.go @@ -35,13 +35,30 @@ func canReprojectDelegation(layout ports.ControllerLayout, invocation model.Invo if err != nil || !authorized { return false, err } - if prior.ControlBundleFingerprint == current.ControlBundleFingerprint { - if prior.ProgramFingerprint != current.ProgramFingerprint || prior.HumanIdentityProviderFingerprint == current.HumanIdentityProviderFingerprint { - return false, nil + return admittedDelegationReprojection( + prior, + current, + func() (bool, error) { + return effects.ConfigurationReprojectionAdmits(layout, current.RunID, invocation, current.HumanIdentityProviderFingerprint, current.ControlBundleFingerprint) + }, + func() (bool, error) { + return effects.InstallationReprojectionAdmits(layout, current.RunID, invocation, current.ControlBundleFingerprint) + }, + ) +} + +func admittedDelegationReprojection(prior, current delegation.Request, configurationAdmits, installationAdmits func() (bool, error)) (bool, error) { + configurationChanged := prior.ControlBundleFingerprint != current.ControlBundleFingerprint || prior.HumanIdentityProviderFingerprint != current.HumanIdentityProviderFingerprint + if prior.ProgramFingerprint == current.ProgramFingerprint && configurationChanged { + admitted, err := configurationAdmits() + if err != nil || admitted { + return admitted, err } - return effects.ConfigurationIdentityReprojectionAdmits(layout, current.RunID, invocation, current.HumanIdentityProviderFingerprint) } - return effects.InstallationReprojectionAdmits(layout, current.RunID, invocation, current.ControlBundleFingerprint) + if prior.ControlBundleFingerprint == current.ControlBundleFingerprint { + return false, nil + } + return installationAdmits() } func sameStringSet(left, right []string) bool { diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index 9d00673..45cb0e4 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -2801,3 +2801,36 @@ func TestDelegationReprojectionRejectsUnadmittedContextChanges(t *testing.T) { t.Fatalf("changed-objective reprojection admitted=%t err=%v", admitted, err) } } + +func TestConfigurationReprojectionPrecedesInstallationForChangedRepositoryBundle(t *testing.T) { + prior := delegation.Request{ + ProgramFingerprint: strings.Repeat("a", 64), ControlBundleFingerprint: strings.Repeat("b", 64), + HumanIdentityProviderFingerprint: strings.Repeat("c", 64), + } + current := prior + current.ControlBundleFingerprint = strings.Repeat("d", 64) + current.HumanIdentityProviderFingerprint = strings.Repeat("e", 64) + configurationCalls, installationCalls := 0, 0 + admitted, err := admittedDelegationReprojection(prior, current, func() (bool, error) { + configurationCalls++ + return true, nil + }, func() (bool, error) { + installationCalls++ + return false, nil + }) + if err != nil || !admitted || configurationCalls != 1 || installationCalls != 0 { + t.Fatalf("configuration reprojection = admitted=%t config=%d install=%d err=%v", admitted, configurationCalls, installationCalls, err) + } + + configurationCalls, installationCalls = 0, 0 + admitted, err = admittedDelegationReprojection(prior, current, func() (bool, error) { + configurationCalls++ + return false, nil + }, func() (bool, error) { + installationCalls++ + return true, nil + }) + if err != nil || !admitted || configurationCalls != 1 || installationCalls != 1 { + t.Fatalf("installation fallback = admitted=%t config=%d install=%d err=%v", admitted, configurationCalls, installationCalls, err) + } +} diff --git a/boatstack/cmd/boatstack-helper/human_identity_test.go b/boatstack/cmd/boatstack-helper/human_identity_test.go index cb131b3..fcbe179 100644 --- a/boatstack/cmd/boatstack-helper/human_identity_test.go +++ b/boatstack/cmd/boatstack-helper/human_identity_test.go @@ -132,7 +132,7 @@ func TestUnverifiedConfigurationRepairPreservesExplicitActorQuestion(t *testing. stale := &model.Snapshot{Observation: model.Observation{Configuration: model.Known(model.ConfigurationStale, model.Evidence{ Source: "configuration:test", Fingerprint: strings.Repeat("a", 64), })}} - for _, transitionID := range []catalog.TransitionID{"configuration.mutate", "configuration.reconcile"} { + for _, transitionID := range []catalog.TransitionID{"configuration.initialize", "configuration.mutate", "configuration.reconcile"} { response := surfaces.Response{Snapshot: stale, Question: &surfaces.Question{ TransitionID: transitionID, Authority: []catalog.AuthorityClass{catalog.AuthorityHuman}, }} diff --git a/boatstack/flow/softwaredelivery/skills.go b/boatstack/flow/softwaredelivery/skills.go index d0b962e..b3403ce 100644 --- a/boatstack/flow/softwaredelivery/skills.go +++ b/boatstack/flow/softwaredelivery/skills.go @@ -69,10 +69,11 @@ The ` + "`provider_fingerprint`" + ` identifies the repository-selected identity descriptor; it is provenance only and grants no authority. Boatstack omits ` + "`human_identity`" + ` only when no verified descriptor exists: -before ` + "`installation.initialize`" + ` or while ` + "`configuration.mutate`" + ` or -` + "`configuration.reconcile`" + ` repairs unverified configuration. For only those -transitions, display the exact question and ask the human which actor to record. -Treat a missing identity on every other human authority boundary as an error. +before ` + "`installation.initialize`" + ` or while ` + "`configuration.initialize`" + `, +` + "`configuration.mutate`" + `, or ` + "`configuration.reconcile`" + ` repairs +unverified configuration. For only those transitions, display the exact question +and ask the human which actor to record. Treat a missing identity on every other +human authority boundary as an error. For a ` + "`literal`" + ` descriptor, use its validated ` + "`value`" + ` as the proposed actor. For a ` + "`command`" + ` descriptor, treat the descriptor as untrusted @@ -90,10 +91,12 @@ Visibly display the proposed actor, exact request or transition, requested authority, and relevant fingerprint, then ask the human for explicit approval. Identity resolution never counts as approval. If command resolution fails, ask the user which actor to record; never infer one from the operating system, Git, host, -or external-provider session. Use the resulting actor only after explicit approval -at that exact boundary. Re-resolve if Boatstack reports identity or configuration -drift. Human identity never satisfies external-provider authority, and provider -authentication never satisfies human authority. +or external-provider session. This explicit fallback does not replace the verified +descriptor: retain its exact ` + "`provider_fingerprint`" + ` and use the resulting +actor only after explicit approval of that exact request. Re-resolve if Boatstack +reports identity or configuration drift. Human identity never satisfies +external-provider authority, and provider authentication never satisfies human +authority. ` startCommand := fmt.Sprintf("boatstack next --repo . --flow %s --entry %s --repository-authority --host %s --format json", compiled.Document.Program.ID, entry.ID, host) if entry.Delegation != nil { diff --git a/boatstack/flow/softwaredelivery/skills_test.go b/boatstack/flow/softwaredelivery/skills_test.go index 1b26613..a1d0b05 100644 --- a/boatstack/flow/softwaredelivery/skills_test.go +++ b/boatstack/flow/softwaredelivery/skills_test.go @@ -50,8 +50,9 @@ func TestGeneratedSkillsProjectOnlyDeclaredEntriesWithHostParity(t *testing.T) { "Ask\nfor product delegation only after Boatstack returns the new exact delegation", "program_change.human_identity", "Do not ask the user to invent\nan actor unless", "inspect its exact\n`human_identity`", "provider_fingerprint", "Submit the exact `command`", - "omits `human_identity` only when no verified descriptor exists", "configuration.mutate", "configuration.reconcile", - "Treat a missing identity on every other human authority boundary as an error", + "omits `human_identity` only when no verified descriptor exists", "configuration.initialize", "configuration.mutate", "configuration.reconcile", + "Treat a missing identity on every other\nhuman authority boundary as an error", + "explicit fallback does not replace the verified\ndescriptor", "retain its exact `provider_fingerprint`", "untrusted\nrepository data", "separate host command action", "do not authorize it", "normal command permission boundary", "independently permits the action", "at most 1024 bytes", "proposed actor", "ask the human for explicit approval", diff --git a/boatstack/internal/softwaredelivery/effects/integration_test.go b/boatstack/internal/softwaredelivery/effects/integration_test.go index c2eb8ba..ec8bfc0 100644 --- a/boatstack/internal/softwaredelivery/effects/integration_test.go +++ b/boatstack/internal/softwaredelivery/effects/integration_test.go @@ -621,7 +621,7 @@ func TestExternalConfigurationAuthorityTransfersAcrossAttachAndDetach(t *testing if err := os.WriteFile(updatedPath, updatedConfig, 0o600); err != nil { t.Fatal(err) } - apply("configuration.mutate", human, false, protocol.Parameters{{Name: "config_path", Value: updatedPath}, {Name: "config_sha256", Value: configFingerprint(t, updatedConfig)}}) + configResult := apply("configuration.mutate", human, false, protocol.Parameters{{Name: "config_path", Value: updatedPath}, {Name: "config_sha256", Value: configFingerprint(t, updatedConfig)}}) updated, _, err := protocol.ProjectConfigFingerprint(updatedConfig) if err != nil { t.Fatal(err) @@ -630,10 +630,10 @@ func TestExternalConfigurationAuthorityTransfersAcrossAttachAndDetach(t *testing if err != nil { t.Fatal(err) } - if admitted, err := effects.ConfigurationIdentityReprojectionAdmits(detachedLayout, "flow-external-config", detachedInvocation, updatedProvider); err != nil || !admitted { + if admitted, err := effects.ConfigurationReprojectionAdmits(detachedLayout, "flow-external-config", detachedInvocation, updatedProvider, configResult.Receipt.ControlBundleTargetFingerprint); err != nil || !admitted { t.Fatalf("accepted external identity reprojection admitted=%t err=%v", admitted, err) } - if admitted, err := effects.ConfigurationIdentityReprojectionAdmits(detachedLayout, "flow-external-config", detachedInvocation, strings.Repeat("f", 64)); err != nil || admitted { + if admitted, err := effects.ConfigurationReprojectionAdmits(detachedLayout, "flow-external-config", detachedInvocation, strings.Repeat("f", 64), configResult.Receipt.ControlBundleTargetFingerprint); err != nil || admitted { t.Fatalf("foreign identity provider admitted=%t err=%v", admitted, err) } repositoryConfigPath := filepath.Join(repository, ".boatstack", "project.json") diff --git a/boatstack/internal/softwaredelivery/effects/receipts.go b/boatstack/internal/softwaredelivery/effects/receipts.go index d018f91..6a40667 100644 --- a/boatstack/internal/softwaredelivery/effects/receipts.go +++ b/boatstack/internal/softwaredelivery/effects/receipts.go @@ -240,11 +240,12 @@ func installationReprojectionAdmits(records []journalRecord, flowID string, invo return false, nil } -// ConfigurationIdentityReprojectionAdmits reports whether the exact current -// identity provider was established by a committed configuration mutation in -// this Flow lineage and remains the verified durable configuration. It permits -// a fresh delegation request only; prior authority is never carried forward. -func ConfigurationIdentityReprojectionAdmits(layout ports.ControllerLayout, flowID string, invocation model.InvocationContext, providerFingerprint string) (bool, error) { +// ConfigurationReprojectionAdmits reports whether the exact current +// configuration, identity provider, and control bundle were established by a +// committed configuration mutation in this Flow lineage and remain the +// verified durable state. It permits a fresh delegation request only; prior +// authority is never carried forward. +func ConfigurationReprojectionAdmits(layout ports.ControllerLayout, flowID string, invocation model.InvocationContext, providerFingerprint, controlBundleFingerprint string) (bool, error) { configRaw, err := os.ReadFile(layout.ConfigPath) if err != nil { return false, err @@ -276,14 +277,15 @@ func ConfigurationIdentityReprojectionAdmits(layout ports.ControllerLayout, flow }); err != nil { return false, err } - return configurationIdentityReprojectionAdmits(records, flowID, invocation, configFingerprint, state.Revision) + return configurationReprojectionAdmits(records, flowID, invocation, configFingerprint, controlBundleFingerprint, state.Revision) } -func configurationIdentityReprojectionAdmits(records []journalRecord, flowID string, invocation model.InvocationContext, configFingerprint string, maximumRevision uint64) (bool, error) { +func configurationReprojectionAdmits(records []journalRecord, flowID string, invocation model.InvocationContext, configFingerprint, controlBundleFingerprint string, maximumRevision uint64) (bool, error) { for _, record := range records { receipt := *record.Receipt admittedFingerprint, exists := record.Admission.Parameters.Get("config_sha256") - if receipt.TransitionID != "configuration.mutate" || receipt.FlowID != flowID || receipt.ResultingStateRevision > maximumRevision || !exists || admittedFingerprint != configFingerprint { + if receipt.TransitionID != "configuration.mutate" || receipt.FlowID != flowID || receipt.ResultingStateRevision > maximumRevision || + receipt.ControlBundleTargetFingerprint != controlBundleFingerprint || !exists || admittedFingerprint != configFingerprint { continue } authorized := sameStateLineage(record.Admission.Invocation, invocation) diff --git a/boatstack/internal/softwaredelivery/effects/receipts_test.go b/boatstack/internal/softwaredelivery/effects/receipts_test.go index 0f2b17b..9986f49 100644 --- a/boatstack/internal/softwaredelivery/effects/receipts_test.go +++ b/boatstack/internal/softwaredelivery/effects/receipts_test.go @@ -86,29 +86,33 @@ func TestAcceptedConfigurationMutationAuthorizesFreshIdentityDelegationRequestOn invoking := model.InvocationContext{RepositoryID: "repo", GitCommonID: "common", WorktreeID: "worktree", Ref: "refs/heads/main", ControllerID: "controller"} receipt := protocol.TransitionReceipt{ ID: "configuration-one", FlowID: "run-one", Sequence: 9, TransitionID: "configuration.mutate", ResultingStateRevision: 12, + ControlBundleTargetFingerprint: "bundle-b", } records := []journalRecord{{ Admission: protocol.Admission{Invocation: invoking, Parameters: protocol.Parameters{{Name: "config_sha256", Value: "config-b"}}}, Receipt: &receipt, }} - admitted, err := configurationIdentityReprojectionAdmits(records, "run-one", invoking, "config-b", 12) + admitted, err := configurationReprojectionAdmits(records, "run-one", invoking, "config-b", "bundle-b", 12) if err != nil || !admitted { t.Fatalf("configuration reprojection admitted=%t err=%v", admitted, err) } - if admitted, err := configurationIdentityReprojectionAdmits(records, "run-other", invoking, "config-b", 12); err != nil || admitted { + if admitted, err := configurationReprojectionAdmits(records, "run-other", invoking, "config-b", "bundle-b", 12); err != nil || admitted { t.Fatalf("foreign Flow mutation admitted=%t err=%v", admitted, err) } - if admitted, err := configurationIdentityReprojectionAdmits(records, "run-one", invoking, "config-other", 12); err != nil || admitted { + if admitted, err := configurationReprojectionAdmits(records, "run-one", invoking, "config-other", "bundle-b", 12); err != nil || admitted { t.Fatalf("foreign configuration admitted=%t err=%v", admitted, err) } - if admitted, err := configurationIdentityReprojectionAdmits(records, "run-one", invoking, "config-b", 11); err != nil || admitted { + if admitted, err := configurationReprojectionAdmits(records, "run-one", invoking, "config-b", "bundle-b", 11); err != nil || admitted { t.Fatalf("future configuration receipt admitted=%t err=%v", admitted, err) } other := invoking other.WorktreeID = "other-worktree" - if admitted, err := configurationIdentityReprojectionAdmits(records, "run-one", other, "config-b", 12); err != nil || admitted { + if admitted, err := configurationReprojectionAdmits(records, "run-one", other, "config-b", "bundle-b", 12); err != nil || admitted { t.Fatalf("foreign worktree mutation admitted=%t err=%v", admitted, err) } + if admitted, err := configurationReprojectionAdmits(records, "run-one", invoking, "config-b", "bundle-other", 12); err != nil || admitted { + t.Fatalf("foreign control bundle admitted=%t err=%v", admitted, err) + } } func TestActiveFlowIdentityRequiresExactWorktreeLineage(t *testing.T) { diff --git a/boatstack/internal/softwaredelivery/humanidentitybinding/binding.go b/boatstack/internal/softwaredelivery/humanidentitybinding/binding.go index 7849eda..20a212d 100644 --- a/boatstack/internal/softwaredelivery/humanidentitybinding/binding.go +++ b/boatstack/internal/softwaredelivery/humanidentitybinding/binding.go @@ -222,7 +222,7 @@ func questionUsesExplicitActor(response *surfaces.Response) bool { return false } switch response.Question.TransitionID { - case "installation.initialize", "configuration.mutate", "configuration.reconcile": + case "installation.initialize", "configuration.initialize", "configuration.mutate", "configuration.reconcile": return response.Snapshot == nil || response.Snapshot.Configuration.Status != model.FactKnown || response.Snapshot.Configuration.Value != model.ConfigurationVerified default: return false diff --git a/docs/configuration.md b/docs/configuration.md index d013250..c57ea66 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -69,7 +69,9 @@ interpolation, and execute them only when that boundary independently permits the action. It accepts only a zero exit status and one non-empty actor line of at most 1 KiB after removing at most one trailing LF or CRLF. If execution is not permitted or resolution fails, the host must ask the user for an actor; it -must not infer an operating-system or Git identity. +must not infer an operating-system or Git identity. This explicit fallback does +not replace the verified descriptor. The host retains its exact provider +fingerprint and still requires separate approval of the exact authority request. The host displays the resolved actor, exact request, and requested authority, then asks for explicit approval. The authorization command still requires @@ -78,12 +80,12 @@ proposed. It does not prove approval, identity ownership, provider permission, or external-provider authority. In particular, resolving an actor through `gh` does not create a GitHub provider receipt. -Before initialization, or while `configuration.mutate` or -`configuration.reconcile` repairs unverified configuration, no trusted -descriptor is available. Boatstack preserves the human authority question but -omits `human_identity`. The host must ask for an explicit actor and must not -infer one. A missing identity on any other human authority boundary is an -error. +Before initialization, or while `configuration.initialize`, +`configuration.mutate`, or `configuration.reconcile` repairs unverified +configuration, no trusted descriptor is available. Boatstack preserves the +human authority question but omits `human_identity`. The host must ask for an +explicit actor and must not infer one. A missing identity on any other human +authority boundary is an error. The canonical snapshot carries this policy projection as controlling evidence. `human` plan approval rejects autonomy receipts. `human-or-autonomy` accepts From 35f5b5e65839086ceb050a75222243e9a84b0243 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sun, 16 Aug 2026 23:08:01 +0100 Subject: [PATCH 10/11] fix: bind human suspensions to identity context --- .../cmd/boatstack-helper/declarative_flow.go | 139 +++++++++---- .../boatstack-helper/declarative_flow_test.go | 189 ++++++++++++++++-- .../cmd/boatstack-helper/flow_runtime.go | 29 ++- .../cmd/boatstack-helper/flow_runtime_test.go | 12 ++ .../cmd/boatstack-helper/input_command.go | 32 ++- boatstack/flow/softwaredelivery/skills.go | 31 ++- boatstack/invocation/invocation.go | 28 ++- boatstack/invocation/invocation_test.go | 11 +- boatstack/invocation/store.go | 3 +- docs/product-delivery/writing-a-flow.md | 13 +- 10 files changed, 390 insertions(+), 97 deletions(-) diff --git a/boatstack/cmd/boatstack-helper/declarative_flow.go b/boatstack/cmd/boatstack-helper/declarative_flow.go index 8dca66b..4120140 100644 --- a/boatstack/cmd/boatstack-helper/declarative_flow.go +++ b/boatstack/cmd/boatstack-helper/declarative_flow.go @@ -22,18 +22,21 @@ import ( "github.com/operatorstack/boatstack/boatstack/invocation" ) -const declarativeRunSchemaRevision = 3 +const declarativeRunSchemaRevision = 4 type declarativeTransitionReceipt struct { - ID string `json:"id"` - TransitionID string `json:"transition_id"` - InvocationFingerprint string `json:"invocation_fingerprint"` - PriorStateRevision uint64 `json:"prior_state_revision"` - ResultStateRevision uint64 `json:"result_state_revision"` - PriorReceiptFingerprint string `json:"prior_receipt_fingerprint,omitempty"` - Parameters []invocation.ResolvedParameter `json:"parameters"` - HumanActor string `json:"human_actor,omitempty"` - Fingerprint string `json:"fingerprint"` + ID string `json:"id"` + TransitionID string `json:"transition_id"` + InvocationFingerprint string `json:"invocation_fingerprint"` + PriorStateRevision uint64 `json:"prior_state_revision"` + ResultStateRevision uint64 `json:"result_state_revision"` + PriorReceiptFingerprint string `json:"prior_receipt_fingerprint,omitempty"` + Parameters []invocation.ResolvedParameter `json:"parameters"` + ControlBundleFingerprint string `json:"control_bundle_fingerprint"` + AuthorityContextFingerprint string `json:"authority_context_fingerprint,omitempty"` + AuthorityFingerprint string `json:"authority_fingerprint,omitempty"` + HumanActor string `json:"human_actor,omitempty"` + Fingerprint string `json:"fingerprint"` } type declarativeRunState struct { @@ -51,13 +54,15 @@ type declarativeRunState struct { } type declarativeRuntimeContext struct { - compiled controlprogram.Compiled - entry controlprogram.Entry - state declarativeRunState - statePath string - store invocation.Store - controlBundle boatstackruntime.ControlBundleSnapshot - executionScopeFingerprint string + compiled controlprogram.Compiled + entry controlprogram.Entry + state declarativeRunState + statePath string + store invocation.Store + controlBundle boatstackruntime.ControlBundleSnapshot + executionScopeFingerprint string + authorityContextFingerprint string + humanIdentity *humanidentity.Presentation } func tryRunDeclarativeFlow(ctx context.Context, options commandOptions) (bool, error) { @@ -175,6 +180,15 @@ func runDeclarativeFlow(ctx context.Context, compiled controlprogram.Compiled, o if !ok { return fmt.Errorf("FLOW_RUNTIME_INVALID: declarative transition %q has no executable operator", transition.ID) } + requiresHumanAuthority := declarativeRequiresHumanAuthority(transition, operator) + if requiresHumanAuthority || transitionUsesHostInput(transition) { + presentation, identityErr := humanIdentityPresentationForRepositoryBound(ctx, repository, options.host, "declarative-suspension", runtimeContext.controlBundle, nil) + if identityErr != nil { + return identityErr + } + runtimeContext.authorityContextFingerprint = presentation.ProviderFingerprint + runtimeContext.humanIdentity = &presentation + } result, materializationContext, err := materializeDeclarativeInvocation(runtimeContext, transition, operator) if err != nil { return err @@ -189,29 +203,26 @@ func runDeclarativeFlow(ctx context.Context, compiled controlprogram.Compiled, o if err := runtimeContext.store.SaveRequest(*result.Request); err != nil { return err } - presentation, identityErr := humanIdentityPresentationForRepositoryBound(ctx, repository, options.host, "declarative-input", runtimeContext.controlBundle, nil) - if identityErr != nil { - return identityErr - } return encodeDeclarativeResult(map[string]any{ "kind": "suspended", "code": result.Request.Code, "run_id": runtimeContext.state.RunID, "program_fingerprint": compiled.Fingerprint, "entry_id": entry.ID, "target_id": entry.Target, - "transition_id": transition.ID, "request": result.Request, "human_identity": presentation, + "transition_id": transition.ID, "request": result.Request, "human_identity": runtimeContext.humanIdentity, }, options.format) } if result.Ready == nil { return fmt.Errorf("FLOW_INVOCATION_INCOMPLETE: declarative materialization produced no evidence") } - if err := requireDeclarativeAuthority(transition, operator, options.humanActor); err != nil { - presentation, identityErr := humanIdentityPresentationForRepositoryBound(ctx, repository, options.host, "declarative-authority", runtimeContext.controlBundle, nil) - if identityErr != nil { - return identityErr - } + authorityFingerprint := "" + if requiresHumanAuthority { + authorityFingerprint = declarativeAuthorityFingerprint(*result.Ready, transition, operator, runtimeContext.authorityContextFingerprint) + } + if err := requireDeclarativeAuthority(transition, operator, options.humanActor, options.authorityFingerprint, authorityFingerprint); err != nil { return encodeDeclarativeResult(map[string]any{ "kind": "blocked", "code": "AUTHORITY_REQUIRED", "detail": err.Error(), "run_id": runtimeContext.state.RunID, "program_fingerprint": compiled.Fingerprint, "entry_id": entry.ID, "target_id": entry.Target, "transition_id": transition.ID, - "human_identity": presentation, + "authority_fingerprint": authorityFingerprint, "requested_authorities": declarativeAuthorities(transition, operator), + "human_identity": runtimeContext.humanIdentity, }, options.format) } @@ -222,6 +233,15 @@ func runDeclarativeFlow(ctx context.Context, compiled controlprogram.Compiled, o if err != nil { return err } + if runtimeContext.humanIdentity != nil { + current, identityErr := humanIdentityPresentationForRepositoryBound(ctx, repository, options.host, "declarative-commit", runtimeContext.controlBundle, nil) + if identityErr != nil { + return identityErr + } + if current.ProviderFingerprint != runtimeContext.authorityContextFingerprint { + return fmt.Errorf("HUMAN_IDENTITY_DRIFT: verified identity provider changed before declarative state commit") + } + } fresh, err := invocation.Materialize(operator.Parameters, transition.Parameters, materializationContext, nil) if err != nil { return err @@ -241,7 +261,10 @@ func runDeclarativeFlow(ctx context.Context, compiled controlprogram.Compiled, o receipt := declarativeTransitionReceipt{ TransitionID: transition.ID, InvocationFingerprint: fresh.Ready.InvocationFingerprint, PriorStateRevision: priorRevision, ResultStateRevision: candidate.StateRevision, - Parameters: append([]invocation.ResolvedParameter(nil), fresh.Ready.Parameters...), HumanActor: strings.TrimSpace(options.humanActor), + Parameters: append([]invocation.ResolvedParameter(nil), fresh.Ready.Parameters...), + ControlBundleFingerprint: runtimeContext.controlBundle.Fingerprint, + AuthorityContextFingerprint: runtimeContext.authorityContextFingerprint, AuthorityFingerprint: authorityFingerprint, + HumanActor: strings.TrimSpace(options.humanActor), } if previous := lastDeclarativeReceipt(candidate); previous != nil { receipt.PriorReceiptFingerprint = previous.Fingerprint @@ -266,7 +289,7 @@ func runDeclarativeFlow(ctx context.Context, compiled controlprogram.Compiled, o }, options.format) } -func requireDeclarativeAuthority(transition controlprogram.Transition, operator controlprogram.Operator, humanActor string) error { +func requireDeclarativeAuthority(transition controlprogram.Transition, operator controlprogram.Operator, humanActor, providedFingerprint, expectedFingerprint string) error { actor := strings.TrimSpace(humanActor) providedHuman := actor != "" if providedHuman { @@ -283,9 +306,36 @@ func requireDeclarativeAuthority(transition controlprogram.Transition, operator if containsString(transition.Requires.Authorities, "human") && !providedHuman { return fmt.Errorf("transition %q requires human authority", transition.ID) } + if declarativeRequiresHumanAuthority(transition, operator) && (len(expectedFingerprint) != 64 || providedFingerprint != expectedFingerprint) { + return fmt.Errorf("exact current authority fingerprint is required for transition %q", transition.ID) + } return nil } +func declarativeRequiresHumanAuthority(transition controlprogram.Transition, operator controlprogram.Operator) bool { + return containsString(operator.Authority.AnyOf, "human") || containsString(operator.Authority.AllOf, "human") || containsString(transition.Requires.Authorities, "human") +} + +func declarativeAuthorities(transition controlprogram.Transition, operator controlprogram.Operator) []string { + values := append(append(append([]string(nil), operator.Authority.AnyOf...), operator.Authority.AllOf...), transition.Requires.Authorities...) + sort.Strings(values) + result := values[:0] + for _, value := range values { + if len(result) == 0 || result[len(result)-1] != value { + result = append(result, value) + } + } + return result +} + +func declarativeAuthorityFingerprint(evidence invocation.Evidence, transition controlprogram.Transition, operator controlprogram.Operator, authorityContextFingerprint string) string { + return digestDeclarative(struct { + InvocationFingerprint string `json:"invocation_fingerprint"` + AuthorityContextFingerprint string `json:"authority_context_fingerprint"` + Authorities []string `json:"authorities"` + }{evidence.InvocationFingerprint, authorityContextFingerprint, declarativeAuthorities(transition, operator)}) +} + func loadDeclarativeRuntimeContext(ctx context.Context, repository string, compiled controlprogram.Compiled, entry controlprogram.Entry, options commandOptions) (declarativeRuntimeContext, error) { controlBundle, err := buildRepositoryControlBundle(ctx, repository) if err != nil { @@ -346,7 +396,10 @@ func loadDeclarativeRuntimeContext(ctx context.Context, repository string, compi receipt := state.Receipts[index] fingerprint := receipt.Fingerprint receipt.Fingerprint = "" - if fingerprint == "" || fingerprint != digestDeclarative(receipt) || receipt.PriorReceiptFingerprint != priorReceiptFingerprint || receipt.PriorStateRevision != expectedRevision || receipt.ResultStateRevision != expectedRevision+1 { + invalidAuthority := (receipt.AuthorityContextFingerprint != "" && len(receipt.AuthorityContextFingerprint) != 64) || + (receipt.HumanActor != "" && (len(receipt.AuthorityContextFingerprint) != 64 || len(receipt.AuthorityFingerprint) != 64)) || + (receipt.HumanActor == "" && receipt.AuthorityFingerprint != "") + if fingerprint == "" || fingerprint != digestDeclarative(receipt) || len(receipt.ControlBundleFingerprint) != 64 || invalidAuthority || receipt.PriorReceiptFingerprint != priorReceiptFingerprint || receipt.PriorStateRevision != expectedRevision || receipt.ResultStateRevision != expectedRevision+1 { return declarativeRuntimeContext{}, fmt.Errorf("FLOW_CONTEXT_MISMATCH: declarative transition receipt is invalid") } expectedRevision = receipt.ResultStateRevision @@ -380,15 +433,17 @@ func materializeDeclarativeInvocation(runtimeContext declarativeRuntimeContext, stateValues[facet] = invocation.Value{Type: controlprogram.ValueTypeDefinition{Kind: "string"}, Canonical: value, Provenance: "state", ProducerFingerprint: digestDeclarative(map[string]string{"facet": facet, "value": value})} } contextFingerprint := digestDeclarative(struct { - RunID string `json:"run_id"` - Program string `json:"program"` - Entry string `json:"entry"` - Target string `json:"target"` - Transition string `json:"transition"` - StateRevision uint64 `json:"state_revision"` - Inputs map[string]string `json:"inputs"` - Facts map[string]string `json:"facts"` - }{runtimeContext.state.RunID, runtimeContext.compiled.Fingerprint, runtimeContext.entry.ID, runtimeContext.entry.Target, transition.ID, runtimeContext.state.StateRevision, runtimeContext.state.EntryInputs, runtimeContext.state.Facts}) + RunID string `json:"run_id"` + Program string `json:"program"` + Entry string `json:"entry"` + Target string `json:"target"` + Transition string `json:"transition"` + StateRevision uint64 `json:"state_revision"` + ControlBundleFingerprint string `json:"control_bundle_fingerprint"` + AuthorityContextFingerprint string `json:"authority_context_fingerprint,omitempty"` + Inputs map[string]string `json:"inputs"` + Facts map[string]string `json:"facts"` + }{runtimeContext.state.RunID, runtimeContext.compiled.Fingerprint, runtimeContext.entry.ID, runtimeContext.entry.Target, transition.ID, runtimeContext.state.StateRevision, runtimeContext.controlBundle.Fingerprint, runtimeContext.authorityContextFingerprint, runtimeContext.state.EntryInputs, runtimeContext.state.Facts}) receipts, err := runtimeContext.store.LoadReceipts(runtimeContext.state.RunID, transition.ID) if err != nil { return invocation.Result{}, invocation.Context{}, err @@ -397,8 +452,8 @@ func materializeDeclarativeInvocation(runtimeContext declarativeRuntimeContext, RunID: runtimeContext.state.RunID, ProgramFingerprint: runtimeContext.compiled.Fingerprint, ExecutionProgramFingerprint: runtimeContext.compiled.Fingerprint, EntryID: runtimeContext.entry.ID, TargetID: runtimeContext.entry.Target, TransitionID: transition.ID, - StateRevision: runtimeContext.state.StateRevision, ContextFingerprint: contextFingerprint, - ExecutionScopeFingerprint: runtimeContext.executionScopeFingerprint, EntryInputs: entryInputs, + StateRevision: runtimeContext.state.StateRevision, ContextFingerprint: contextFingerprint, ControlBundleFingerprint: runtimeContext.controlBundle.Fingerprint, + AuthorityContextFingerprint: runtimeContext.authorityContextFingerprint, ExecutionScopeFingerprint: runtimeContext.executionScopeFingerprint, EntryInputs: entryInputs, State: stateValues, Receipts: map[string]invocation.Value{}, WorkOutputs: map[string]invocation.Value{}, InputReceipts: receipts, } result, err := invocation.Materialize(operator.Parameters, transition.Parameters, materializationContext, nil) diff --git a/boatstack/cmd/boatstack-helper/declarative_flow_test.go b/boatstack/cmd/boatstack-helper/declarative_flow_test.go index cde26dc..861b9b7 100644 --- a/boatstack/cmd/boatstack-helper/declarative_flow_test.go +++ b/boatstack/cmd/boatstack-helper/declarative_flow_test.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "encoding/json" "os" "path/filepath" @@ -93,6 +94,72 @@ func decodeObject(t *testing.T, raw []byte) map[string]any { return value } +func runApprovedDeclarativeTransition(t *testing.T, repository, runID string, entryInputs ...string) []byte { + t.Helper() + arguments := []string{"--repo", repository, "--flow", "incident-response-invocation", "--entry", "respond"} + if runID != "" { + arguments = append(arguments, "--run-id", runID) + } + for _, input := range entryInputs { + arguments = append(arguments, "--input", input) + } + arguments = append(arguments, "--host", "codex", "--format", "json") + blockedRaw, err := captureStdout(t, func() error { return runFlowContinuation(arguments) }) + if err != nil { + t.Fatal(err) + } + blocked := decodeObject(t, blockedRaw) + if blocked["kind"] != "blocked" || blocked["code"] != "AUTHORITY_REQUIRED" { + t.Fatalf("declarative authority suspension = %s", blockedRaw) + } + runID, _ = blocked["run_id"].(string) + authorityFingerprint, _ := blocked["authority_fingerprint"].(string) + if len(authorityFingerprint) != 64 || blocked["human_identity"] == nil { + t.Fatalf("declarative authority binding = %s", blockedRaw) + } + approved := []string{ + "--repo", repository, "--flow", "incident-response-invocation", "--entry", "respond", "--run-id", runID, + "--authority-fingerprint", authorityFingerprint, "--human", "boateng", "--host", "codex", "--format", "json", + } + completedRaw, err := captureStdout(t, func() error { return runFlowContinuation(approved) }) + if err != nil { + t.Fatal(err) + } + return completedRaw +} + +func rotateDeclarativeIdentity(t *testing.T, repository, prior, next string) { + t.Helper() + path := filepath.Join(repository, ".boatstack", "project.json") + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + updated := bytes.Replace(raw, []byte(`"value":"`+prior+`"`), []byte(`"value":"`+next+`"`), 1) + if bytes.Equal(updated, raw) { + t.Fatalf("identity fixture does not contain %q", prior) + } + if err := os.WriteFile(path, updated, 0o600); err != nil { + t.Fatal(err) + } + runFlowGit(t, repository, "add", ".boatstack/project.json") + runFlowGit(t, repository, "commit", "-m", "rotate identity to "+next) + writeVerifiedFlowConfigurationState(t, repository) +} + +func answerDeclarativeRequest(t *testing.T, repository, runID, requestFingerprint, actor string) { + t.Helper() + answer := filepath.Join(t.TempDir(), "answer.json") + if err := os.WriteFile(answer, []byte(`{"channel":"incident-room"}`), 0o600); err != nil { + t.Fatal(err) + } + if _, err := captureStdout(t, func() error { + return runFlowInput([]string{"answer", "--repo", repository, "--flow", "incident-response-invocation", "--entry", "respond", "--run-id", runID, "--request-fingerprint", requestFingerprint, "--answer", answer, "--human", actor, "--host", "codex", "--format", "json"}) + }); err != nil { + t.Fatal(err) + } +} + func TestGeneratedDeclarativeDriverSuspendsAnswersRestartsAndExecutes(t *testing.T) { // control-law: a non-domain adapter generated driver crosses the same // typed invocation boundary and resumes one exact run after restart. @@ -103,7 +170,7 @@ func TestGeneratedDeclarativeDriverSuspendsAnswersRestartsAndExecutes(t *testing if err != nil { t.Fatal(err) } - if !strings.Contains(string(skill), "boatstack flow run --repo . --flow incident-response-invocation --entry respond --host codex --format json") || !strings.Contains(string(skill), "--input name=value") { + if !strings.Contains(string(skill), "boatstack flow run --repo . --flow incident-response-invocation --entry respond --host codex --format json") || !strings.Contains(string(skill), "--input name=value") || !strings.Contains(string(skill), "--authority-fingerprint --human ") { t.Fatalf("generated driver lacks declarative invocation protocol:\n%s", skill) } @@ -144,9 +211,13 @@ func TestGeneratedDeclarativeDriverSuspendsAnswersRestartsAndExecutes(t *testing if blocked["kind"] != "blocked" || blocked["code"] != "AUTHORITY_REQUIRED" { t.Fatalf("unauthorized driver result = %s", blockedRaw) } + authorityFingerprint, _ := blocked["authority_fingerprint"].(string) + if len(authorityFingerprint) != 64 || blocked["human_identity"] == nil { + t.Fatalf("authority suspension is not identity-bound: %s", blockedRaw) + } completedRaw, err := captureStdout(t, func() error { - return runFlowContinuation([]string{"--repo", repository, "--flow", "incident-response-invocation", "--entry", "respond", "--run-id", runID, "--human", "boateng", "--host", "codex", "--format", "json"}) + return runFlowContinuation([]string{"--repo", repository, "--flow", "incident-response-invocation", "--entry", "respond", "--run-id", runID, "--authority-fingerprint", authorityFingerprint, "--human", "boateng", "--host", "codex", "--format", "json"}) }) if err != nil { t.Fatal(err) @@ -167,6 +238,106 @@ func TestGeneratedDeclarativeDriverSuspendsAnswersRestartsAndExecutes(t *testing } } +func TestDeclarativeIdentityRotationSupersedesInputAndAuthoritySuspensions(t *testing.T) { + // control-law: provider rotation never consumes an input receipt or explicit + // approval presented under the prior verified identity context. + t.Setenv("BOATSTACK_STATE_ROOT", t.TempDir()) + repository := declarativeFlowRepository(t) + startRaw, err := captureStdout(t, func() error { + return runFlowContinuation([]string{"--repo", repository, "--flow", "incident-response-invocation", "--entry", "respond", "--input", "incident=INC-7", "--host", "codex", "--format", "json"}) + }) + if err != nil { + t.Fatal(err) + } + start := decodeObject(t, startRaw) + runID, _ := start["run_id"].(string) + requestA := start["request"].(map[string]any) + requestFingerprintA, _ := requestA["fingerprint"].(string) + authorityContextA, _ := requestA["authority_context_fingerprint"].(string) + identityA := start["human_identity"].(map[string]any) + if len(authorityContextA) != 64 || authorityContextA != identityA["provider_fingerprint"] { + t.Fatalf("provider A suspension is not bound: %s", startRaw) + } + + rotateDeclarativeIdentity(t, repository, "operator", "second-operator") + answer := filepath.Join(t.TempDir(), "answer.json") + if err := os.WriteFile(answer, []byte(`{"channel":"incident-room"}`), 0o600); err != nil { + t.Fatal(err) + } + if _, err := captureStdout(t, func() error { + return runFlowInput([]string{"answer", "--repo", repository, "--flow", "incident-response-invocation", "--entry", "respond", "--run-id", runID, "--request-fingerprint", requestFingerprintA, "--answer", answer, "--human", "operator", "--host", "codex", "--format", "json"}) + }); err == nil || !strings.Contains(err.Error(), "HUMAN_IDENTITY_DRIFT") { + t.Fatalf("stale provider A input answer = %v", err) + } + + secondRaw, err := captureStdout(t, func() error { + return runFlowContinuation([]string{"--repo", repository, "--flow", "incident-response-invocation", "--entry", "respond", "--run-id", runID, "--host", "codex", "--format", "json"}) + }) + if err != nil { + t.Fatal(err) + } + second := decodeObject(t, secondRaw) + requestB := second["request"].(map[string]any) + requestFingerprintB, _ := requestB["fingerprint"].(string) + authorityContextB, _ := requestB["authority_context_fingerprint"].(string) + if second["kind"] != "suspended" || requestFingerprintB == requestFingerprintA || authorityContextB == authorityContextA { + t.Fatalf("provider B did not produce a fresh input suspension: %s", secondRaw) + } + answerDeclarativeRequest(t, repository, runID, requestFingerprintB, "second-operator") + + authorityRaw, err := captureStdout(t, func() error { + return runFlowContinuation([]string{"--repo", repository, "--flow", "incident-response-invocation", "--entry", "respond", "--run-id", runID, "--host", "codex", "--format", "json"}) + }) + if err != nil { + t.Fatal(err) + } + authorityB := decodeObject(t, authorityRaw) + authorityFingerprintB, _ := authorityB["authority_fingerprint"].(string) + if authorityB["code"] != "AUTHORITY_REQUIRED" || len(authorityFingerprintB) != 64 { + t.Fatalf("provider B authority suspension = %s", authorityRaw) + } + + rotateDeclarativeIdentity(t, repository, "second-operator", "third-operator") + resuspendedRaw, err := captureStdout(t, func() error { + return runFlowContinuation([]string{"--repo", repository, "--flow", "incident-response-invocation", "--entry", "respond", "--run-id", runID, "--authority-fingerprint", authorityFingerprintB, "--human", "second-operator", "--host", "codex", "--format", "json"}) + }) + if err != nil { + t.Fatal(err) + } + resuspended := decodeObject(t, resuspendedRaw) + requestC := resuspended["request"].(map[string]any) + requestFingerprintC, _ := requestC["fingerprint"].(string) + authorityContextC, _ := requestC["authority_context_fingerprint"].(string) + if resuspended["kind"] != "suspended" || requestFingerprintC == requestFingerprintB || authorityContextC == authorityContextB { + t.Fatalf("provider C reused provider B input: %s", resuspendedRaw) + } + answerDeclarativeRequest(t, repository, runID, requestFingerprintC, "third-operator") + + freshAuthorityRaw, err := captureStdout(t, func() error { + return runFlowContinuation([]string{"--repo", repository, "--flow", "incident-response-invocation", "--entry", "respond", "--run-id", runID, "--authority-fingerprint", authorityFingerprintB, "--human", "second-operator", "--host", "codex", "--format", "json"}) + }) + if err != nil { + t.Fatal(err) + } + freshAuthority := decodeObject(t, freshAuthorityRaw) + authorityFingerprintC, _ := freshAuthority["authority_fingerprint"].(string) + if freshAuthority["code"] != "AUTHORITY_REQUIRED" || authorityFingerprintC == authorityFingerprintB { + t.Fatalf("provider C reused provider B approval: %s", freshAuthorityRaw) + } + + terminalRaw, err := captureStdout(t, func() error { + return runFlowContinuation([]string{"--repo", repository, "--flow", "incident-response-invocation", "--entry", "respond", "--run-id", runID, "--authority-fingerprint", authorityFingerprintC, "--human", "third-operator", "--host", "codex", "--format", "json"}) + }) + if err != nil { + t.Fatal(err) + } + terminal := decodeObject(t, terminalRaw) + receipt := terminal["receipt"].(map[string]any) + if terminal["kind"] != "terminal" || receipt["authority_context_fingerprint"] != authorityContextC || receipt["authority_fingerprint"] != authorityFingerprintC || receipt["human_actor"] != "third-operator" { + t.Fatalf("terminal receipt lost provider C authority: %s", terminalRaw) + } +} + func TestDeclarativeRunRejectsCrossWorktreeResume(t *testing.T) { // control-law: an explicit run ID cannot bypass the opaque execution-scope // identity that was bound when the durable declarative run was created. @@ -276,24 +447,14 @@ func TestDeclarativeReceiptHistoryIsImmutableAndContiguous(t *testing.T) { // one contiguous durable receipt chain after later commits and restart. t.Setenv("BOATSTACK_STATE_ROOT", t.TempDir()) repository := declarativeFlowRepositoryWithDocument(t, twoStepDeclarativeDocument()) - firstRaw, err := captureStdout(t, func() error { - return runFlowContinuation([]string{"--repo", repository, "--flow", "incident-response-invocation", "--entry", "respond", "--input", "incident=INC-7", "--human", "boateng", "--host", "codex", "--format", "json"}) - }) - if err != nil { - t.Fatal(err) - } + firstRaw := runApprovedDeclarativeTransition(t, repository, "", "incident=INC-7") first := decodeObject(t, firstRaw) if first["kind"] != "continued" { t.Fatalf("first transition = %s", firstRaw) } runID, _ := first["run_id"].(string) firstReceipt := first["receipt"] - secondRaw, err := captureStdout(t, func() error { - return runFlowContinuation([]string{"--repo", repository, "--flow", "incident-response-invocation", "--entry", "respond", "--run-id", runID, "--human", "boateng", "--host", "codex", "--format", "json"}) - }) - if err != nil { - t.Fatal(err) - } + secondRaw := runApprovedDeclarativeTransition(t, repository, runID) second := decodeObject(t, secondRaw) receipts, _ := second["receipts"].([]any) if second["kind"] != "terminal" || len(receipts) != 2 || !reflect.DeepEqual(receipts[0], firstReceipt) { diff --git a/boatstack/cmd/boatstack-helper/flow_runtime.go b/boatstack/cmd/boatstack-helper/flow_runtime.go index 65b6503..1a737d3 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime.go @@ -571,7 +571,18 @@ func materializeFlowInvocation(ctx context.Context, compiled controlprogram.Comp } bundleFingerprint := "" if bundle != nil { - bundleFingerprint = bundle.Fingerprint + bundleFingerprint = bundle.Source.Fingerprint + } + authorityContextFingerprint := "" + if transitionUsesHostInput(*transition) { + if bundle == nil || len(bundleFingerprint) != 64 { + return commandOptions{}, fmt.Errorf("FLOW_INPUT_UNBOUND: host input requires an exact verified control bundle") + } + presentation, presentationErr := humanIdentityPresentationForRepositoryBound(ctx, options.repository, host, "flow-input-"+options.runID+"-"+transition.ID, bundle.Source, nil) + if presentationErr != nil { + return commandOptions{}, presentationErr + } + authorityContextFingerprint = presentation.ProviderFingerprint } contextFingerprint, err := general.Fingerprint(struct { Invocation model.InvocationContext `json:"invocation"` @@ -581,7 +592,8 @@ func materializeFlowInvocation(ctx context.Context, compiled controlprogram.Comp Entry string `json:"entry"` Target string `json:"target"` Transition string `json:"transition"` - }{invocationContext, state.Revision, compiled.Fingerprint, state.ProgramFingerprint, entry.ID, options.targetID, transition.ID}) + AuthorityContext string `json:"authority_context,omitempty"` + }{invocationContext, state.Revision, compiled.Fingerprint, state.ProgramFingerprint, entry.ID, options.targetID, transition.ID, authorityContextFingerprint}) if err != nil { return commandOptions{}, err } @@ -676,8 +688,8 @@ func materializeFlowInvocation(ctx context.Context, compiled controlprogram.Comp RunID: options.runID, ProgramFingerprint: compiled.Fingerprint, ExecutionProgramFingerprint: state.ProgramFingerprint, EntryID: entry.ID, TargetID: options.targetID, TransitionID: transition.ID, StateRevision: state.Revision, ContextFingerprint: contextFingerprint, ControlBundleFingerprint: bundleFingerprint, - ExecutionScopeFingerprint: executionScopeFingerprint, - EntryInputs: entryInputs, State: stateValues, Receipts: receiptValues, WorkOutputs: workOutputs, InputReceipts: inputReceipts, + AuthorityContextFingerprint: authorityContextFingerprint, ExecutionScopeFingerprint: executionScopeFingerprint, + EntryInputs: entryInputs, State: stateValues, Receipts: receiptValues, WorkOutputs: workOutputs, InputReceipts: inputReceipts, } if latest, found, latestErr := store.LatestRequest(materializationContext); latestErr != nil { return commandOptions{}, latestErr @@ -716,6 +728,15 @@ func materializeFlowInvocation(ctx context.Context, compiled controlprogram.Comp return options, nil } +func transitionUsesHostInput(transition controlprogram.Transition) bool { + for _, binding := range transition.Parameters { + if binding.Producer.Kind == controlprogram.ParameterSourceHostInput { + return true + } + } + return false +} + func validateWorkOutputProducer(record foregroundwork.Record, work controlprogram.WorkContract, compiled controlprogram.Compiled, entry controlprogram.Entry, options commandOptions, current model.InvocationContext) error { contract, err := softwareflow.RuntimeWorkContract(work) if err != nil { diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index 45cb0e4..6dc2022 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -346,6 +346,18 @@ func writeAdmittedFlowProgramState(t *testing.T, repository, programFingerprint state := durable.Default(invoking, time.Now().UTC()) state.ProgramFingerprint = programFingerprint state.ControlBundleFingerprint = bundleFingerprint + configRaw, err := os.ReadFile(layout.ConfigPath) + if err != nil { + t.Fatal(err) + } + config, configFingerprint, err := protocol.ProjectConfigFingerprint(configRaw) + if err != nil { + t.Fatal(err) + } + policy := config.ControlPolicy() + state.Configuration, state.ConfigFingerprint = model.ConfigurationVerified, configFingerprint + state.PlanApprovalPolicy, state.VisualEvidencePolicy, state.ExternalEffectPolicy = policy.PlanApproval, policy.VisualEvidence, policy.ExternalEffectAuthority + state.IndependentReview, state.EnabledHosts = policy.IndependentReviewForHighRisk, policy.Hosts raw, err := durable.EncodeState(state) if err != nil { t.Fatal(err) diff --git a/boatstack/cmd/boatstack-helper/input_command.go b/boatstack/cmd/boatstack-helper/input_command.go index d0a03c8..22446d5 100644 --- a/boatstack/cmd/boatstack-helper/input_command.go +++ b/boatstack/cmd/boatstack-helper/input_command.go @@ -92,19 +92,18 @@ func runFlowInput(arguments []string) error { if request.ProgramFingerprint != compiled.Fingerprint || request.EntryID != options.entryID { return fmt.Errorf("FLOW_INPUT_REQUEST_MISMATCH: request does not belong to the selected program and entry") } - if request.ControlBundleFingerprint != "" && request.ControlBundleFingerprint != runtimeContext.controlBundle.Fingerprint { + if request.ControlBundleFingerprint != runtimeContext.controlBundle.Fingerprint { return fmt.Errorf("HUMAN_IDENTITY_DRIFT: input request bundle %s does not match current verified bundle %s", request.ControlBundleFingerprint, runtimeContext.controlBundle.Fingerprint) } + if request.AuthorityContextFingerprint != runtimeContext.humanIdentity.ProviderFingerprint { + return fmt.Errorf("HUMAN_IDENTITY_DRIFT: input request authority context %s does not match current verified identity provider %s", request.AuthorityContextFingerprint, runtimeContext.humanIdentity.ProviderFingerprint) + } if action == "show" { receipts, loadErr := store.LoadReceipts(options.runID, request.TransitionID) if loadErr != nil { return loadErr } - presentation, identityErr := humanIdentityPresentationForRepositoryBound(ctx, repository, options.host, "flow-input", runtimeContext.controlBundle.Source, nil) - if identityErr != nil { - return identityErr - } - return encodeFlowInputResult(map[string]any{"request": request, "receipts": receipts, "human_identity": presentation}, options.format) + return encodeFlowInputResult(map[string]any{"request": request, "receipts": receipts, "human_identity": runtimeContext.humanIdentity}, options.format) } if action == "supersede" { if options.reason == "" || options.human == "" || options.host == "" { @@ -119,7 +118,8 @@ func runFlowInput(arguments []string) error { requestContext := invocation.Context{ RunID: request.RunID, ProgramFingerprint: request.ProgramFingerprint, ExecutionProgramFingerprint: request.ExecutionProgramFingerprint, EntryID: request.EntryID, TargetID: request.TargetID, TransitionID: request.TransitionID, StateRevision: request.StateRevision, - ContextFingerprint: request.ContextFingerprint, ControlBundleFingerprint: request.ControlBundleFingerprint, ExecutionScopeFingerprint: request.ExecutionScopeFingerprint, + ContextFingerprint: request.ContextFingerprint, ControlBundleFingerprint: request.ControlBundleFingerprint, + AuthorityContextFingerprint: request.AuthorityContextFingerprint, ExecutionScopeFingerprint: request.ExecutionScopeFingerprint, } latest, found, latestErr := store.LatestRequest(requestContext) if latestErr != nil { @@ -167,7 +167,8 @@ func runFlowInput(arguments []string) error { type flowInputRuntimeContext struct { executionScopeFingerprint string - controlBundle *boatstackruntime.ControlBundleContract + controlBundle boatstackruntime.ControlBundleSnapshot + humanIdentity humanidentity.Presentation } func loadFlowInputContext(ctx context.Context, options flowInputOptions) (controlprogram.Compiled, invocation.Store, flowInputRuntimeContext, error) { @@ -207,7 +208,13 @@ func loadFlowInputContext(ctx context.Context, options flowInputOptions) (contro if err != nil { return controlprogram.Compiled{}, invocation.Store{}, flowInputRuntimeContext{}, err } - return compiled, invocation.Store{Root: layout.FlowRoot, Writer: effects.NewRuntimeStore()}, flowInputRuntimeContext{executionScopeFingerprint: executionScopeFingerprint, controlBundle: controlBundle}, nil + presentation, err := humanIdentityPresentationForRepositoryBound(ctx, options.repository, options.host, "flow-input-"+options.runID, controlBundle.Source, nil) + if err != nil { + return controlprogram.Compiled{}, invocation.Store{}, flowInputRuntimeContext{}, err + } + return compiled, invocation.Store{Root: layout.FlowRoot, Writer: effects.NewRuntimeStore()}, flowInputRuntimeContext{ + executionScopeFingerprint: executionScopeFingerprint, controlBundle: controlBundle.Source, humanIdentity: presentation, + }, nil } func loadFlowInputAnswers(path string) (map[string]string, error) { @@ -246,6 +253,9 @@ func recordFlowInputAnswers(store invocation.Store, compiled controlprogram.Comp if runtimeContext.executionScopeFingerprint != request.ExecutionScopeFingerprint { return nil, fmt.Errorf("FLOW_INPUT_REQUEST_MISMATCH: execution scope changed after suspension") } + if runtimeContext.humanIdentity.ProviderFingerprint != request.AuthorityContextFingerprint { + return nil, fmt.Errorf("HUMAN_IDENTITY_DRIFT: input request identity provider changed after suspension") + } transition, ok := findCompiledTransition(compiled.Document.Transitions, request.TransitionID) if !ok { return nil, fmt.Errorf("FLOW_TRANSITION_UNKNOWN: %s", request.TransitionID) @@ -300,8 +310,8 @@ func recordFlowInputAnswers(store invocation.Store, compiled controlprogram.Comp TransitionID: request.TransitionID, ParameterID: parameterID, Type: contract.Type, Value: value, ProducerFingerprint: invocation.ProducerFingerprint(producer), RequestFingerprint: request.Fingerprint, StateRevision: request.StateRevision, ContextFingerprint: request.ContextFingerprint, ControlBundleFingerprint: request.ControlBundleFingerprint, - ExecutionScopeFingerprint: runtimeContext.executionScopeFingerprint, - Actor: actor, Host: host, AuthorityReceipts: []string{"human:" + actor}, Scope: "transition", + AuthorityContextFingerprint: request.AuthorityContextFingerprint, ExecutionScopeFingerprint: runtimeContext.executionScopeFingerprint, + Actor: actor, Host: host, AuthorityReceipts: []string{"human:" + actor}, Scope: "transition", }) if sealErr != nil { return nil, sealErr diff --git a/boatstack/flow/softwaredelivery/skills.go b/boatstack/flow/softwaredelivery/skills.go index b3403ce..9de6b5d 100644 --- a/boatstack/flow/softwaredelivery/skills.go +++ b/boatstack/flow/softwaredelivery/skills.go @@ -60,6 +60,7 @@ func renderSkill(compiled controlprogram.Compiled, entry controlprogram.Entry, s inputProtocol := "" gateEvidenceProtocol := "" entryInputProtocol := "" + declarativeAuthorityProtocol := "" programReconciliation := "" publication := "" humanIdentityProtocol := ` @@ -104,6 +105,19 @@ authority. } if declarativeProgram(compiled.Document.Operators) { startCommand = fmt.Sprintf("boatstack flow run --repo . --flow %s --entry %s --host %s --format json", compiled.Document.Program.ID, entry.ID, host) + declarativeAuthorityProtocol = fmt.Sprintf(` +If Boatstack returns `+"`AUTHORITY_REQUIRED`"+`, preserve its exact run, state, +transition, `+"`authority_fingerprint`"+`, requested authorities, and +`+"`human_identity`"+`. Resolve the proposed actor through the identity protocol +above, display the exact authority request, and ask for explicit approval. Only +after approval, resume with: + +`+"`boatstack flow run --repo . --flow %s --entry %s --run-id --authority-fingerprint --human --host %s --format json`"+` + +If the authority or identity fingerprint changes, discard the prior approval, +present the fresh suspension, and ask again. Never resume a declarative human +authority boundary with `+"`--human`"+` alone. +`, compiled.Document.Program.ID, entry.ID, host) if len(entry.Inputs) != 0 { var required []string for _, input := range entry.Inputs { @@ -178,8 +192,8 @@ background while a question is open. if hasHostInputProducer(compiled.Document.Transitions) { inputProtocol = fmt.Sprintf(` When Boatstack returns `+"`TRANSITION_INPUT_REQUIRED`"+`, preserve the exact run, -program, entry, target, transition, state, context, control-bundle, and request -fingerprints. Inspect the runtime-owned request with: +program, entry, target, transition, state, context, control-bundle, +authority-context, and request fingerprints. Inspect the runtime-owned request with: `+"`boatstack flow input show --repo . --flow %s --entry %s --run-id --request-fingerprint --host %s --format json`"+` @@ -325,16 +339,17 @@ background while input is missing. Never synthesize authority. %s %s %s -%s -%s -%s -%s -%s + %s + %s + %s + %s + %s + %s Stop only when Boatstack reports the marked target, a typed blocker, refusal, unresolved recovery, or missing authority. This entry grants no merge or deploy authority. -`, slug, description, title(slug), compiled.Document.Program.ID, entry.ID, entry.Target, skillprojection.BootstrapContract(), startCommand, humanIdentityProtocol, delegation, supersession, diagnostics, workProtocol, inputProtocol, entryInputProtocol, programReconciliation, publication)) +`, slug, description, title(slug), compiled.Document.Program.ID, entry.ID, entry.Target, skillprojection.BootstrapContract(), startCommand, humanIdentityProtocol, delegation, supersession, diagnostics, workProtocol, inputProtocol, entryInputProtocol, declarativeAuthorityProtocol, programReconciliation, publication)) } func declarativeProgram(operators []controlprogram.Operator) bool { diff --git a/boatstack/invocation/invocation.go b/boatstack/invocation/invocation.go index abf4e0d..31c2b38 100644 --- a/boatstack/invocation/invocation.go +++ b/boatstack/invocation/invocation.go @@ -17,11 +17,11 @@ import ( const ( EvidenceSchema = "transition-invocation" - EvidenceSchemaRevision = 2 + EvidenceSchemaRevision = 3 RequestSchema = "transition-input-request" - RequestSchemaRevision = 2 + RequestSchemaRevision = 3 ReceiptSchema = "transition-input-receipt" - ReceiptSchemaRevision = 2 + ReceiptSchemaRevision = 3 ) type Context struct { @@ -34,6 +34,7 @@ type Context struct { StateRevision uint64 ContextFingerprint string ControlBundleFingerprint string + AuthorityContextFingerprint string ExecutionScopeFingerprint string InputRequestGeneration uint64 InputRequestSupersession *InputRequestSupersession @@ -80,6 +81,7 @@ type Evidence struct { StateRevision uint64 `json:"state_revision"` ContextFingerprint string `json:"context_fingerprint"` ControlBundleFingerprint string `json:"control_bundle_fingerprint,omitempty"` + AuthorityContextFingerprint string `json:"authority_context_fingerprint,omitempty"` Parameters []ResolvedParameter `json:"parameters"` InvocationFingerprint string `json:"invocation_fingerprint"` } @@ -106,7 +108,8 @@ type InputRequest struct { Fingerprint string `json:"fingerprint"` StateRevision uint64 `json:"state_revision"` ContextFingerprint string `json:"context_fingerprint"` - ControlBundleFingerprint string `json:"control_bundle_fingerprint,omitempty"` + ControlBundleFingerprint string `json:"control_bundle_fingerprint"` + AuthorityContextFingerprint string `json:"authority_context_fingerprint"` ExecutionScopeFingerprint string `json:"execution_scope_fingerprint"` Generation uint64 `json:"generation,omitempty"` Supersession *InputRequestSupersession `json:"supersession,omitempty"` @@ -143,7 +146,8 @@ type InputReceipt struct { RequestFingerprint string `json:"request_fingerprint"` StateRevision uint64 `json:"state_revision"` ContextFingerprint string `json:"context_fingerprint"` - ControlBundleFingerprint string `json:"control_bundle_fingerprint,omitempty"` + ControlBundleFingerprint string `json:"control_bundle_fingerprint"` + AuthorityContextFingerprint string `json:"authority_context_fingerprint"` ExecutionScopeFingerprint string `json:"execution_scope_fingerprint"` Actor string `json:"actor"` Host string `json:"host"` @@ -166,7 +170,7 @@ type Blocker struct { } func (r InputRequest) Validate() error { - if r.Schema != RequestSchema || r.SchemaRevision != RequestSchemaRevision || r.ID == "" || r.Code != "TRANSITION_INPUT_REQUIRED" || r.RunID == "" || len(r.ProgramFingerprint) != 64 || len(r.ExecutionProgramFingerprint) != 64 || r.EntryID == "" || r.TargetID == "" || r.TransitionID == "" || len(r.ContextFingerprint) != 64 || len(r.ExecutionScopeFingerprint) != 64 || r.Fingerprint == "" || len(r.Parameters) == 0 { + if r.Schema != RequestSchema || r.SchemaRevision != RequestSchemaRevision || r.ID == "" || r.Code != "TRANSITION_INPUT_REQUIRED" || r.RunID == "" || len(r.ProgramFingerprint) != 64 || len(r.ExecutionProgramFingerprint) != 64 || r.EntryID == "" || r.TargetID == "" || r.TransitionID == "" || len(r.ContextFingerprint) != 64 || len(r.ControlBundleFingerprint) != 64 || len(r.AuthorityContextFingerprint) != 64 || len(r.ExecutionScopeFingerprint) != 64 || r.Fingerprint == "" || len(r.Parameters) == 0 { return fmt.Errorf("input request envelope is invalid") } generation := r.EffectiveGeneration() @@ -196,7 +200,7 @@ func (r InputRequest) EffectiveGeneration() uint64 { } func (e Evidence) Validate() error { - if e.Schema != EvidenceSchema || e.SchemaRevision != EvidenceSchemaRevision || e.RunID == "" || len(e.ProgramFingerprint) != 64 || len(e.ExecutionProgramFingerprint) != 64 || e.EntryID == "" || e.TargetID == "" || e.TransitionID == "" || len(e.ContextFingerprint) != 64 || len(e.InvocationFingerprint) != 64 { + if e.Schema != EvidenceSchema || e.SchemaRevision != EvidenceSchemaRevision || e.RunID == "" || len(e.ProgramFingerprint) != 64 || len(e.ExecutionProgramFingerprint) != 64 || e.EntryID == "" || e.TargetID == "" || e.TransitionID == "" || len(e.ContextFingerprint) != 64 || (e.AuthorityContextFingerprint != "" && len(e.AuthorityContextFingerprint) != 64) || len(e.InvocationFingerprint) != 64 { return fmt.Errorf("invocation evidence envelope is invalid") } previous := "" @@ -226,6 +230,9 @@ func Materialize(contracts []controlprogram.OperatorParameter, bindings []contro byBinding[binding.Parameter] = binding.Producer } hostRequest := inputRequestForHostBindings(contracts, byBinding, context) + if hostRequest != nil && (len(context.ControlBundleFingerprint) != 64 || len(context.AuthorityContextFingerprint) != 64) { + return Result{}, fmt.Errorf("host input materialization requires exact control-bundle and authority-context fingerprints") + } var parameters []ResolvedParameter var requested []RequestedParameter for _, contract := range contracts { @@ -271,7 +278,7 @@ func Materialize(contracts []controlprogram.OperatorParameter, bindings []contro return Result{Request: hostRequest}, nil } sort.Slice(parameters, func(i, j int) bool { return parameters[i].Name < parameters[j].Name }) - evidence := Evidence{Schema: EvidenceSchema, SchemaRevision: EvidenceSchemaRevision, RunID: context.RunID, ProgramFingerprint: context.ProgramFingerprint, ExecutionProgramFingerprint: context.ExecutionProgramFingerprint, EntryID: context.EntryID, TargetID: context.TargetID, TransitionID: context.TransitionID, StateRevision: context.StateRevision, ContextFingerprint: context.ContextFingerprint, ControlBundleFingerprint: context.ControlBundleFingerprint, Parameters: parameters} + evidence := Evidence{Schema: EvidenceSchema, SchemaRevision: EvidenceSchemaRevision, RunID: context.RunID, ProgramFingerprint: context.ProgramFingerprint, ExecutionProgramFingerprint: context.ExecutionProgramFingerprint, EntryID: context.EntryID, TargetID: context.TargetID, TransitionID: context.TransitionID, StateRevision: context.StateRevision, ContextFingerprint: context.ContextFingerprint, ControlBundleFingerprint: context.ControlBundleFingerprint, AuthorityContextFingerprint: context.AuthorityContextFingerprint, Parameters: parameters} evidence.InvocationFingerprint = fingerprintWithoutField(evidence, "InvocationFingerprint") return Result{Ready: &evidence}, nil } @@ -317,7 +324,7 @@ func materializeOne(contract controlprogram.OperatorParameter, producer controlp } func (r InputReceipt) ValidateCurrent(context Context, contract controlprogram.OperatorParameter, producer controlprogram.ParameterProducer, requestFingerprint string) error { - if r.Schema != ReceiptSchema || r.SchemaRevision != ReceiptSchemaRevision || r.ID == "" || r.Fingerprint == "" || r.Scope != "transition" || len(r.ExecutionProgramFingerprint) != 64 || len(r.ExecutionScopeFingerprint) != 64 || producer.Request == nil { + if r.Schema != ReceiptSchema || r.SchemaRevision != ReceiptSchemaRevision || r.ID == "" || r.Fingerprint == "" || r.Scope != "transition" || len(r.ExecutionProgramFingerprint) != 64 || len(r.ControlBundleFingerprint) != 64 || len(r.AuthorityContextFingerprint) != 64 || len(r.ExecutionScopeFingerprint) != 64 || producer.Request == nil { return fmt.Errorf("input receipt envelope is invalid") } identity := r @@ -335,6 +342,7 @@ func (r InputReceipt) ValidateCurrent(context Context, contract controlprogram.O {"transition", r.TransitionID != context.TransitionID}, {"parameter", r.ParameterID != contract.ID}, {"state", r.StateRevision != context.StateRevision}, {"context", r.ContextFingerprint != context.ContextFingerprint}, {"control-bundle", r.ControlBundleFingerprint != context.ControlBundleFingerprint}, + {"authority-context", r.AuthorityContextFingerprint != context.AuthorityContextFingerprint}, {"execution-scope", r.ExecutionScopeFingerprint != context.ExecutionScopeFingerprint}, {"request", r.RequestFingerprint != requestFingerprint}, } @@ -403,7 +411,7 @@ func inputRequestForHostBindings(contracts []controlprogram.OperatorParameter, p return nil } sort.Slice(requested, func(i, j int) bool { return requested[i].ID < requested[j].ID }) - request := InputRequest{Schema: RequestSchema, SchemaRevision: RequestSchemaRevision, Code: "TRANSITION_INPUT_REQUIRED", RunID: context.RunID, ProgramFingerprint: context.ProgramFingerprint, ExecutionProgramFingerprint: context.ExecutionProgramFingerprint, EntryID: context.EntryID, TargetID: context.TargetID, TransitionID: context.TransitionID, StateRevision: context.StateRevision, ContextFingerprint: context.ContextFingerprint, ControlBundleFingerprint: context.ControlBundleFingerprint, ExecutionScopeFingerprint: context.ExecutionScopeFingerprint, Generation: context.InputRequestGeneration, Supersession: context.InputRequestSupersession, Parameters: requested} + request := InputRequest{Schema: RequestSchema, SchemaRevision: RequestSchemaRevision, Code: "TRANSITION_INPUT_REQUIRED", RunID: context.RunID, ProgramFingerprint: context.ProgramFingerprint, ExecutionProgramFingerprint: context.ExecutionProgramFingerprint, EntryID: context.EntryID, TargetID: context.TargetID, TransitionID: context.TransitionID, StateRevision: context.StateRevision, ContextFingerprint: context.ContextFingerprint, ControlBundleFingerprint: context.ControlBundleFingerprint, AuthorityContextFingerprint: context.AuthorityContextFingerprint, ExecutionScopeFingerprint: context.ExecutionScopeFingerprint, Generation: context.InputRequestGeneration, Supersession: context.InputRequestSupersession, Parameters: requested} request.ID = "input-" + fingerprintWithoutField(request, "Fingerprint")[:24] request.Fingerprint = fingerprintWithoutField(request, "Fingerprint") return &request diff --git a/boatstack/invocation/invocation_test.go b/boatstack/invocation/invocation_test.go index 964990f..4aae68f 100644 --- a/boatstack/invocation/invocation_test.go +++ b/boatstack/invocation/invocation_test.go @@ -20,7 +20,7 @@ func (testRuntimeStore) WriteAtomic(path string, raw []byte, mode uint32) error } func testContext() Context { - return Context{RunID: "run-one", ProgramFingerprint: strings.Repeat("a", 64), ExecutionProgramFingerprint: strings.Repeat("e", 64), EntryID: "run", TargetID: "mitigated", TransitionID: "respond", StateRevision: 12, ContextFingerprint: strings.Repeat("b", 64), ControlBundleFingerprint: strings.Repeat("c", 64), ExecutionScopeFingerprint: strings.Repeat("d", 64), InputReceipts: map[string]InputReceipt{}} + return Context{RunID: "run-one", ProgramFingerprint: strings.Repeat("a", 64), ExecutionProgramFingerprint: strings.Repeat("e", 64), EntryID: "run", TargetID: "mitigated", TransitionID: "respond", StateRevision: 12, ContextFingerprint: strings.Repeat("b", 64), ControlBundleFingerprint: strings.Repeat("c", 64), AuthorityContextFingerprint: strings.Repeat("f", 64), ExecutionScopeFingerprint: strings.Repeat("d", 64), InputReceipts: map[string]InputReceipt{}} } func testContract() controlprogram.OperatorParameter { @@ -39,7 +39,7 @@ func TestHostInputSuspendsAndSameRunReceiptResumes(t *testing.T) { if err != nil || result.Request == nil || result.Request.Code != "TRANSITION_INPUT_REQUIRED" || result.Ready != nil { t.Fatalf("suspension = %#v, %v", result, err) } - receipt, err := SealReceipt(InputReceipt{RunID: context.RunID, ProgramFingerprint: context.ProgramFingerprint, ExecutionProgramFingerprint: context.ExecutionProgramFingerprint, EntryID: context.EntryID, TargetID: context.TargetID, TransitionID: context.TransitionID, ParameterID: "channel", Type: testContract().Type, Value: "pager", ProducerFingerprint: fingerprintProducer(testProducer()), RequestFingerprint: result.Request.Fingerprint, StateRevision: context.StateRevision, ContextFingerprint: context.ContextFingerprint, ControlBundleFingerprint: context.ControlBundleFingerprint, ExecutionScopeFingerprint: context.ExecutionScopeFingerprint, Actor: "operator", Host: "codex", AuthorityReceipts: []string{"human:operator"}, CreatedAt: time.Now().UTC(), Scope: "transition"}) + receipt, err := SealReceipt(InputReceipt{RunID: context.RunID, ProgramFingerprint: context.ProgramFingerprint, ExecutionProgramFingerprint: context.ExecutionProgramFingerprint, EntryID: context.EntryID, TargetID: context.TargetID, TransitionID: context.TransitionID, ParameterID: "channel", Type: testContract().Type, Value: "pager", ProducerFingerprint: fingerprintProducer(testProducer()), RequestFingerprint: result.Request.Fingerprint, StateRevision: context.StateRevision, ContextFingerprint: context.ContextFingerprint, ControlBundleFingerprint: context.ControlBundleFingerprint, AuthorityContextFingerprint: context.AuthorityContextFingerprint, ExecutionScopeFingerprint: context.ExecutionScopeFingerprint, Actor: "operator", Host: "codex", AuthorityReceipts: []string{"human:operator"}, CreatedAt: time.Now().UTC(), Scope: "transition"}) if err != nil { t.Fatal(err) } @@ -136,8 +136,8 @@ func TestInputReceiptRejectsCrossScopeExpiryAndControlDrift(t *testing.T) { TransitionID: context.TransitionID, ParameterID: "channel", Type: testContract().Type, Value: "pager", ProducerFingerprint: fingerprintProducer(testProducer()), RequestFingerprint: suspended.Request.Fingerprint, StateRevision: context.StateRevision, ContextFingerprint: context.ContextFingerprint, ControlBundleFingerprint: context.ControlBundleFingerprint, - ExecutionScopeFingerprint: context.ExecutionScopeFingerprint, - Actor: "operator", Host: "codex", AuthorityReceipts: []string{"human:operator"}, Scope: "transition", + AuthorityContextFingerprint: context.AuthorityContextFingerprint, ExecutionScopeFingerprint: context.ExecutionScopeFingerprint, + Actor: "operator", Host: "codex", AuthorityReceipts: []string{"human:operator"}, Scope: "transition", }) if err != nil { t.Fatal(err) @@ -150,6 +150,7 @@ func TestInputReceiptRejectsCrossScopeExpiryAndControlDrift(t *testing.T) { "state": func(value *Context) { value.StateRevision++ }, "context": func(value *Context) { value.ContextFingerprint = strings.Repeat("d", 64) }, "control-bundle": func(value *Context) { value.ControlBundleFingerprint = strings.Repeat("e", 64) }, + "authority-context": func(value *Context) { value.AuthorityContextFingerprint = strings.Repeat("a", 64) }, "execution-scope": func(value *Context) { value.ExecutionScopeFingerprint = strings.Repeat("f", 64) }, } { t.Run(name, func(t *testing.T) { @@ -185,7 +186,7 @@ func TestInputReceiptCannotClaimUnrecordedParameterAuthority(t *testing.T) { EntryID: context.EntryID, TargetID: context.TargetID, TransitionID: context.TransitionID, ParameterID: "channel", Type: testContract().Type, Value: "pager", ProducerFingerprint: fingerprintProducer(testProducer()), RequestFingerprint: suspended.Request.Fingerprint, StateRevision: context.StateRevision, ContextFingerprint: context.ContextFingerprint, ControlBundleFingerprint: context.ControlBundleFingerprint, - ExecutionScopeFingerprint: context.ExecutionScopeFingerprint, Actor: "automation", Host: "codex", + AuthorityContextFingerprint: context.AuthorityContextFingerprint, ExecutionScopeFingerprint: context.ExecutionScopeFingerprint, Actor: "automation", Host: "codex", AuthorityReceipts: []string{"autonomy:automation"}, Scope: "transition", }) if err != nil { diff --git a/boatstack/invocation/store.go b/boatstack/invocation/store.go index a7f827c..65cb35a 100644 --- a/boatstack/invocation/store.go +++ b/boatstack/invocation/store.go @@ -178,7 +178,8 @@ func requestMatchesContext(request InputRequest, context Context) bool { request.ExecutionProgramFingerprint == context.ExecutionProgramFingerprint && request.EntryID == context.EntryID && request.TargetID == context.TargetID && request.TransitionID == context.TransitionID && request.StateRevision == context.StateRevision && request.ContextFingerprint == context.ContextFingerprint && - request.ControlBundleFingerprint == context.ControlBundleFingerprint && request.ExecutionScopeFingerprint == context.ExecutionScopeFingerprint + request.ControlBundleFingerprint == context.ControlBundleFingerprint && + request.AuthorityContextFingerprint == context.AuthorityContextFingerprint && request.ExecutionScopeFingerprint == context.ExecutionScopeFingerprint } func (s Store) SaveReceipt(receipt InputReceipt) error { diff --git a/docs/product-delivery/writing-a-flow.md b/docs/product-delivery/writing-a-flow.md index d8f2aaf..7bb0562 100644 --- a/docs/product-delivery/writing-a-flow.md +++ b/docs/product-delivery/writing-a-flow.md @@ -117,11 +117,20 @@ Human or delegated approval remains an authority decision. The approving actor does not type deterministic values such as the admitted package fingerprint. Only `delivery.slice.advance.slice_id` is free-form in the standard lifecycle. A missing free-form input returns a typed `TRANSITION_INPUT_REQUIRED` -suspension; record its answer with `boatstack flow input answer`, then resume -the same run. If the value is semantically rejected, use +suspension. Its immutable request and answer receipt bind the current verified +control bundle and human-identity authority context. Record its answer with +`boatstack flow input answer`, then resume the same run. Identity rotation +therefore produces a fresh suspension instead of consuming an old answer. If +the value is semantically rejected, use `boatstack flow input supersede` to issue a linked request generation. Do not edit or delete the old request or receipt. +A declarative transition that requires human authority returns +`AUTHORITY_REQUIRED` with an exact `authority_fingerprint`. After the operator +approves the displayed transition and proposed actor, resume with both +`--authority-fingerprint ` and `--human `. A changed +authority or identity fingerprint requires a fresh approval. + Prepare gate evidence at `.boatstack/evidence//.input.json`, visual evidence at `.boatstack/evidence//visual-manifest.input.json`, and the pull From f2561ae54c4e98a9efca7e31497ebcb9181acc4d Mon Sep 17 00:00:00 2001 From: bigboateng Date: Mon, 17 Aug 2026 01:31:08 +0100 Subject: [PATCH 11/11] test: make identity rendering assertion portable --- boatstack/cmd/boatstack-helper/human_identity_test.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/boatstack/cmd/boatstack-helper/human_identity_test.go b/boatstack/cmd/boatstack-helper/human_identity_test.go index fcbe179..279e5ff 100644 --- a/boatstack/cmd/boatstack-helper/human_identity_test.go +++ b/boatstack/cmd/boatstack-helper/human_identity_test.go @@ -7,6 +7,7 @@ import ( "os/exec" "path/filepath" "reflect" + "strconv" "strings" "testing" "time" @@ -178,7 +179,8 @@ func TestHumanIdentityRenderingPreservesStructuredArgvWithoutExecutingIt(t *test t.Fatalf("structured JSON lost command argv: %#v", decoded.Delegation) } output, err := captureStdout(t, func() error { return renderResponse(response, "text") }) - if err != nil || !strings.Contains(string(output), `human_identity_command="touch" "`+marker+`"`) { + expectedCommand := "human_identity_command=" + strconv.Quote("touch") + " " + strconv.Quote(marker) + if err != nil || !strings.Contains(string(output), expectedCommand) { t.Fatalf("text output = %q, err=%v", output, err) } if _, err := os.Stat(marker); !os.IsNotExist(err) { @@ -189,7 +191,7 @@ func TestHumanIdentityRenderingPreservesStructuredArgvWithoutExecutingIt(t *test ProgramDeltaFingerprint: strings.Repeat("d", 64), RequiredTransition: "installation.reconcile-update", AcceptanceFlag: "--accept-program-change", HumanIdentity: &presentation, }} output, err = captureStdout(t, func() error { return renderResponse(programChange, "text") }) - if err != nil || !strings.Contains(string(output), `human_identity_command="touch" "`+marker+`"`) { + if err != nil || !strings.Contains(string(output), expectedCommand) { t.Fatalf("program-change text output = %q, err=%v", output, err) } }