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/.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/declarative_flow.go b/boatstack/cmd/boatstack-helper/declarative_flow.go index e285dc6..4120140 100644 --- a/boatstack/cmd/boatstack-helper/declarative_flow.go +++ b/boatstack/cmd/boatstack-helper/declarative_flow.go @@ -17,22 +17,26 @@ 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" ) -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 { @@ -50,12 +54,15 @@ type declarativeRunState struct { } type declarativeRuntimeContext struct { - compiled controlprogram.Compiled - entry controlprogram.Entry - state declarativeRunState - statePath string - store invocation.Store - 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) { @@ -173,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 @@ -190,17 +206,23 @@ func runDeclarativeFlow(ctx context.Context, compiled controlprogram.Compiled, o 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": 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 { + 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, + "authority_fingerprint": authorityFingerprint, "requested_authorities": declarativeAuthorities(transition, operator), + "human_identity": runtimeContext.humanIdentity, }, options.format) } @@ -211,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 @@ -230,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 @@ -255,8 +289,14 @@ func runDeclarativeFlow(ctx context.Context, compiled controlprogram.Compiled, o }, options.format) } -func requireDeclarativeAuthority(transition controlprogram.Transition, operator controlprogram.Operator, humanActor string) error { - providedHuman := strings.TrimSpace(humanActor) != "" +func requireDeclarativeAuthority(transition controlprogram.Transition, operator controlprogram.Operator, humanActor, providedFingerprint, expectedFingerprint string) error { + 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, ", ")) } @@ -266,10 +306,41 @@ 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 { + return declarativeRuntimeContext{}, err + } resolver, err := plant.NewResolver("") if err != nil { return declarativeRuntimeContext{}, err @@ -325,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 @@ -342,7 +416,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 } @@ -359,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 @@ -376,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 6941b9e..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" @@ -58,6 +59,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 } @@ -92,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. @@ -102,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) } @@ -143,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) @@ -166,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. @@ -275,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/delegation_command.go b/boatstack/cmd/boatstack-helper/delegation_command.go index 20a6462..1fd15a4 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 @@ -335,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 @@ -390,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 @@ -432,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 @@ -484,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 4efbc3d..5c47c08 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,19 +24,59 @@ 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 + } + 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 + } + } if prior.ControlBundleFingerprint == current.ControlBundleFingerprint { return false, nil } - return effects.InstallationReprojectionAdmits(layout, current.RunID, invocation, current.ControlBundleFingerprint) + return installationAdmits() +} + +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 } + presentation, err := humanIdentityPresentationForRequest(*request) + if err != nil { + return nil, nil, err + } resolver, err := plant.NewResolver("") if err != nil { return nil, nil, err @@ -81,18 +123,32 @@ 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 { - 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, - }) + 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 { + 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 @@ -101,7 +157,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 +202,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 @@ -179,7 +240,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 } diff --git a/boatstack/cmd/boatstack-helper/flow_runtime.go b/boatstack/cmd/boatstack-helper/flow_runtime.go index 30da1ad..1a737d3 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 := humanIdentityPresentationForRepositoryBound(ctx, repository, host, "flow-delegation-request", bundle.Source, nil) + 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 @@ -566,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"` @@ -576,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 } @@ -671,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 @@ -711,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 5365ba6..6dc2022 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", @@ -346,6 +346,57 @@ 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) + } + 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 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) @@ -504,7 +555,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 { @@ -2474,6 +2525,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 { @@ -2487,6 +2539,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 +2585,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 +2665,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 { @@ -2611,6 +2688,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) } @@ -2687,7 +2765,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 +2773,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,18 +2782,18 @@ 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") } } -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", @@ -2735,3 +2813,36 @@ func TestDelegationReprojectionRequiresAChangedControlBundle(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.go b/boatstack/cmd/boatstack-helper/human_identity.go new file mode 100644 index 0000000..aa455bc --- /dev/null +++ b/boatstack/cmd/boatstack-helper/human_identity.go @@ -0,0 +1,48 @@ +package main + +import ( + "context" + "fmt" + + boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" + "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/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) { + return humanidentitybinding.Handle(ctx, "", handler, request) +} + +func humanIdentityPresentationForRequest(request surfaces.Request) (humanidentity.Presentation, error) { + return humanidentitybinding.PresentationForRequest(context.Background(), "", request, nil) +} + +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 { + return humanidentitybinding.Attach(context.Background(), "", request, response) +} + +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..279e5ff --- /dev/null +++ b/boatstack/cmd/boatstack-helper/human_identity_test.go @@ -0,0 +1,207 @@ +package main + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "reflect" + "strconv" + "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/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 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() + 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 { + t.Fatal(err) + } + 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) + } + contract, err := boatstackruntime.NewControlBundleContract(snapshot, nil, "") + if err != nil { + t.Fatal(err) + } + request := surfaces.Request{Repository: repository, Host: "cli", CorrelationID: "human-identity-test", 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) + } + 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", + }}}, 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) + } + + 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{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" + 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 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.initialize", "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}} + 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) + } + 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") }) + 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) { + 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), expectedCommand) { + t.Fatalf("program-change text output = %q, err=%v", output, 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..22446d5 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,24 +92,34 @@ 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 != 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 } - return encodeFlowInputResult(map[string]any{"request": request, "receipts": receipts}, 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 == "" { 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") } 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 { @@ -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,8 @@ func runFlowInput(arguments []string) error { type flowInputRuntimeContext struct { executionScopeFingerprint string + controlBundle boatstackruntime.ControlBundleSnapshot + humanIdentity humanidentity.Presentation } func loadFlowInputContext(ctx context.Context, options flowInputOptions) (controlprogram.Compiled, invocation.Store, flowInputRuntimeContext, error) { @@ -188,7 +204,17 @@ 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 + } + 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) { @@ -227,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) @@ -281,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/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..ee3d544 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" @@ -211,7 +212,7 @@ func run(arguments []string) error { resolveRequest.FlowID = "" } resolveRequest.Prescription = protocol.Prescription{} - resolved, resolveErr := kernel.Handle(context.Background(), resolveRequest) + resolved, resolveErr := handleWithHumanIdentity(context.Background(), kernel, resolveRequest) if resolveErr != nil || resolved.Prescription == nil { if renderErr := renderResponse(resolved, options.format); renderErr != nil { return renderErr @@ -226,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) } @@ -238,6 +239,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") @@ -333,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) } @@ -345,6 +349,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 +485,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 { @@ -591,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 } @@ -635,9 +647,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 +889,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 @@ -947,6 +959,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 } @@ -965,6 +980,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/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 } 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..9de6b5d 100644 --- a/boatstack/flow/softwaredelivery/skills.go +++ b/boatstack/flow/softwaredelivery/skills.go @@ -60,14 +60,64 @@ func renderSkill(compiled controlprogram.Compiled, entry controlprogram.Entry, s inputProtocol := "" gateEvidenceProtocol := "" entryInputProtocol := "" + declarativeAuthorityProtocol := "" 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. + +Boatstack omits ` + "`human_identity`" + ` only when no verified descriptor exists: +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 +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 +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. 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 { startCommand = fmt.Sprintf("boatstack flow run --repo . --flow %s --entry %s --repository-authority --host %s --format json", compiled.Document.Program.ID, entry.ID, host) } 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 { @@ -96,6 +146,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: @@ -138,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`"+` @@ -231,7 +285,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 @@ -285,15 +339,17 @@ background while input is missing. Never synthesize authority. %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, 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/flow/softwaredelivery/skills_test.go b/boatstack/flow/softwaredelivery/skills_test.go index af01310..a1d0b05 100644 --- a/boatstack/flow/softwaredelivery/skills_test.go +++ b/boatstack/flow/softwaredelivery/skills_test.go @@ -48,11 +48,26 @@ 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", "Submit the exact `command`", + "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", + "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) @@ -81,6 +96,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/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..ec8bfc0 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,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\":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\":\"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)}}) + 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) + } + updatedProvider, err := updated.Identity.Human.Fingerprint() + if err != nil { + t.Fatal(err) + } + 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.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") 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) @@ -671,7 +685,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 +940,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 +1072,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 +1085,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 +1206,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/receipts.go b/boatstack/internal/softwaredelivery/effects/receipts.go index 3b834ab..6a40667 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,69 @@ func installationReprojectionAdmits(records []journalRecord, flowID string, invo return false, nil } +// 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 + } + 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 configurationReprojectionAdmits(records, flowID, invocation, configFingerprint, controlBundleFingerprint, state.Revision) +} + +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 || + receipt.ControlBundleTargetFingerprint != controlBundleFingerprint || !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..9986f49 100644 --- a/boatstack/internal/softwaredelivery/effects/receipts_test.go +++ b/boatstack/internal/softwaredelivery/effects/receipts_test.go @@ -82,6 +82,39 @@ 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, + ControlBundleTargetFingerprint: "bundle-b", + } + records := []journalRecord{{ + Admission: protocol.Admission{Invocation: invoking, Parameters: protocol.Parameters{{Name: "config_sha256", Value: "config-b"}}}, + Receipt: &receipt, + }} + 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 := 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 := 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 := 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 := 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) { current := model.InvocationContext{RepositoryID: "repo", GitCommonID: "common", WorktreeID: "worktree-a", ControllerID: "controller"} otherWorktree := current 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/humanidentitybinding/binding.go b/boatstack/internal/softwaredelivery/humanidentitybinding/binding.go new file mode 100644 index 0000000..20a212d --- /dev/null +++ b/boatstack/internal/softwaredelivery/humanidentitybinding/binding.go @@ -0,0 +1,230 @@ +// 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 questionRequiresIdentity && questionUsesExplicitActor(response) { + questionRequiresIdentity = false + } + if !programChangeRequiresHuman && !questionRequiresIdentity { + 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") + } + } + 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") + } + 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 +} + +// 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.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/boatstack/internal/softwaredelivery/humanidentitybinding/binding_test.go b/boatstack/internal/softwaredelivery/humanidentitybinding/binding_test.go new file mode 100644 index 0000000..e08d7c1 --- /dev/null +++ b/boatstack/internal/softwaredelivery/humanidentitybinding/binding_test.go @@ -0,0 +1,159 @@ +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) { + ctx := context.Background() + 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) + } + 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(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) + } +} + +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) + } + 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") { + 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/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..f16a3f0 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}$`) @@ -228,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 { @@ -264,11 +266,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 +289,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/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/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..f6db41b 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", @@ -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/boatstack/sdk/human_identity_test.go b/boatstack/sdk/human_identity_test.go new file mode 100644 index 0000000..1ed7058 --- /dev/null +++ b/boatstack/sdk/human_identity_test.go @@ -0,0 +1,71 @@ +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/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 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) + } + controlSnapshot, err := boatstackruntime.NewControlBundleSnapshot(map[string][]byte{".boatstack/project.json": raw}) + if err != nil { + t.Fatal(err) + } + 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: 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) + } + 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 1c9764e..db3d885 100644 --- a/boatstack/sdk/sdk.go +++ b/boatstack/sdk/sdk.go @@ -11,6 +11,8 @@ 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/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" @@ -37,6 +39,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 @@ -240,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) } diff --git a/docs/configuration.md b/docs/configuration.md index 87f0f7e..c57ea66 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,47 @@ 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. 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. 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 +`--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. + +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 either class. When independent high-risk review is enabled, the observer derives @@ -99,7 +148,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 +175,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/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 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..c49bb5f --- /dev/null +++ b/release-notes/2026-08-16-human-actor-identity.md @@ -0,0 +1,11 @@ +### 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. + +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.