Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions .github/tests/test_detached_supervision.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -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"],
Expand Down Expand Up @@ -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",
Expand Down
14 changes: 14 additions & 0 deletions .github/tests/test_repository_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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(
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }

Expand All @@ -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
Expand Down
144 changes: 110 additions & 34 deletions boatstack/cmd/boatstack-helper/declarative_flow.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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)
}

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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, ", "))
}
Expand All @@ -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
Expand Down Expand Up @@ -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 != "")
Comment on lines +399 to +401

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Extra human actor corrupts declarative receipt history

Invariant: every successfully committed declarative receipt must pass the loader's validation. A valid transition requiring no human authority, invoked with the accepted optional --human alice flag, commits HumanActor while both authority fingerprints remain empty; the next replay deterministically rejects that receipt as FLOW_CONTEXT_MISMATCH. This patch introduced that mismatch by requiring fingerprints whenever HumanActor is present while still recording the actor unconditionally. The durable run becomes unreadable after reported success. Add a regression test that executes and then reloads a no-authority declarative transition with --human.

Confidence: 0.99

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
Expand All @@ -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
}

Expand All @@ -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
Expand All @@ -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)
Expand Down
Loading
Loading