Skip to content

Commit 56087aa

Browse files
authored
Add human actor identity descriptors (#224)
* feat: add human actor identity descriptors * fix: bind identity to program changes * fix: finalize identity on kernel responses * test: make identity checks portable * fix: gate host identity resolution * fix: bind identity to authoritative configuration * fix: reproject verified identity changes * fix: preserve configuration repair authority * fix: complete identity reprojection paths * fix: bind human suspensions to identity context * test: make identity rendering assertion portable
1 parent 6485491 commit 56087aa

52 files changed

Lines changed: 2294 additions & 239 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/tests/test_detached_supervision.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,8 @@ def test_detached_installation_and_engaged_guard_use_the_same_kernel(self) -> No
175175
config.write_text(
176176
json.dumps(
177177
{
178-
"schema_version": 2,
178+
"schema_version": 3,
179+
"identity": {"human": {"kind": "literal", "value": "contract"}},
179180
"project": {"name": "fixture", "default_branch": "main", "commands": {}},
180181
"policy": {"plan_approval": "human", "visual_evidence": "optional"},
181182
"hosts": ["cli", "cursor", "codex", "claude", "gemini", "mcp"],
@@ -244,7 +245,8 @@ def test_authority_free_frontier_does_not_block_authorized_plan_creation(self) -
244245
config.write_text(
245246
json.dumps(
246247
{
247-
"schema_version": 2,
248+
"schema_version": 3,
249+
"identity": {"human": {"kind": "literal", "value": "contract"}},
248250
"project": {"name": "driver-fixture", "default_branch": "main", "commands": {}},
249251
"policy": {"plan_approval": "human", "visual_evidence": "optional"},
250252
"hosts": ["cli", "codex"],
@@ -344,7 +346,8 @@ def test_one_delivery_context_rematerializes_repository_authority_after_initiali
344346
config.write_text(
345347
json.dumps(
346348
{
347-
"schema_version": 2,
349+
"schema_version": 3,
350+
"identity": {"human": {"kind": "literal", "value": "contract"}},
348351
"project": {
349352
"name": "retained-authority-fixture",
350353
"default_branch": "main",

.github/tests/test_repository_contract.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -793,6 +793,19 @@ def test_rpc_and_configuration_decoders_fail_closed(self) -> None:
793793
self.assertIn("unknown field", (rejected.stdout + rejected.stderr).lower())
794794
self.assertFalse((repository / ".boatstack" / "project.json").exists())
795795

796+
def test_installers_require_an_explicit_human_actor(self) -> None:
797+
if os.name != "nt":
798+
env = dict(os.environ)
799+
env.pop("BOATSTACK_ACTOR", None)
800+
rejected = self.run_command(
801+
"bash", REPO / "install.sh", cwd=REPO, env=env, expected=2
802+
)
803+
self.assertIn("BOATSTACK_HUMAN_ACTOR_REQUIRED", rejected.stderr)
804+
powershell = (REPO / "install.ps1").read_text()
805+
shell = (REPO / "install.sh").read_text()
806+
self.assertNotIn("$env:USERNAME", powershell)
807+
self.assertNotIn("${USER", shell)
808+
796809
def test_offline_installer_initializes_updates_and_guards_through_kernel(self) -> None:
797810
if os.name == "nt":
798811
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
954967
"BOATSTACK_HOME": str(root / "home"),
955968
"BOATSTACK_INSTALL_DIR": str(root / "bin"),
956969
"BOATSTACK_VERSION": "v9.9.9",
970+
"BOATSTACK_ACTOR": "contract",
957971
}
958972
)
959973
unavailable = self.run_command(

.github/workflows/ci.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,11 @@ jobs:
223223
$env:BOATSTACK_BINARY = $helper
224224
$env:BOATSTACK_BINARY_SHA256 = $digest
225225
$env:BOATSTACK_EXPECTED_RUNTIME_SHA256 = $digest
226+
if ($mode -eq "install") {
227+
$env:BOATSTACK_ACTOR = "installer-contract"
228+
} else {
229+
Remove-Item Env:BOATSTACK_ACTOR -ErrorAction SilentlyContinue
230+
}
226231
& ./install.ps1
227232
if ($LASTEXITCODE -ne 0) { throw "$mode installer failed" }
228233
@@ -238,6 +243,7 @@ jobs:
238243
throw "install did not initialize the repository runtime pin"
239244
}
240245
}
246+
Remove-Item Env:BOATSTACK_ACTOR -ErrorAction SilentlyContinue
241247
242248
$missingEvidenceRepository = Join-Path $root "missing-evidence-repository"
243249
New-Item -ItemType Directory -Force -Path $missingEvidenceRepository | Out-Null

boatstack/cmd/boatstack-helper/declarative_flow.go

Lines changed: 110 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -17,22 +17,26 @@ import (
1717
softwareflow "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery"
1818
boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime"
1919
"github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/effects"
20+
"github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/humanidentity"
2021
"github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/plant"
2122
"github.com/operatorstack/boatstack/boatstack/invocation"
2223
)
2324

24-
const declarativeRunSchemaRevision = 3
25+
const declarativeRunSchemaRevision = 4
2526

2627
type declarativeTransitionReceipt struct {
27-
ID string `json:"id"`
28-
TransitionID string `json:"transition_id"`
29-
InvocationFingerprint string `json:"invocation_fingerprint"`
30-
PriorStateRevision uint64 `json:"prior_state_revision"`
31-
ResultStateRevision uint64 `json:"result_state_revision"`
32-
PriorReceiptFingerprint string `json:"prior_receipt_fingerprint,omitempty"`
33-
Parameters []invocation.ResolvedParameter `json:"parameters"`
34-
HumanActor string `json:"human_actor,omitempty"`
35-
Fingerprint string `json:"fingerprint"`
28+
ID string `json:"id"`
29+
TransitionID string `json:"transition_id"`
30+
InvocationFingerprint string `json:"invocation_fingerprint"`
31+
PriorStateRevision uint64 `json:"prior_state_revision"`
32+
ResultStateRevision uint64 `json:"result_state_revision"`
33+
PriorReceiptFingerprint string `json:"prior_receipt_fingerprint,omitempty"`
34+
Parameters []invocation.ResolvedParameter `json:"parameters"`
35+
ControlBundleFingerprint string `json:"control_bundle_fingerprint"`
36+
AuthorityContextFingerprint string `json:"authority_context_fingerprint,omitempty"`
37+
AuthorityFingerprint string `json:"authority_fingerprint,omitempty"`
38+
HumanActor string `json:"human_actor,omitempty"`
39+
Fingerprint string `json:"fingerprint"`
3640
}
3741

3842
type declarativeRunState struct {
@@ -50,12 +54,15 @@ type declarativeRunState struct {
5054
}
5155

5256
type declarativeRuntimeContext struct {
53-
compiled controlprogram.Compiled
54-
entry controlprogram.Entry
55-
state declarativeRunState
56-
statePath string
57-
store invocation.Store
58-
executionScopeFingerprint string
57+
compiled controlprogram.Compiled
58+
entry controlprogram.Entry
59+
state declarativeRunState
60+
statePath string
61+
store invocation.Store
62+
controlBundle boatstackruntime.ControlBundleSnapshot
63+
executionScopeFingerprint string
64+
authorityContextFingerprint string
65+
humanIdentity *humanidentity.Presentation
5966
}
6067

6168
func tryRunDeclarativeFlow(ctx context.Context, options commandOptions) (bool, error) {
@@ -173,6 +180,15 @@ func runDeclarativeFlow(ctx context.Context, compiled controlprogram.Compiled, o
173180
if !ok {
174181
return fmt.Errorf("FLOW_RUNTIME_INVALID: declarative transition %q has no executable operator", transition.ID)
175182
}
183+
requiresHumanAuthority := declarativeRequiresHumanAuthority(transition, operator)
184+
if requiresHumanAuthority || transitionUsesHostInput(transition) {
185+
presentation, identityErr := humanIdentityPresentationForRepositoryBound(ctx, repository, options.host, "declarative-suspension", runtimeContext.controlBundle, nil)
186+
if identityErr != nil {
187+
return identityErr
188+
}
189+
runtimeContext.authorityContextFingerprint = presentation.ProviderFingerprint
190+
runtimeContext.humanIdentity = &presentation
191+
}
176192
result, materializationContext, err := materializeDeclarativeInvocation(runtimeContext, transition, operator)
177193
if err != nil {
178194
return err
@@ -190,17 +206,23 @@ func runDeclarativeFlow(ctx context.Context, compiled controlprogram.Compiled, o
190206
return encodeDeclarativeResult(map[string]any{
191207
"kind": "suspended", "code": result.Request.Code, "run_id": runtimeContext.state.RunID,
192208
"program_fingerprint": compiled.Fingerprint, "entry_id": entry.ID, "target_id": entry.Target,
193-
"transition_id": transition.ID, "request": result.Request,
209+
"transition_id": transition.ID, "request": result.Request, "human_identity": runtimeContext.humanIdentity,
194210
}, options.format)
195211
}
196212
if result.Ready == nil {
197213
return fmt.Errorf("FLOW_INVOCATION_INCOMPLETE: declarative materialization produced no evidence")
198214
}
199-
if err := requireDeclarativeAuthority(transition, operator, options.humanActor); err != nil {
215+
authorityFingerprint := ""
216+
if requiresHumanAuthority {
217+
authorityFingerprint = declarativeAuthorityFingerprint(*result.Ready, transition, operator, runtimeContext.authorityContextFingerprint)
218+
}
219+
if err := requireDeclarativeAuthority(transition, operator, options.humanActor, options.authorityFingerprint, authorityFingerprint); err != nil {
200220
return encodeDeclarativeResult(map[string]any{
201221
"kind": "blocked", "code": "AUTHORITY_REQUIRED", "detail": err.Error(),
202222
"run_id": runtimeContext.state.RunID, "program_fingerprint": compiled.Fingerprint,
203223
"entry_id": entry.ID, "target_id": entry.Target, "transition_id": transition.ID,
224+
"authority_fingerprint": authorityFingerprint, "requested_authorities": declarativeAuthorities(transition, operator),
225+
"human_identity": runtimeContext.humanIdentity,
204226
}, options.format)
205227
}
206228

@@ -211,6 +233,15 @@ func runDeclarativeFlow(ctx context.Context, compiled controlprogram.Compiled, o
211233
if err != nil {
212234
return err
213235
}
236+
if runtimeContext.humanIdentity != nil {
237+
current, identityErr := humanIdentityPresentationForRepositoryBound(ctx, repository, options.host, "declarative-commit", runtimeContext.controlBundle, nil)
238+
if identityErr != nil {
239+
return identityErr
240+
}
241+
if current.ProviderFingerprint != runtimeContext.authorityContextFingerprint {
242+
return fmt.Errorf("HUMAN_IDENTITY_DRIFT: verified identity provider changed before declarative state commit")
243+
}
244+
}
214245
fresh, err := invocation.Materialize(operator.Parameters, transition.Parameters, materializationContext, nil)
215246
if err != nil {
216247
return err
@@ -230,7 +261,10 @@ func runDeclarativeFlow(ctx context.Context, compiled controlprogram.Compiled, o
230261
receipt := declarativeTransitionReceipt{
231262
TransitionID: transition.ID, InvocationFingerprint: fresh.Ready.InvocationFingerprint,
232263
PriorStateRevision: priorRevision, ResultStateRevision: candidate.StateRevision,
233-
Parameters: append([]invocation.ResolvedParameter(nil), fresh.Ready.Parameters...), HumanActor: strings.TrimSpace(options.humanActor),
264+
Parameters: append([]invocation.ResolvedParameter(nil), fresh.Ready.Parameters...),
265+
ControlBundleFingerprint: runtimeContext.controlBundle.Fingerprint,
266+
AuthorityContextFingerprint: runtimeContext.authorityContextFingerprint, AuthorityFingerprint: authorityFingerprint,
267+
HumanActor: strings.TrimSpace(options.humanActor),
234268
}
235269
if previous := lastDeclarativeReceipt(candidate); previous != nil {
236270
receipt.PriorReceiptFingerprint = previous.Fingerprint
@@ -255,8 +289,14 @@ func runDeclarativeFlow(ctx context.Context, compiled controlprogram.Compiled, o
255289
}, options.format)
256290
}
257291

258-
func requireDeclarativeAuthority(transition controlprogram.Transition, operator controlprogram.Operator, humanActor string) error {
259-
providedHuman := strings.TrimSpace(humanActor) != ""
292+
func requireDeclarativeAuthority(transition controlprogram.Transition, operator controlprogram.Operator, humanActor, providedFingerprint, expectedFingerprint string) error {
293+
actor := strings.TrimSpace(humanActor)
294+
providedHuman := actor != ""
295+
if providedHuman {
296+
if err := humanidentity.ValidateActor(actor); err != nil {
297+
return err
298+
}
299+
}
260300
if len(operator.Authority.AnyOf) != 0 && !providedHuman {
261301
return fmt.Errorf("operator %q requires one of %s", operator.ID, strings.Join(operator.Authority.AnyOf, ", "))
262302
}
@@ -266,10 +306,41 @@ func requireDeclarativeAuthority(transition controlprogram.Transition, operator
266306
if containsString(transition.Requires.Authorities, "human") && !providedHuman {
267307
return fmt.Errorf("transition %q requires human authority", transition.ID)
268308
}
309+
if declarativeRequiresHumanAuthority(transition, operator) && (len(expectedFingerprint) != 64 || providedFingerprint != expectedFingerprint) {
310+
return fmt.Errorf("exact current authority fingerprint is required for transition %q", transition.ID)
311+
}
269312
return nil
270313
}
271314

315+
func declarativeRequiresHumanAuthority(transition controlprogram.Transition, operator controlprogram.Operator) bool {
316+
return containsString(operator.Authority.AnyOf, "human") || containsString(operator.Authority.AllOf, "human") || containsString(transition.Requires.Authorities, "human")
317+
}
318+
319+
func declarativeAuthorities(transition controlprogram.Transition, operator controlprogram.Operator) []string {
320+
values := append(append(append([]string(nil), operator.Authority.AnyOf...), operator.Authority.AllOf...), transition.Requires.Authorities...)
321+
sort.Strings(values)
322+
result := values[:0]
323+
for _, value := range values {
324+
if len(result) == 0 || result[len(result)-1] != value {
325+
result = append(result, value)
326+
}
327+
}
328+
return result
329+
}
330+
331+
func declarativeAuthorityFingerprint(evidence invocation.Evidence, transition controlprogram.Transition, operator controlprogram.Operator, authorityContextFingerprint string) string {
332+
return digestDeclarative(struct {
333+
InvocationFingerprint string `json:"invocation_fingerprint"`
334+
AuthorityContextFingerprint string `json:"authority_context_fingerprint"`
335+
Authorities []string `json:"authorities"`
336+
}{evidence.InvocationFingerprint, authorityContextFingerprint, declarativeAuthorities(transition, operator)})
337+
}
338+
272339
func loadDeclarativeRuntimeContext(ctx context.Context, repository string, compiled controlprogram.Compiled, entry controlprogram.Entry, options commandOptions) (declarativeRuntimeContext, error) {
340+
controlBundle, err := buildRepositoryControlBundle(ctx, repository)
341+
if err != nil {
342+
return declarativeRuntimeContext{}, err
343+
}
273344
resolver, err := plant.NewResolver("")
274345
if err != nil {
275346
return declarativeRuntimeContext{}, err
@@ -325,7 +396,10 @@ func loadDeclarativeRuntimeContext(ctx context.Context, repository string, compi
325396
receipt := state.Receipts[index]
326397
fingerprint := receipt.Fingerprint
327398
receipt.Fingerprint = ""
328-
if fingerprint == "" || fingerprint != digestDeclarative(receipt) || receipt.PriorReceiptFingerprint != priorReceiptFingerprint || receipt.PriorStateRevision != expectedRevision || receipt.ResultStateRevision != expectedRevision+1 {
399+
invalidAuthority := (receipt.AuthorityContextFingerprint != "" && len(receipt.AuthorityContextFingerprint) != 64) ||
400+
(receipt.HumanActor != "" && (len(receipt.AuthorityContextFingerprint) != 64 || len(receipt.AuthorityFingerprint) != 64)) ||
401+
(receipt.HumanActor == "" && receipt.AuthorityFingerprint != "")
402+
if fingerprint == "" || fingerprint != digestDeclarative(receipt) || len(receipt.ControlBundleFingerprint) != 64 || invalidAuthority || receipt.PriorReceiptFingerprint != priorReceiptFingerprint || receipt.PriorStateRevision != expectedRevision || receipt.ResultStateRevision != expectedRevision+1 {
329403
return declarativeRuntimeContext{}, fmt.Errorf("FLOW_CONTEXT_MISMATCH: declarative transition receipt is invalid")
330404
}
331405
expectedRevision = receipt.ResultStateRevision
@@ -342,7 +416,7 @@ func loadDeclarativeRuntimeContext(ctx context.Context, repository string, compi
342416
}
343417
return declarativeRuntimeContext{
344418
compiled: compiled, entry: entry, state: state, statePath: statePath,
345-
store: invocation.Store{Root: layout.FlowRoot, Writer: effects.NewRuntimeStore()}, executionScopeFingerprint: scope,
419+
store: invocation.Store{Root: layout.FlowRoot, Writer: effects.NewRuntimeStore()}, controlBundle: controlBundle, executionScopeFingerprint: scope,
346420
}, nil
347421
}
348422

@@ -359,15 +433,17 @@ func materializeDeclarativeInvocation(runtimeContext declarativeRuntimeContext,
359433
stateValues[facet] = invocation.Value{Type: controlprogram.ValueTypeDefinition{Kind: "string"}, Canonical: value, Provenance: "state", ProducerFingerprint: digestDeclarative(map[string]string{"facet": facet, "value": value})}
360434
}
361435
contextFingerprint := digestDeclarative(struct {
362-
RunID string `json:"run_id"`
363-
Program string `json:"program"`
364-
Entry string `json:"entry"`
365-
Target string `json:"target"`
366-
Transition string `json:"transition"`
367-
StateRevision uint64 `json:"state_revision"`
368-
Inputs map[string]string `json:"inputs"`
369-
Facts map[string]string `json:"facts"`
370-
}{runtimeContext.state.RunID, runtimeContext.compiled.Fingerprint, runtimeContext.entry.ID, runtimeContext.entry.Target, transition.ID, runtimeContext.state.StateRevision, runtimeContext.state.EntryInputs, runtimeContext.state.Facts})
436+
RunID string `json:"run_id"`
437+
Program string `json:"program"`
438+
Entry string `json:"entry"`
439+
Target string `json:"target"`
440+
Transition string `json:"transition"`
441+
StateRevision uint64 `json:"state_revision"`
442+
ControlBundleFingerprint string `json:"control_bundle_fingerprint"`
443+
AuthorityContextFingerprint string `json:"authority_context_fingerprint,omitempty"`
444+
Inputs map[string]string `json:"inputs"`
445+
Facts map[string]string `json:"facts"`
446+
}{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})
371447
receipts, err := runtimeContext.store.LoadReceipts(runtimeContext.state.RunID, transition.ID)
372448
if err != nil {
373449
return invocation.Result{}, invocation.Context{}, err
@@ -376,8 +452,8 @@ func materializeDeclarativeInvocation(runtimeContext declarativeRuntimeContext,
376452
RunID: runtimeContext.state.RunID, ProgramFingerprint: runtimeContext.compiled.Fingerprint,
377453
ExecutionProgramFingerprint: runtimeContext.compiled.Fingerprint,
378454
EntryID: runtimeContext.entry.ID, TargetID: runtimeContext.entry.Target, TransitionID: transition.ID,
379-
StateRevision: runtimeContext.state.StateRevision, ContextFingerprint: contextFingerprint,
380-
ExecutionScopeFingerprint: runtimeContext.executionScopeFingerprint, EntryInputs: entryInputs,
455+
StateRevision: runtimeContext.state.StateRevision, ContextFingerprint: contextFingerprint, ControlBundleFingerprint: runtimeContext.controlBundle.Fingerprint,
456+
AuthorityContextFingerprint: runtimeContext.authorityContextFingerprint, ExecutionScopeFingerprint: runtimeContext.executionScopeFingerprint, EntryInputs: entryInputs,
381457
State: stateValues, Receipts: map[string]invocation.Value{}, WorkOutputs: map[string]invocation.Value{}, InputReceipts: receipts,
382458
}
383459
result, err := invocation.Materialize(operator.Parameters, transition.Parameters, materializationContext, nil)

0 commit comments

Comments
 (0)