Skip to content

Commit 18edd86

Browse files
authored
Enforce capability authority at effect boundaries (#197)
* Enforce capability authority at effect boundaries * Share strict prescription projection in contract tests
1 parent 5adada0 commit 18edd86

62 files changed

Lines changed: 1472 additions & 213 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""Shared strict projections for Boatstack repository tests."""
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import Mapping
6+
7+
8+
def prescription_cli_arguments(
9+
prescription: Mapping[str, object], correlation: str
10+
) -> tuple[str, ...]:
11+
"""Project one complete current prescription into raw apply CLI arguments."""
12+
arguments = [
13+
"--correlation",
14+
correlation,
15+
"--prescription-id",
16+
str(prescription["id"]),
17+
"--expected-state-revision",
18+
str(prescription["expected_state_revision"]),
19+
"--expected-program-fingerprint",
20+
str(prescription["expected_program_fingerprint"]),
21+
"--expected-snapshot-fingerprint",
22+
str(prescription["expected_snapshot_fingerprint"]),
23+
"--authority-fingerprint",
24+
str(prescription["authority_fingerprint"]),
25+
]
26+
for capability in prescription["required_capabilities"]:
27+
arguments.extend(("--required-capability", str(capability)))
28+
for capability in prescription["effective_capabilities"]:
29+
arguments.extend(("--effective-capability", str(capability)))
30+
return tuple(arguments)

.github/tests/test_detached_supervision.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
import unittest
1212
from pathlib import Path
1313

14+
from boatstack_test_support import prescription_cli_arguments
15+
1416

1517
REPO = Path(__file__).resolve().parents[2]
1618
RUNTIME = REPO / "boatstack"
@@ -100,11 +102,7 @@ def apply_prescribed(
100102
correlation = resolved["snapshot"]["invocation"]["correlation_id"]
101103
return self.helper_json(
102104
"apply", "--transition", transition, *args,
103-
"--correlation", correlation,
104-
"--prescription-id", prescription["id"],
105-
"--expected-state-revision", prescription["expected_state_revision"],
106-
"--expected-program-fingerprint", prescription["expected_program_fingerprint"],
107-
"--expected-snapshot-fingerprint", prescription["expected_snapshot_fingerprint"],
105+
*prescription_cli_arguments(prescription, correlation),
108106
cwd=cwd,
109107
)
110108

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""Contract tests for the shared raw-CLI prescription projection."""
2+
3+
from __future__ import annotations
4+
5+
import unittest
6+
7+
from boatstack_test_support import prescription_cli_arguments
8+
9+
10+
class PrescriptionProjectionContract(unittest.TestCase):
11+
def setUp(self) -> None:
12+
self.prescription = {
13+
"id": "prx-example",
14+
"expected_state_revision": 41,
15+
"expected_program_fingerprint": "a" * 64,
16+
"expected_snapshot_fingerprint": "snapshot-example",
17+
"authority_fingerprint": "auth-example",
18+
"required_capabilities": ["command.execute", "repository.write"],
19+
"effective_capabilities": ["command.execute", "repository.write"],
20+
}
21+
22+
def test_projection_preserves_complete_current_identity(self) -> None:
23+
self.assertEqual(
24+
prescription_cli_arguments(self.prescription, "correlation-example"),
25+
(
26+
"--correlation", "correlation-example",
27+
"--prescription-id", "prx-example",
28+
"--expected-state-revision", "41",
29+
"--expected-program-fingerprint", "a" * 64,
30+
"--expected-snapshot-fingerprint", "snapshot-example",
31+
"--authority-fingerprint", "auth-example",
32+
"--required-capability", "command.execute",
33+
"--required-capability", "repository.write",
34+
"--effective-capability", "command.execute",
35+
"--effective-capability", "repository.write",
36+
),
37+
)
38+
39+
def test_missing_current_identity_fails_closed(self) -> None:
40+
del self.prescription["authority_fingerprint"]
41+
with self.assertRaises(KeyError):
42+
prescription_cli_arguments(self.prescription, "correlation-example")
43+
44+
45+
if __name__ == "__main__":
46+
unittest.main()

.github/tests/test_repository_contract.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
import xml.etree.ElementTree as ET
1111
from pathlib import Path
1212

13+
from boatstack_test_support import prescription_cli_arguments
14+
1315

1416
REPO = Path(__file__).resolve().parents[2]
1517
RUNTIME = REPO / "boatstack"
@@ -97,11 +99,7 @@ def apply_prescribed(
9799
correlation = resolved["snapshot"]["invocation"]["correlation_id"]
98100
applied = self.run_command(
99101
binary, "apply", "--transition", transition, *args,
100-
"--correlation", correlation,
101-
"--prescription-id", prescription["id"],
102-
"--expected-state-revision", prescription["expected_state_revision"],
103-
"--expected-program-fingerprint", prescription["expected_program_fingerprint"],
104-
"--expected-snapshot-fingerprint", prescription["expected_snapshot_fingerprint"],
102+
*prescription_cli_arguments(prescription, correlation),
105103
cwd=cwd, env=env,
106104
)
107105
return json.loads(applied.stdout)
@@ -566,11 +564,16 @@ def test_offline_installer_initializes_updates_and_guards_through_kernel(self) -
566564
self.assertEqual(pin["version"], "v0.7.contract-new")
567565
self.assertEqual(pin["sha256"], hashlib.sha256(self.helper.read_bytes()).hexdigest())
568566
self.assertNotIn("path", pin)
569-
events = self.run_command(
567+
events = [json.loads(line) for line in self.run_command(
570568
launcher, "events", "--repo", repository, "--format", "jsonl", env=env
571-
).stdout.splitlines()
572-
transitions = {json.loads(line)["transition_id"] for line in events}
569+
).stdout.splitlines()]
570+
transitions = {event["transition_id"] for event in events}
573571
self.assertTrue({"installation.initialize", "engagement.begin", "installation.reconcile-update"}.issubset(transitions))
572+
for event in events:
573+
self.assertTrue(event["authority_fingerprint"])
574+
self.assertTrue(event["required_capabilities"])
575+
self.assertTrue(event["granted_capabilities"])
576+
self.assertTrue(event["exercised_capabilities"])
574577

575578
def test_program_changing_update_is_explicit_atomic_and_dormant_safe(self) -> None:
576579
# control-law: accepted-program-delta-atomically-pins-runtime-and-program

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ program-qualified transition identity used by complete user-facing Flows.
3939
The [prescription transaction boundary](docs/architecture/prescription-transactions.md)
4040
defines the exact durable-state and executable-program compare-and-swap contract
4141
between resolution and effects.
42+
The [capability and authority boundary](docs/architecture/capability-authority-boundary.md)
43+
defines how programs narrow effect surfaces without granting themselves
44+
authority, including the explicit arbitrary-command frontier.
4245

4346
## Install
4447

boatstack/cmd/boatstack-helper/main.go

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ type commandOptions struct {
4848
expectedStateRevision uint64
4949
expectedProgramFingerprint string
5050
expectedSnapshotFingerprint string
51+
authorityFingerprint string
52+
requiredCapabilities stringList
53+
effectiveCapabilities stringList
5154
idempotencyKey string
5255
humanActor string
5356
repositoryPolicy bool
@@ -252,6 +255,9 @@ func parseOptions(command string, arguments []string, transition catalog.Transit
252255
flags.Uint64Var(&options.expectedStateRevision, "expected-state-revision", 0, "exact durable state revision observed during resolution")
253256
flags.StringVar(&options.expectedProgramFingerprint, "expected-program-fingerprint", "", "exact executable control-program fingerprint observed during resolution")
254257
flags.StringVar(&options.expectedSnapshotFingerprint, "expected-snapshot-fingerprint", "", "exact admission-relevant snapshot fingerprint observed during resolution")
258+
flags.StringVar(&options.authorityFingerprint, "authority-fingerprint", "", "exact authority projection fingerprint from resolution")
259+
flags.Var(&options.requiredCapabilities, "required-capability", "required capability from resolution (repeatable)")
260+
flags.Var(&options.effectiveCapabilities, "effective-capability", "effective capability from resolution (repeatable)")
255261
flags.StringVar(&options.idempotencyKey, "idempotency-key", "", "exact prior admission idempotency key for safe replay")
256262
flags.StringVar(&options.humanActor, "human", "", "explicit command-scoped human authority actor")
257263
flags.BoolVar(&options.repositoryPolicy, "repository-authority", false, "derive repository-policy authority from the V2 project configuration")
@@ -448,6 +454,14 @@ func buildRequest(operation surfaces.Operation, options commandOptions) (surface
448454
if err != nil {
449455
return surfaces.Request{}, err
450456
}
457+
requiredCapabilities, err := parseCapabilities("--required-capability", options.requiredCapabilities)
458+
if err != nil {
459+
return surfaces.Request{}, err
460+
}
461+
effectiveCapabilities, err := parseCapabilities("--effective-capability", options.effectiveCapabilities)
462+
if err != nil {
463+
return surfaces.Request{}, err
464+
}
451465
flowID := options.flowID
452466
if flowID == "" && goal.ID != "" {
453467
flowID = "flow-" + goal.ID
@@ -460,11 +474,24 @@ func buildRequest(operation surfaces.Operation, options commandOptions) (surface
460474
FlowID: flowID, Goal: goal, TransitionID: catalog.TransitionID(options.transitionID), Authority: authority, Parameters: parameters,
461475
Prescription: protocol.Prescription{SchemaVersion: protocol.PrescriptionSchemaVersion, ID: options.prescriptionID,
462476
TransitionID: catalog.TransitionID(options.transitionID), ExpectedStateRevision: options.expectedStateRevision,
463-
ExpectedProgramFingerprint: options.expectedProgramFingerprint, ExpectedSnapshotFingerprint: options.expectedSnapshotFingerprint},
477+
ExpectedProgramFingerprint: options.expectedProgramFingerprint, ExpectedSnapshotFingerprint: options.expectedSnapshotFingerprint,
478+
AuthorityFingerprint: options.authorityFingerprint, RequiredCapabilities: requiredCapabilities, EffectiveCapabilities: effectiveCapabilities},
464479
RepositoryAuthority: options.repositoryPolicy, IdempotencyKey: options.idempotencyKey, Command: options.command,
465480
}, nil
466481
}
467482

483+
func parseCapabilities(field string, values []string) ([]catalog.Capability, error) {
484+
capabilities := make([]catalog.Capability, len(values))
485+
for index, value := range values {
486+
capabilities[index] = catalog.Capability(value)
487+
}
488+
normalized, err := catalog.NormalizeCapabilities(field, capabilities)
489+
if err != nil {
490+
return nil, err
491+
}
492+
return normalized, nil
493+
}
494+
468495
func parseParameters(values []string) (protocol.Parameters, error) {
469496
parameters := make(protocol.Parameters, 0, len(values))
470497
for _, value := range values {

boatstack/cmd/boatstack-helper/main_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@ import (
44
"os"
55
"path/filepath"
66
"testing"
7+
"time"
78

89
"github.com/operatorstack/boatstack/boatstack/internal/buildinfo"
10+
"github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog"
11+
"github.com/operatorstack/boatstack/boatstack/internal/kernel/model"
912
"github.com/operatorstack/boatstack/boatstack/internal/surfaces"
1013
"github.com/operatorstack/boatstack/boatstack/internal/testprogram"
1114
)
@@ -73,3 +76,32 @@ func TestBuildRevisionPrefersReleaseEmbeddedSourceCommit(t *testing.T) {
7376
t.Fatalf("build revision = %q", got)
7477
}
7578
}
79+
80+
func TestTransitionReceiptCannotBeLoadedAsAuthority(t *testing.T) {
81+
// control-law: historical execution evidence is not a reusable authority token
82+
path := filepath.Join(t.TempDir(), "transition-receipt.json")
83+
if err := os.WriteFile(path, []byte(`{"schema_version":5,"id":"trc-old"}`), 0o600); err != nil {
84+
t.Fatal(err)
85+
}
86+
_, err := loadAuthority(commandOptions{authorityReceipts: stringList{path}}, "correlation", model.Goal{}, time.Now().UTC())
87+
if err == nil {
88+
t.Fatal("transition receipt was accepted as authority")
89+
}
90+
}
91+
92+
func TestRawCLIReconstructsExactCapabilityPrescription(t *testing.T) {
93+
options := commandOptions{
94+
transitionID: "installation.update", host: "cli", repository: ".", prescriptionID: "prx-test",
95+
expectedStateRevision: 7, expectedProgramFingerprint: hash([]byte("program")), expectedSnapshotFingerprint: hash([]byte("snapshot")),
96+
authorityFingerprint: "auth-test",
97+
requiredCapabilities: stringList{string(catalog.CapabilityRepositoryWrite), string(catalog.CapabilityCommandExecute)},
98+
effectiveCapabilities: stringList{string(catalog.CapabilityRepositoryWrite), string(catalog.CapabilityCommandExecute)},
99+
}
100+
request, err := buildRequest(surfaces.OperationApply, options)
101+
if err != nil {
102+
t.Fatal(err)
103+
}
104+
if request.Prescription.AuthorityFingerprint != options.authorityFingerprint || len(request.Prescription.RequiredCapabilities) != 2 || len(request.Prescription.EffectiveCapabilities) != 2 {
105+
t.Fatalf("CLI lost capability prescription: %#v", request.Prescription)
106+
}
107+
}

0 commit comments

Comments
 (0)