From 0746d876b70f062d7137c55bd800ddfccc917714 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Tue, 4 Aug 2026 22:01:55 +0100 Subject: [PATCH] feat(boatstack): enforce repository-only authority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Disclosure-Reviewed: reviewed — public-safe only, private facet kept out of this commit --- .../boatstack-distribution/CONFIGURATION.md | 16 ++ .../boatstack-distribution/SAFETY.template.md | 2 + .../2026-08-04-external-authority-boundary.md | 3 + .../product-engineering-loop/SKILL.md | 2 +- .../product-engineering-loop/authority.go | 246 ++++++++++++++++++ .../authority_test.go | 215 +++++++++++++++ .../coverage_conformance_test.go | 19 +- .../cmd/boatstack-helper/main.go | 24 +- .../config_documentation_test.go | 2 + .../product-engineering-loop/export.go | 3 + .../references/config-schema.md | 6 + .../irreversible-operation-boundary.md | 8 + .../references/workflow.md | 2 +- .../product-engineering-loop/run.go | 48 +++- .../product-engineering-loop/runtime.go | 4 + .../product-engineering-loop/safety.go | 13 +- .../product-engineering-loop/safety_test.go | 151 +++++++++-- 17 files changed, 720 insertions(+), 44 deletions(-) create mode 100644 labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-08-04-external-authority-boundary.md create mode 100644 labs/12-product-engineering-loop/product-engineering-loop/authority.go create mode 100644 labs/12-product-engineering-loop/product-engineering-loop/authority_test.go diff --git a/labs/12-product-engineering-loop/boatstack-distribution/CONFIGURATION.md b/labs/12-product-engineering-loop/boatstack-distribution/CONFIGURATION.md index 99d27b3de..c4769f4cb 100644 --- a/labs/12-product-engineering-loop/boatstack-distribution/CONFIGURATION.md +++ b/labs/12-product-engineering-loop/boatstack-distribution/CONFIGURATION.md @@ -14,6 +14,8 @@ boatstack-user-config-field:workflow.pr_visual_evidence boatstack-user-config-field:workflow.visual_evidence_publish.mode boatstack-user-config-field:workflow.visual_evidence_publish.host boatstack-user-config-field:workflow.visual_evidence_publish.expiry +boatstack-user-config-field:workflow.external_authority.mode +boatstack-user-config-field:workflow.external_authority.trust_store boatstack-user-config-field:workflow.ignored_deliveries boatstack-user-config-field:delivery.terminal boatstack-user-config-field:insights.enabled @@ -61,6 +63,7 @@ failed, or stale results. | Check for a systemic boundary | `workflow.boundary_analysis` | Planning guidance asks whether the request is a local symptom before scope expands. | | Add frontend PR screenshots | `workflow.pr_visual_evidence` | `suggest` exposes missing screenshots as a gap; `require` blocks completed publication. A plan that approves visual scenarios lifts `suggest` to require semantics for that feature; `off` and a per-feature `not_relevant` decision (with a reason) are the escapes. Boatstack captures registered scenarios automatically during ship; per-surface harnesses register as `project.commands["visual:"]` (`capability-register --surface`) and scenarios select them with a `surface` field. | | Render screenshots inline on a private PR | `workflow.visual_evidence_publish.*` | `mode: external-host` uploads the captured PNGs to an anonymous expiring host so the comment renders inline even on a private repo; opt-in, never automatic. | +| Require repository-only credentials | `workflow.external_authority.*` | `credential-enforced` blocks managed execution without a current external receipt signed by a configured Ed25519 issuer. Omission stays explicitly `HOOK_GUARDED`. | | Ignore old ambiguous deliveries | `workflow.ignored_deliveries` | Listed feature slugs are excluded from delivery-ambiguity resolution so past work stops blocking new work; new, unlisted ambiguous deliveries still pause. | | Pursue the PR to merge, not just to open | `delivery.terminal` | `merged` keeps the read-only flow advisors naming post-publish steps (watch checks, route corrections) until the PR is observed merged; the default `published` ends the flow when the PR is open, exactly as before. | | Preserve and evaluate product insights | `insights.*` | Manual, fingerprint-bound captures and events become tracked `docs/insights/` diffs; PR evidence can update readiness, but only a human completes an insight. | @@ -113,6 +116,19 @@ When human approval is disabled, Boatstack still locks the exact plan and inputs Changelog enforcement is mechanical. Boundary analysis is model-mediated planning guidance and cannot silently expand approved scope. +```json +{ + "workflow": { + "external_authority": { + "mode": "credential-enforced", + "trust_store": "/etc/boatstack/authority-issuers.json" + } + } +} +``` + +Strict mode requires an external service-IAM, credential-broker, or isolated-host attestor. The JSON trust store maps issuer IDs to base64 Ed25519 public keys and must be operator-owned outside the repository; Boatstack rejects a file or parent directory owned or writable by the managed principal. Obtain the expected binding with `boatstack-helper authority-context --repo .`; the attestor signs a receipt for that repository, worktree, host session, principal, and a maximum 15-minute lifetime. Set the absolute receipt path in `BOATSTACK_AUTHORITY_RECEIPT`, the attested session in `BOATSTACK_HOST_SESSION`, and the attested principal fingerprint in `BOATSTACK_PRINCIPAL_FINGERPRINT`. Boatstack never holds the signing key. Missing or invalid evidence blocks `run-preflight` and remains `HOOK_GUARDED`; only a valid external receipt reports `CREDENTIAL_ENFORCED`. + ```json { "workflow": { diff --git a/labs/12-product-engineering-loop/boatstack-distribution/SAFETY.template.md b/labs/12-product-engineering-loop/boatstack-distribution/SAFETY.template.md index 39757e9d5..dc2c4ad93 100644 --- a/labs/12-product-engineering-loop/boatstack-distribution/SAFETY.template.md +++ b/labs/12-product-engineering-loop/boatstack-distribution/SAFETY.template.md @@ -26,6 +26,8 @@ The installer merges only Boatstack-owned fragments into the repository's Cursor Host trust or enablement may not be machine-inspectable. Hooks are therefore defense in depth rather than a complete sandbox. [Codex documents incomplete interception for some shell paths](https://learn.chatgpt.com/docs/hooks), [Claude notes that command hooks run with the user's full permissions](https://code.claude.com/docs/en/hooks), and Cursor's pre-shell/pre-MCP hooks still depend on the host loading the project configuration. Protected systems still need least-privilege credentials, scoped service roles, backups, and service-side approval for destructive administration. +Managed-run preflight names that boundary. `HOOK_GUARDED` means Boatstack blocks recognized unsafe operations but does not prove ambient cloud authority absent. `CREDENTIAL_ENFORCED` requires a short-lived repository-only receipt signed by a configured external service-IAM, credential-broker, or isolated-host attestor. Boatstack verifies the receipt and never holds an attestor signing key. + ## Evidence status This is a **PROPOSED** Move, not a claim of experimental proof. Existing benchmark results support deterministic enforcement over stronger prompting, and a sanitized partial-schema incident establishes the target failure mechanism. Promotion requires paired evaluation against the unguarded baseline: zero destructive executions, safe diagnostics and transactional operations retained, bounded latency, no secret-bearing denial logs, and no existing-workflow regression. diff --git a/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-08-04-external-authority-boundary.md b/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-08-04-external-authority-boundary.md new file mode 100644 index 000000000..744973f8e --- /dev/null +++ b/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-08-04-external-authority-boundary.md @@ -0,0 +1,3 @@ +### Keep cloud control-plane authority outside managed runs + +Boatstack now blocks Supabase branch deletion, lifecycle weakening, and known public service exposure before execution. Managed-run preflight also distinguishes hook-only protection from a short-lived, externally signed repository-only credential boundary rooted in an operator-protected trust store, so Boatstack never presents pattern matching or repository-authored keys as categorical cloud protection. diff --git a/labs/12-product-engineering-loop/product-engineering-loop/SKILL.md b/labs/12-product-engineering-loop/product-engineering-loop/SKILL.md index 7f5a613ad..af9f1eb87 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/SKILL.md +++ b/labs/12-product-engineering-loop/product-engineering-loop/SKILL.md @@ -46,7 +46,7 @@ When `delivery.terminal` is `merged`, follow the post-publish prescriptions exac Read [irreversible-operation-boundary.md](references/irreversible-operation-boundary.md). Project hooks hard-deny high-confidence destructive shell and MCP operations on every supported agent call. Never request or invent an in-session bypass. After an external-write failure, preserve state, use read-only diagnosis, retain the immutable target boundary, and choose only proven transactional retry or fix-forward recovery. Source edits may be reviewed, but an executable destructive capability blocks activation and every later gate. -This enforcement is defense in depth, not a complete sandbox. Keep least-privilege service credentials and service-side destructive approval in place. +This enforcement is defense in depth, not a complete sandbox. Keep least-privilege service credentials and service-side destructive approval in place. Read `authority_status` from `run-preflight`: `HOOK_GUARDED` never proves ambient cloud authority absent, while `CREDENTIAL_ENFORCED` means a trusted external attestor supplied a current repository-only receipt. Never strengthen the former into the latter in prose. ## Keep repository administration outside delivery diff --git a/labs/12-product-engineering-loop/product-engineering-loop/authority.go b/labs/12-product-engineering-loop/product-engineering-loop/authority.go new file mode 100644 index 000000000..2b931b744 --- /dev/null +++ b/labs/12-product-engineering-loop/product-engineering-loop/authority.go @@ -0,0 +1,246 @@ +package boatstack + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "time" +) + +const ( + AuthorityHookGuarded = "HOOK_GUARDED" + AuthorityCredentialEnforced = "CREDENTIAL_ENFORCED" + AuthorityClassRepositoryOnly = "repository-only" + AuthorityReceiptEnv = "BOATSTACK_AUTHORITY_RECEIPT" + AuthorityHostSessionEnv = "BOATSTACK_HOST_SESSION" + AuthorityPrincipalEnv = "BOATSTACK_PRINCIPAL_FINGERPRINT" + maxAuthorityReceiptLifetime = 15 * time.Minute +) + +var ( + authorityNow = time.Now + authorityTrustStoreProtected = protectedExternalTrustStore +) + +// ExternalAuthorityPolicy chooses whether a managed run relies on hook-only +// interception or requires an independently signed, credential-enforced boundary. +// Boatstack stores only public verification keys and never signs its own receipt. +type ExternalAuthorityPolicy struct { + Mode string `json:"mode,omitempty"` // "" | "hook-only" | "credential-enforced" + TrustStore string `json:"trust_store,omitempty"` +} + +type externalAuthorityTrustStore struct { + SchemaVersion int `json:"schema_version"` + Issuers map[string]string `json:"issuers"` +} + +// AuthorityBoundaryReceipt is supplied by an external authority such as service +// IAM, a credential broker, or an isolated host. Signature covers every field +// except Signature using AuthorityReceiptSigningBytes. +type AuthorityBoundaryReceipt struct { + SchemaVersion int `json:"schema_version"` + RepositoryFingerprint string `json:"repository_fingerprint"` + WorktreeFingerprint string `json:"worktree_fingerprint"` + HostSession string `json:"host_session"` + PrincipalFingerprint string `json:"principal_fingerprint"` + AuthorityClass string `json:"authority_class"` + CloudControlPlaneAuthority bool `json:"cloud_control_plane_authority"` + EnforcedBy string `json:"enforced_by"` + Issuer string `json:"issuer"` + IssuedAt string `json:"issued_at"` + ExpiresAt string `json:"expires_at"` + Signature string `json:"signature"` +} + +type AuthorityContext struct { + SchemaVersion int `json:"schema_version"` + RepositoryFingerprint string `json:"repository_fingerprint"` + WorktreeFingerprint string `json:"worktree_fingerprint"` +} + +// AuthorityReceiptSigningBytes is the stable external-attestor wire contract. +func AuthorityReceiptSigningBytes(receipt AuthorityBoundaryReceipt) ([]byte, error) { + receipt.Signature = "" + return json.Marshal(receipt) +} + +func ResolveAuthorityContext(repoInput string) (AuthorityContext, error) { + repo, err := ResolveRepository(repoInput) + if err != nil { + return AuthorityContext{}, err + } + common, err := gitCommonDir(repo) + if err != nil { + return AuthorityContext{}, err + } + repoPath, err := filepath.Abs(repo) + if err != nil { + return AuthorityContext{}, err + } + commonPath, err := filepath.Abs(common) + if err != nil { + return AuthorityContext{}, err + } + return AuthorityContext{ + SchemaVersion: 1, + RepositoryFingerprint: SHA256Bytes([]byte(filepath.Clean(commonPath))), + WorktreeFingerprint: SHA256Bytes([]byte(filepath.Clean(repoPath))), + }, nil +} + +func normalizedAuthorityMode(policy *ExternalAuthorityPolicy) string { + if policy == nil || strings.TrimSpace(policy.Mode) == "" { + return "hook-only" + } + return strings.TrimSpace(policy.Mode) +} + +func validateExternalAuthorityPolicy(policy *ExternalAuthorityPolicy) error { + mode := normalizedAuthorityMode(policy) + if mode != "hook-only" && mode != "credential-enforced" { + return fmt.Errorf("workflow.external_authority.mode must be \"hook-only\" or \"credential-enforced\"") + } + if mode == "credential-enforced" && (policy == nil || !filepath.IsAbs(strings.TrimSpace(policy.TrustStore))) { + return fmt.Errorf("workflow.external_authority.trust_store must be an absolute external path for credential-enforced mode") + } + return nil +} + +func ownerID(info os.FileInfo) (uint64, bool) { + value := reflect.ValueOf(info.Sys()) + if !value.IsValid() { + return 0, false + } + if value.Kind() == reflect.Pointer { + value = value.Elem() + } + if !value.IsValid() || value.Kind() != reflect.Struct { + return 0, false + } + uid := value.FieldByName("Uid") + if !uid.IsValid() || !uid.CanUint() { + return 0, false + } + return uid.Uint(), true +} + +// protectedExternalTrustStore refuses trust roots the managed principal can +// replace. Production strict mode therefore requires an operator-provisioned +// file outside the repository under non-writable parent directories. +func protectedExternalTrustStore(path string) error { + path = filepath.Clean(path) + for current := path; ; current = filepath.Dir(current) { + info, err := os.Lstat(current) + if err != nil || info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("external authority trust store path is missing or contains a symlink") + } + if current == path && !info.Mode().IsRegular() { + return fmt.Errorf("external authority trust store is not a regular file") + } + if info.Mode().Perm()&0o022 != 0 { + return fmt.Errorf("external authority trust store path is group- or world-writable") + } + uid, ok := ownerID(info) + if !ok || uid == uint64(os.Geteuid()) { + return fmt.Errorf("external authority trust store path is owned by the managed principal") + } + parent := filepath.Dir(current) + if parent == current { + break + } + } + return nil +} + +func loadExternalTrustStore(policy *ExternalAuthorityPolicy) (map[string]string, error) { + path := filepath.Clean(policy.TrustStore) + if err := authorityTrustStoreProtected(path); err != nil { + return nil, err + } + info, err := os.Lstat(path) + if err != nil || info.Size() > 64*1024 { + return nil, fmt.Errorf("external authority trust store is missing or too large") + } + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var store externalAuthorityTrustStore + if err := DecodeJSON("load external authority trust store", path, raw, &store); err != nil || store.SchemaVersion != 1 || len(store.Issuers) == 0 { + return nil, fmt.Errorf("external authority trust store is malformed") + } + for issuer, encoded := range store.Issuers { + key, err := base64.StdEncoding.DecodeString(encoded) + if strings.TrimSpace(issuer) == "" || err != nil || len(key) != ed25519.PublicKeySize { + return nil, fmt.Errorf("external authority trust store contains an invalid issuer") + } + } + return store.Issuers, nil +} + +func verifyAuthorityBoundary(repo string, policy *ExternalAuthorityPolicy) (string, string) { + if normalizedAuthorityMode(policy) != "credential-enforced" { + return AuthorityHookGuarded, "Boatstack hooks guard known irreversible operations; cloud authority is not externally attested." + } + trustedIssuers, trustErr := loadExternalTrustStore(policy) + if trustErr != nil { + return AuthorityHookGuarded, "external authority trust store is not protected or valid" + } + path := strings.TrimSpace(os.Getenv(AuthorityReceiptEnv)) + if path == "" || !filepath.IsAbs(path) { + return AuthorityHookGuarded, "credential-enforced mode requires an absolute external authority receipt path" + } + info, err := os.Lstat(path) + if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Size() > 64*1024 { + return AuthorityHookGuarded, "external authority receipt is missing, unsafe, or too large" + } + raw, err := os.ReadFile(path) + if err != nil { + return AuthorityHookGuarded, "external authority receipt could not be read" + } + var receipt AuthorityBoundaryReceipt + if err := DecodeJSON("load external authority receipt", path, raw, &receipt); err != nil { + return AuthorityHookGuarded, "external authority receipt is malformed" + } + if receipt.SchemaVersion != 1 || receipt.AuthorityClass != AuthorityClassRepositoryOnly || receipt.CloudControlPlaneAuthority { + return AuthorityHookGuarded, "external authority receipt does not attest repository-only authority" + } + switch receipt.EnforcedBy { + case "service-iam", "credential-broker", "isolated-host": + default: + return AuthorityHookGuarded, "external authority receipt names an unsupported enforcement boundary" + } + context, err := ResolveAuthorityContext(repo) + if err != nil || receipt.RepositoryFingerprint != context.RepositoryFingerprint || receipt.WorktreeFingerprint != context.WorktreeFingerprint { + return AuthorityHookGuarded, "external authority receipt is bound to a different repository or worktree" + } + if receipt.HostSession == "" || receipt.HostSession != strings.TrimSpace(os.Getenv(AuthorityHostSessionEnv)) { + return AuthorityHookGuarded, "external authority receipt is bound to a different host session" + } + if receipt.PrincipalFingerprint == "" || receipt.PrincipalFingerprint != strings.TrimSpace(os.Getenv(AuthorityPrincipalEnv)) { + return AuthorityHookGuarded, "external authority receipt is bound to a different principal" + } + issued, issuedErr := time.Parse(time.RFC3339, receipt.IssuedAt) + expires, expiresErr := time.Parse(time.RFC3339, receipt.ExpiresAt) + now := authorityNow().UTC() + if issuedErr != nil || expiresErr != nil || expires.Before(now) || issued.After(now.Add(time.Minute)) || !expires.After(issued) || expires.Sub(issued) > maxAuthorityReceiptLifetime { + return AuthorityHookGuarded, "external authority receipt is stale or has an invalid lifetime" + } + encodedKey, ok := trustedIssuers[receipt.Issuer] + if !ok { + return AuthorityHookGuarded, "external authority receipt issuer is not trusted" + } + publicKey, keyErr := base64.StdEncoding.DecodeString(encodedKey) + signature, signatureErr := base64.StdEncoding.DecodeString(receipt.Signature) + payload, payloadErr := AuthorityReceiptSigningBytes(receipt) + if keyErr != nil || signatureErr != nil || payloadErr != nil || len(publicKey) != ed25519.PublicKeySize || !ed25519.Verify(ed25519.PublicKey(publicKey), payload, signature) { + return AuthorityHookGuarded, "external authority receipt signature is invalid" + } + return AuthorityCredentialEnforced, "An external authority attests repository-only credentials for this repository, worktree, host session, and principal." +} diff --git a/labs/12-product-engineering-loop/product-engineering-loop/authority_test.go b/labs/12-product-engineering-loop/product-engineering-loop/authority_test.go new file mode 100644 index 000000000..926757ddb --- /dev/null +++ b/labs/12-product-engineering-loop/product-engineering-loop/authority_test.go @@ -0,0 +1,215 @@ +package boatstack + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func authorityFixture(t *testing.T, repo string) (ExternalAuthorityPolicy, AuthorityBoundaryReceipt, ed25519.PrivateKey) { + t.Helper() + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + context, err := ResolveAuthorityContext(repo) + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC) + authorityNow = func() time.Time { return now } + t.Cleanup(func() { authorityNow = time.Now }) + t.Setenv(AuthorityHostSessionEnv, "session-123") + t.Setenv(AuthorityPrincipalEnv, "principal-sha256") + storeValue, err := MarshalJSON(externalAuthorityTrustStore{SchemaVersion: 1, Issuers: map[string]string{"broker": base64.StdEncoding.EncodeToString(publicKey)}}) + if err != nil { + t.Fatal(err) + } + trustStore := filepath.Join(t.TempDir(), "authority-trust-store.json") + if err := os.WriteFile(trustStore, storeValue, 0o600); err != nil { + t.Fatal(err) + } + authorityTrustStoreProtected = func(string) error { return nil } + t.Cleanup(func() { authorityTrustStoreProtected = protectedExternalTrustStore }) + policy := ExternalAuthorityPolicy{Mode: "credential-enforced", TrustStore: trustStore} + receipt := AuthorityBoundaryReceipt{ + SchemaVersion: 1, RepositoryFingerprint: context.RepositoryFingerprint, + WorktreeFingerprint: context.WorktreeFingerprint, HostSession: "session-123", + PrincipalFingerprint: "principal-sha256", AuthorityClass: AuthorityClassRepositoryOnly, + CloudControlPlaneAuthority: false, EnforcedBy: "credential-broker", Issuer: "broker", + IssuedAt: now.Add(-time.Minute).Format(time.RFC3339), ExpiresAt: now.Add(10 * time.Minute).Format(time.RFC3339), + } + return policy, receipt, privateKey +} + +// control-law: categorical-authority-requires-external-attestation +func TestCredentialEnforcedAuthorityRejectsPrincipalOwnedTrustStore(t *testing.T) { + publicKey, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + value, err := MarshalJSON(externalAuthorityTrustStore{SchemaVersion: 1, Issuers: map[string]string{"self": base64.StdEncoding.EncodeToString(publicKey)}}) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "self-authored-trust-store.json") + if err := os.WriteFile(path, value, 0o600); err != nil { + t.Fatal(err) + } + status, reason := verifyAuthorityBoundary(runTestRepo(t), &ExternalAuthorityPolicy{Mode: "credential-enforced", TrustStore: path}) + if status != AuthorityHookGuarded || !strings.Contains(reason, "trust store") { + t.Fatalf("principal-owned trust store was accepted: %s: %s", status, reason) + } +} + +func signAuthorityReceipt(t *testing.T, receipt AuthorityBoundaryReceipt, privateKey ed25519.PrivateKey) AuthorityBoundaryReceipt { + t.Helper() + payload, err := AuthorityReceiptSigningBytes(receipt) + if err != nil { + t.Fatal(err) + } + receipt.Signature = base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, payload)) + return receipt +} + +func writeAuthorityReceipt(t *testing.T, receipt AuthorityBoundaryReceipt) string { + t.Helper() + value, err := MarshalJSON(receipt) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "authority-receipt.json") + if err := os.WriteFile(path, value, 0o600); err != nil { + t.Fatal(err) + } + t.Setenv(AuthorityReceiptEnv, path) + return path +} + +// control-law: categorical-authority-requires-external-attestation +func TestCredentialEnforcedAuthorityReceiptIsAccepted(t *testing.T) { + repo := runTestRepo(t) + policy, receipt, privateKey := authorityFixture(t, repo) + writeAuthorityReceipt(t, signAuthorityReceipt(t, receipt, privateKey)) + status, reason := verifyAuthorityBoundary(repo, &policy) + if status != AuthorityCredentialEnforced || !strings.Contains(reason, "external authority") { + t.Fatalf("valid external authority receipt was rejected: %s: %s", status, reason) + } +} + +// control-law: categorical-authority-requires-external-attestation +func TestCredentialEnforcedAuthorityReceiptRejectsInvalidBindings(t *testing.T) { + tests := []struct { + name string + mutate func(*AuthorityBoundaryReceipt) + env func(*testing.T) + }{ + {"forged signature", func(r *AuthorityBoundaryReceipt) { r.PrincipalFingerprint = "changed-after-signing" }, func(t *testing.T) { t.Setenv(AuthorityPrincipalEnv, "changed-after-signing") }}, + {"stale", func(r *AuthorityBoundaryReceipt) { + r.IssuedAt = "2026-08-04T10:00:00Z" + r.ExpiresAt = "2026-08-04T10:10:00Z" + }, nil}, + {"wrong worktree", func(r *AuthorityBoundaryReceipt) { r.WorktreeFingerprint = "wrong" }, nil}, + {"wrong session", func(r *AuthorityBoundaryReceipt) { r.HostSession = "other" }, nil}, + {"wrong principal", func(r *AuthorityBoundaryReceipt) { r.PrincipalFingerprint = "other" }, nil}, + {"overprivileged", func(r *AuthorityBoundaryReceipt) { r.CloudControlPlaneAuthority = true }, nil}, + {"self-authored issuer", func(r *AuthorityBoundaryReceipt) { r.Issuer = "untrusted" }, nil}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + repo := runTestRepo(t) + policy, receipt, privateKey := authorityFixture(t, repo) + receipt = signAuthorityReceipt(t, receipt, privateKey) + test.mutate(&receipt) + if test.name != "forged signature" { + receipt = signAuthorityReceipt(t, receipt, privateKey) + } + if test.env != nil { + test.env(t) + } + writeAuthorityReceipt(t, receipt) + status, _ := verifyAuthorityBoundary(repo, &policy) + if status != AuthorityHookGuarded { + t.Fatalf("invalid %s receipt was accepted: %s", test.name, status) + } + }) + } +} + +// control-law: categorical-authority-requires-external-attestation +func TestCredentialEnforcedModeFailsClosedWithoutReceiptAndPreservesEffect(t *testing.T) { + repo := runTestRepo(t) + t.Setenv(AuthorityReceiptEnv, "") + config := testConfig() + config.Project.DefaultBranch = "main" + config.Adapters = []string{"cursor"} + config.Workflow.ExternalAuthority = &ExternalAuthorityPolicy{Mode: "credential-enforced", TrustStore: filepath.Join(t.TempDir(), "trust-store.json")} + value, err := MarshalJSON(config) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, ".product-loop", "project.json"), value, 0o644); err != nil { + t.Fatal(err) + } + status := CheckRunPreflight(repo, "") + externalEffectApplied := false + if status.VerificationStatus == "VERIFIED" { + externalEffectApplied = true + } + if status.Relation != "AUTHORITY_BOUNDARY" || status.AuthorityStatus != AuthorityHookGuarded || externalEffectApplied { + t.Fatalf("missing receipt did not fail closed before the effect: %+v effect=%t", status, externalEffectApplied) + } +} + +// control-law: categorical-authority-requires-external-attestation +func TestRunPreflightReportsCredentialEnforcedForValidReceipt(t *testing.T) { + repo := runTestRepo(t) + policy, receipt, privateKey := authorityFixture(t, repo) + config := testConfig() + config.Project.DefaultBranch = "main" + config.Adapters = []string{"cursor"} + config.Workflow.ExternalAuthority = &policy + value, err := MarshalJSON(config) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, ".product-loop", "project.json"), value, 0o644); err != nil { + t.Fatal(err) + } + writeAuthorityReceipt(t, signAuthorityReceipt(t, receipt, privateKey)) + remote := filepath.Join(t.TempDir(), "origin.git") + if output, err := exec.Command("git", "init", "--bare", remote).CombinedOutput(); err != nil { + t.Fatalf("git init --bare: %v: %s", err, output) + } + for _, args := range [][]string{ + {"remote", "add", "origin", remote}, + {"push", "-u", "origin", "main"}, + {"switch", "-c", "feature"}, + } { + if output, err := exec.Command("git", append([]string{"-C", repo}, args...)...).CombinedOutput(); err != nil { + t.Fatalf("git %s: %v: %s", strings.Join(args, " "), err, output) + } + } + status := CheckRunPreflight(repo, "") + if status.VerificationStatus != "VERIFIED" || status.AuthorityStatus != AuthorityCredentialEnforced { + t.Fatalf("valid receipt did not admit the managed run: %+v", status) + } +} + +func TestExternalAuthorityPolicyValidation(t *testing.T) { + config := testConfig() + config.Workflow.ExternalAuthority = &ExternalAuthorityPolicy{Mode: "credential-enforced"} + if err := ValidateConfig(config); err == nil || !strings.Contains(err.Error(), "trust_store") { + t.Fatalf("credential-enforced mode accepted no external trust store: %v", err) + } + config.Workflow.ExternalAuthority = &ExternalAuthorityPolicy{Mode: "unknown"} + if err := ValidateConfig(config); err == nil || !strings.Contains(err.Error(), "external_authority.mode") { + t.Fatalf("unknown external authority mode was accepted: %v", err) + } +} diff --git a/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/coverage_conformance_test.go b/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/coverage_conformance_test.go index 086975ec7..bfab53414 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/coverage_conformance_test.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/coverage_conformance_test.go @@ -49,15 +49,16 @@ var nonDeliveryVerbs = map[string]bool{ "record-approval": true, "record-autonomy": true, // Read-only status / diagnostics (observe helpers, not modeled transitions). - "repair-status": true, - "operation-status": true, - "mutation-status": true, - "run-preflight": true, - "check-safety": true, - "doctor": true, - "diagnose-hook": true, - "render-denial": true, - "workspace-status": true, + "repair-status": true, + "operation-status": true, + "mutation-status": true, + "run-preflight": true, + "authority-context": true, + "check-safety": true, + "doctor": true, + "diagnose-hook": true, + "render-denial": true, + "workspace-status": true, // Evidence / capability substrate (a separate tenant, not the delivery graph). "record-pr-visual-evidence": true, "capture-evidence": true, diff --git a/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/main.go b/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/main.go index efb033d24..dd0d116ed 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/main.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/main.go @@ -965,7 +965,7 @@ func runPreflightCommand(arguments []string) int { } fmt.Print(string(value)) } else { - fmt.Printf("Boatstack run preflight: %s\nReason: %s\n", status.VerificationStatus, status.Reason) + fmt.Printf("Boatstack run preflight: %s\nAuthority: %s\nAuthority reason: %s\nReason: %s\n", status.VerificationStatus, status.AuthorityStatus, status.AuthorityReason, status.Reason) } if status.VerificationStatus != "VERIFIED" { return 1 @@ -973,6 +973,24 @@ func runPreflightCommand(arguments []string) int { return 0 } +func authorityContextCommand(arguments []string) int { + flags := flag.NewFlagSet("authority-context", flag.ContinueOnError) + repo := flags.String("repo", ".", "repository whose authority binding should be projected") + if err := flags.Parse(arguments); err != nil { + return 2 + } + context, err := boatstack.ResolveAuthorityContext(*repo) + if err != nil { + return fail(err) + } + value, err := boatstack.MarshalJSON(context) + if err != nil { + return fail(err) + } + fmt.Print(string(value)) + return 0 +} + func recordChangeCommand(arguments []string) int { flags := flag.NewFlagSet("record-change", flag.ContinueOnError) options := boatstack.ChangeObservationOptions{} @@ -1546,7 +1564,7 @@ func workspaceSyncCommand(arguments []string) int { func run() int { if len(os.Args) < 2 { - fmt.Fprintln(os.Stderr, "usage: boatstack-helper ") + fmt.Fprintln(os.Stderr, "usage: boatstack-helper ") return 2 } switch os.Args[1] { @@ -1608,6 +1626,8 @@ func run() int { return undoCommand(os.Args[2:]) case "run-preflight": return runPreflightCommand(os.Args[2:]) + case "authority-context": + return authorityContextCommand(os.Args[2:]) case "record-change": return recordChangeCommand(os.Args[2:]) case "record-journey-results": diff --git a/labs/12-product-engineering-loop/product-engineering-loop/config_documentation_test.go b/labs/12-product-engineering-loop/product-engineering-loop/config_documentation_test.go index cdefe1479..5f38ff329 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/config_documentation_test.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/config_documentation_test.go @@ -127,6 +127,8 @@ func TestPublicConfigurationGuideContainsOnlySupportedUserControls(t *testing.T) "project.high_risk_paths", "workflow.allow_pass_with_gaps", "workflow.boundary_analysis", + "workflow.external_authority.mode", + "workflow.external_authority.trust_store", "workflow.human_plan_approval", "workflow.ignored_deliveries", "workflow.independent_review_for_high_risk", diff --git a/labs/12-product-engineering-loop/product-engineering-loop/export.go b/labs/12-product-engineering-loop/product-engineering-loop/export.go index 6ce4d38db..f96700bd5 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/export.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/export.go @@ -149,6 +149,9 @@ func ValidateConfig(config ProjectConfig) error { if err := validateVisualEvidencePublish(config.Workflow.VisualEvidencePublish); err != nil { return err } + if err := validateExternalAuthorityPolicy(config.Workflow.ExternalAuthority); err != nil { + return err + } for _, surface := range config.Project.VisualSurfaces { if !surfaceSlugPattern.MatchString(surface.ID) || len(surface.Paths) == 0 { return fmt.Errorf("project.visual_surfaces require a lowercase-kebab id and at least one path") diff --git a/labs/12-product-engineering-loop/product-engineering-loop/references/config-schema.md b/labs/12-product-engineering-loop/product-engineering-loop/references/config-schema.md index 0fdfe867e..aba83f0ee 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/references/config-schema.md +++ b/labs/12-product-engineering-loop/product-engineering-loop/references/config-schema.md @@ -23,6 +23,9 @@ boatstack-config-field:workflow.visual_evidence_publish boatstack-config-field:workflow.visual_evidence_publish.mode boatstack-config-field:workflow.visual_evidence_publish.host boatstack-config-field:workflow.visual_evidence_publish.expiry +boatstack-config-field:workflow.external_authority +boatstack-config-field:workflow.external_authority.mode +boatstack-config-field:workflow.external_authority.trust_store boatstack-config-field:workflow.ignored_deliveries boatstack-config-field:delivery boatstack-config-field:delivery.terminal @@ -96,6 +99,9 @@ This is the exhaustive serialization contract, not a list of recommended user ed - `mode` (string, optional): Compatibility value `external-host`; external hosting is always used. - `host` (string, optional): `litterbox` (default) or `catbox`. Only meaningful when `mode` is `external-host`. `litterbox` auto-expires uploads; `catbox` is permanent. - `expiry` (string, optional): `1h`, `12h`, `24h`, or `72h` (default `72h`). Only meaningful for an expiring host; the PR comment reminds reviewers of the host and this window. +- `external_authority` (object, optional): Declares the credential boundary for managed runs. Omission or `mode: "hook-only"` reports `HOOK_GUARDED`; it never claims cloud credentials are constrained. `mode: "credential-enforced"` requires a short-lived external receipt signed by an issuer in a protected external trust store. + - `mode` (string): `hook-only` or `credential-enforced`. + - `trust_store` (string): Absolute path to an operator-provisioned JSON file mapping issuer IDs to base64 Ed25519 public keys. Boatstack rejects a trust store or parent directory owned or writable by the managed principal and never holds a signing key. - `ignored_deliveries` (array of strings, optional): Deterministic ambiguity control. Feature slugs of past deliveries to exclude from delivery-ambiguity resolution so historical work no longer blocks new work. New, unlisted ambiguous deliveries still pause the workflow. ### workspace Fields diff --git a/labs/12-product-engineering-loop/product-engineering-loop/references/irreversible-operation-boundary.md b/labs/12-product-engineering-loop/product-engineering-loop/references/irreversible-operation-boundary.md index 246d0c610..ec644bdcc 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/references/irreversible-operation-boundary.md +++ b/labs/12-product-engineering-loop/product-engineering-loop/references/irreversible-operation-boundary.md @@ -10,6 +10,8 @@ The guard always denies: - recursive removal of repository, home, root, parent, or wildcard targets; - destructive Git cleanup, hard resets, and forced remote-history replacement; - cloud, project, database, cluster, namespace, or volume destruction; +- Supabase branch deletion and lifecycle weakening that makes a protected branch deletable; +- public or unauthenticated service exposure through supported cloud and cluster control planes; - disabling recovery or deleting backups and snapshots. There is no break-glass token or in-session override. Intentional destructive recovery belongs to a separately controlled operator surface outside Boatstack. Agents may edit source that describes a dangerous operation for review, but may not execute it; an operational diff containing that capability blocks build activation and subsequent gates until it is removed or transferred to the operator boundary. @@ -32,6 +34,12 @@ Planning declares each external side effect with its kind, immutable target iden Project hooks are deterministic interception, not a complete security sandbox. Host APIs can change, some tool surfaces may not expose hooks, and an agent can possess credentials broader than the repository intends. [Codex requires project-local hooks and their exact definitions to be trusted](https://learn.chatgpt.com/docs/hooks); [Claude documents that command hooks run with the user's full permissions](https://code.claude.com/docs/en/hooks); Cursor documents pre-shell and pre-MCP interception but host enablement remains a separate trust boundary, and a current fast-exit race can drop hook output. Protected services still require least-privilege credentials, scoped roles, backups, and service-side approval for destructive administration. `doctor` verifies generated contracts, launchers, helper version, and fail-closed smoke behavior, then reports host activation as an operator verification step rather than claiming that repository structure proves the host actually loaded the hook. +Managed-run preflight reports this distinction directly. `HOOK_GUARDED` means the deterministic hook blocks recognized unsafe effects, but ambient cloud authority is not proven absent. `CREDENTIAL_ENFORCED` requires `workflow.external_authority.mode: "credential-enforced"`, an operator-provisioned trust store outside the managed principal's writable boundary, and a short-lived Ed25519-signed receipt from service IAM, a credential broker, or an isolated host. The receipt binds the repository, worktree, host session, principal, issuer, enforcement mechanism, and expiry, and must attest `repository-only` authority with no cloud control-plane capability. Missing, stale, mismatched, overprivileged, self-authored, or invalidly signed receipts block the run before delivery mutation. + +The external attestor obtains the expected repository and worktree fingerprints from `boatstack-helper authority-context --repo .`. The managed host supplies the absolute receipt path in `BOATSTACK_AUTHORITY_RECEIPT`, the session binding in `BOATSTACK_HOST_SESSION`, and the attested principal fingerprint in `BOATSTACK_PRINCIPAL_FINGERPRINT`. These coordinates are bindings, not credentials, and must contain no secret material. + +The receipt is strict JSON with `schema_version: 1`, the two context fingerprints, `host_session`, `principal_fingerprint`, `authority_class: "repository-only"`, `cloud_control_plane_authority: false`, `enforced_by` (`service-iam`, `credential-broker`, or `isolated-host`), `issuer`, RFC 3339 `issued_at` and `expires_at`, and a base64 Ed25519 `signature`. Its maximum lifetime is 15 minutes. The signing payload is the compact JSON returned by `AuthorityReceiptSigningBytes` with `signature` set to the empty string; unknown or duplicate fields are rejected. + ## Evaluation status This guard is a **PROPOSED** Move. Existing benchmark evidence supports deterministic protocol enforcement over stronger prompting, and a sanitized database incident establishes the target mechanism: failed external operation -> scope drift -> invented destructive recovery. The exact guard is not promoted until paired evaluation demonstrates zero destructive executions, retained safe diagnostics and transactional operations, bounded latency, no secret-bearing denial logs, and no workflow regression. diff --git a/labs/12-product-engineering-loop/product-engineering-loop/references/workflow.md b/labs/12-product-engineering-loop/product-engineering-loop/references/workflow.md index b48e3d61b..70b9e5714 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/references/workflow.md +++ b/labs/12-product-engineering-loop/product-engineering-loop/references/workflow.md @@ -50,7 +50,7 @@ After an external-write failure, preserve state and use only read-only diagnosis Repository administration is not a delivery transition. Branch synchronization, status, switching, worktree maintenance, and requests to discard local changes do not enter `auto-plan` or `repair` unless the exact target branch belongs to an active managed delivery. Use the project-local `workspace-sync` helper for recoverable branch alignment: it fetches the exact remote source, preserves the original branch and dirty worktree under verified Git refs, updates the branch in its owning worktree, and verifies the final ref and clean status. A raw destructive-Git denial must return this one recovery action immediately without plan inspection or repository-wide discovery. -Hooks are defense in depth rather than a complete sandbox. Protected systems still require least-privilege credentials, scoped service roles, backups, and service-side destructive approval. A missing, drifted, or failing helper denies execution and requires reinstall or repair. Cursor's exact `MainThreadShellExec not initialized` error occurs before the Boatstack hook starts; preserve fail-closed behavior, reload the Cursor window, and retry before diagnosing the Boatstack installation. +Hooks are defense in depth rather than a complete sandbox. Protected systems still require least-privilege credentials, scoped service roles, backups, and service-side destructive approval. `run-preflight` reports `HOOK_GUARDED` for this default posture and never presents it as credential isolation. Repositories configured for `credential-enforced` mode block before delivery mutation unless a trusted external attestor supplies a current repository-only receipt; only that path reports `CREDENTIAL_ENFORCED`. A missing, drifted, or failing helper denies execution and requires reinstall or repair. Cursor's exact `MainThreadShellExec not initialized` error occurs before the Boatstack hook starts; preserve fail-closed behavior, reload the Cursor window, and retry before diagnosing the Boatstack installation. ## User-facing response contract diff --git a/labs/12-product-engineering-loop/product-engineering-loop/run.go b/labs/12-product-engineering-loop/product-engineering-loop/run.go index fa87cd1d3..5185caf06 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/run.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/run.go @@ -6,7 +6,7 @@ import ( "strings" ) -const runPreflightSchemaVersion = 1 +const runPreflightSchemaVersion = 2 var runGitCommand = gitCommand @@ -19,16 +19,28 @@ type RunPreflight struct { HeadBranch string `json:"head_branch,omitempty"` Upstream string `json:"upstream,omitempty"` Relation string `json:"relation,omitempty"` + AuthorityStatus string `json:"authority_status"` + AuthorityReason string `json:"authority_reason"` Reason string `json:"reason"` } func blockedRunPreflight(base, head, upstream, relation, reason string) RunPreflight { return RunPreflight{ SchemaVersion: runPreflightSchemaVersion, VerificationStatus: "BLOCKED", - BaseBranch: base, HeadBranch: head, Upstream: upstream, Relation: relation, Reason: reason, + BaseBranch: base, HeadBranch: head, Upstream: upstream, Relation: relation, + AuthorityStatus: AuthorityHookGuarded, + AuthorityReason: "Boatstack hooks guard known irreversible operations; cloud authority is not externally attested.", + Reason: reason, } } +func blockedRunPreflightWithAuthority(base, head, upstream, relation, reason, authorityStatus, authorityReason string) RunPreflight { + status := blockedRunPreflight(base, head, upstream, relation, reason) + status.AuthorityStatus = authorityStatus + status.AuthorityReason = authorityReason + return status +} + func runBranches(repo, explicitFeature string) (string, string, error) { base := defaultPRBase(repo) head, err := runGitCommand(repo, "branch", "--show-current") @@ -98,23 +110,34 @@ func CheckRunPreflight(repoPath, explicitFeature string) RunPreflight { if !fileExists(WorkspaceFor(repo).ProjectConfigPath()) { return blockedRunPreflight("", "", "", "NOT_INITIALIZED", "This repository has no Boatstack project installation to run.") } + config, _, configErr := LoadConfig(WorkspaceFor(repo).ProjectConfigPath()) + if configErr != nil { + return blockedRunPreflight("", "", "", "INVALID_CONFIG", "Boatstack could not validate the project configuration.") + } + authorityStatus, authorityReason := verifyAuthorityBoundary(repo, config.Workflow.ExternalAuthority) + block := func(base, head, upstream, relation, reason string) RunPreflight { + return blockedRunPreflightWithAuthority(base, head, upstream, relation, reason, authorityStatus, authorityReason) + } + if normalizedAuthorityMode(config.Workflow.ExternalAuthority) == "credential-enforced" && authorityStatus != AuthorityCredentialEnforced { + return block("", "", "", "AUTHORITY_BOUNDARY", authorityReason) + } if _, err := runGitCommand(repo, "remote", "get-url", "origin"); err != nil { - return blockedRunPreflight("", "", "", "MISSING_ORIGIN", "Boatstack run requires a usable origin remote.") + return block("", "", "", "MISSING_ORIGIN", "Boatstack run requires a usable origin remote.") } if _, err := runGitCommand(repo, "fetch", "origin"); err != nil { - return blockedRunPreflight("", "", "", "FETCH_FAILED", "Boatstack could not fetch origin: "+err.Error()) + return block("", "", "", "FETCH_FAILED", "Boatstack could not fetch origin: "+err.Error()) } base, head, err := runBranches(repo, explicitFeature) if err != nil { - return blockedRunPreflight(base, head, "", "BRANCH_MISMATCH", err.Error()) + return block(base, head, "", "BRANCH_MISMATCH", err.Error()) } remoteBase := "refs/remotes/origin/" + base if _, err := runGitCommand(repo, "rev-parse", "--verify", remoteBase+"^{commit}"); err != nil { - return blockedRunPreflight(base, head, "", "MISSING_REMOTE_BASE", fmt.Sprintf("Fetched origin does not contain base branch %s.", base)) + return block(base, head, "", "MISSING_REMOTE_BASE", fmt.Sprintf("Fetched origin does not contain base branch %s.", base)) } if _, err := runGitCommand(repo, "merge-base", "--is-ancestor", remoteBase, "HEAD"); err != nil { - return blockedRunPreflight(base, head, "", "STALE_BASE", fmt.Sprintf("Current branch %s does not contain fetched origin/%s; synchronize it outside Boatstack run.", head, base)) + return block(base, head, "", "STALE_BASE", fmt.Sprintf("Current branch %s does not contain fetched origin/%s; synchronize it outside Boatstack run.", head, base)) } upstream, upstreamErr := runGitCommand(repo, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}") @@ -122,22 +145,22 @@ func CheckRunPreflight(repoPath, explicitFeature string) RunPreflight { if upstreamErr == nil && upstream != "" { counts, countErr := runGitCommand(repo, "rev-list", "--left-right", "--count", "HEAD...@{upstream}") if countErr != nil { - return blockedRunPreflight(base, head, upstream, "UPSTREAM_UNKNOWN", "Boatstack could not compare the current branch with its upstream: "+countErr.Error()) + return block(base, head, upstream, "UPSTREAM_UNKNOWN", "Boatstack could not compare the current branch with its upstream: "+countErr.Error()) } fields := strings.Fields(counts) if len(fields) != 2 { - return blockedRunPreflight(base, head, upstream, "UPSTREAM_UNKNOWN", "Boatstack received an invalid Git upstream comparison.") + return block(base, head, upstream, "UPSTREAM_UNKNOWN", "Boatstack received an invalid Git upstream comparison.") } ahead, aheadErr := strconv.Atoi(fields[0]) behind, behindErr := strconv.Atoi(fields[1]) if aheadErr != nil || behindErr != nil { - return blockedRunPreflight(base, head, upstream, "UPSTREAM_UNKNOWN", "Boatstack received an invalid Git upstream comparison.") + return block(base, head, upstream, "UPSTREAM_UNKNOWN", "Boatstack received an invalid Git upstream comparison.") } switch { case ahead > 0 && behind > 0: - return blockedRunPreflight(base, head, upstream, "DIVERGED", fmt.Sprintf("Current branch %s has diverged from %s; synchronize it outside Boatstack run.", head, upstream)) + return block(base, head, upstream, "DIVERGED", fmt.Sprintf("Current branch %s has diverged from %s; synchronize it outside Boatstack run.", head, upstream)) case behind > 0: - return blockedRunPreflight(base, head, upstream, "BEHIND", fmt.Sprintf("Current branch %s is behind %s; synchronize it outside Boatstack run.", head, upstream)) + return block(base, head, upstream, "BEHIND", fmt.Sprintf("Current branch %s is behind %s; synchronize it outside Boatstack run.", head, upstream)) case ahead > 0: relation = "AHEAD" default: @@ -148,6 +171,7 @@ func CheckRunPreflight(repoPath, explicitFeature string) RunPreflight { return RunPreflight{ SchemaVersion: runPreflightSchemaVersion, VerificationStatus: "VERIFIED", BaseBranch: base, HeadBranch: head, Upstream: upstream, Relation: relation, + AuthorityStatus: authorityStatus, AuthorityReason: authorityReason, Reason: "Origin was fetched and the current branch is fresh enough to run Boatstack.", } } diff --git a/labs/12-product-engineering-loop/product-engineering-loop/runtime.go b/labs/12-product-engineering-loop/product-engineering-loop/runtime.go index 9ddeb6eef..c4c6286e2 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/runtime.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/runtime.go @@ -103,6 +103,10 @@ type Workflow struct { MaintainChangelog bool `json:"maintain_changelog"` BoundaryAnalysis bool `json:"boundary_analysis,omitempty"` PRVisualEvidence string `json:"pr_visual_evidence,omitempty"` + // ExternalAuthority distinguishes hook-only interception from an externally + // attested repository-only credential boundary. Omission preserves existing + // installs as HOOK_GUARDED and never implies credential enforcement. + ExternalAuthority *ExternalAuthorityPolicy `json:"external_authority,omitempty"` // VisualEvidencePublish selects how programmatic visual evidence reaches a PR. // The nil zero value uses Litterbox with a 72-hour expiry. Publication still // requires explicit human privacy review for every screenshot. diff --git a/labs/12-product-engineering-loop/product-engineering-loop/safety.go b/labs/12-product-engineering-loop/product-engineering-loop/safety.go index 7f5725fec..7b4c3f195 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/safety.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/safety.go @@ -67,7 +67,7 @@ func malformedHookInput(code string) error { // idioms — recovery-status | jq, git diff | wc -l, … | sort | uniq -c — compose // freely. Effect-CHANGING syntax (redirection > <, command substitution $()) is // still banned in isPureReadOnlyCommand, so no filter can be turned into a writer. -var readOnlyStage = regexp.MustCompile(`(?i)^\s*(?:env\s+[^ ]+\s+)*(?:rg|grep|git\s+(?:grep|diff|status|show|log)|cat|sed|head|tail|less|wc|awk|sort|uniq|cut|tr|jq|column|nl|comm|rev|fold|find\s+[^\n]*-(?:print|ls)|psql\s+[^\n]*\s-c\s+["']?\s*select\b|(?:[^\s]*/)?boatstack-helper(?:[_.-][a-z0-9._-]+)?\s+(?:recovery-status|mutation-status|operation-status|delivery-status|next-status|workspace-status|repair-status|check-plan|check-source-plan|check-safety|diagnose-hook|doctor|version)\b|(?:[^\s]*/)?boatstack-helper(?:[_.-][a-z0-9._-]+)?\s+insight\s+(?:check|list|show|frontier|evaluate)\b)`) +var readOnlyStage = regexp.MustCompile(`(?i)^\s*(?:env\s+[^ ]+\s+)*(?:rg|grep|git\s+(?:grep|diff|status|show|log)|cat|sed|head|tail|less|wc|awk|sort|uniq|cut|tr|jq|column|nl|comm|rev|fold|find\s+[^\n]*-(?:print|ls)|psql\s+[^\n]*\s-c\s+["']?\s*select\b|(?:[^\s]*/)?boatstack-helper(?:[_.-][a-z0-9._-]+)?\s+(?:recovery-status|mutation-status|operation-status|delivery-status|next-status|workspace-status|repair-status|check-plan|check-source-plan|check-safety|diagnose-hook|authority-context|doctor|version)\b|(?:[^\s]*/)?boatstack-helper(?:[_.-][a-z0-9._-]+)?\s+insight\s+(?:check|list|show|frontier|evaluate)\b)`) // Constitutional/Optimization split. These destruction rules are CONSTITUTIONAL: // they define the real boundary (destroying a live resource) and are never traded @@ -95,6 +95,9 @@ var irreversiblePatterns = []struct { }{ {"database-destruction", "database or schema destruction is operator-only", regexp.MustCompile(`(?is)\bdrop\s+(?:database|schema|table)\b|\balter\s+table\b[^;\n]*\bdrop\s+(?:column|constraint)\b|\btruncate(?:\s+table)?\b|\bdrop\s+schema\b[^;\n]*\bcascade\b`), true}, {"database-reset", "database reset, flush, or destructive downgrade is operator-only", regexp.MustCompile(`(?i)(?:--reset-public\b|\b(?:supabase\s+db\s+reset|prisma\s+migrate\s+reset|rails\s+db:(?:drop|reset)|django-admin\s+flush|manage\.py\s+flush|alembic\s+downgrade\s+base|pg_restore\b[^\n]*\s--clean\b))`), false}, + {"external-resource-destruction", "external resource destruction is operator-only", regexp.MustCompile(`(?i)\bsupabase[\s"',()]+branches?[\s"',()]+delete\b`), false}, + {"external-lifecycle-weakening", "weakening external resource lifecycle protection is operator-only", regexp.MustCompile(`(?i)\bsupabase[\s"',()]+branches?[\s"',()]+update\b[^\n;&|]*(?:--persistent(?:=|[\s"',()]+)false\b|--no-persistent\b)`), false}, + {"external-public-exposure", "public or unauthenticated service exposure is operator-only", regexp.MustCompile(`(?i)(?:\bgcloud\s+run\s+(?:deploy|services\s+update)\b[^\n;&|]*--allow-unauthenticated\b|\bgcloud\s+(?:projects|run\s+services)\s+(?:add-iam-policy-binding|set-iam-policy)\b[^\n;&|]*(?:allUsers|roles/run\.invoker)|\bkubectl\s+(?:create|expose|patch|apply)\b[^\n;&|]*(?:type[=:](?:LoadBalancer|NodePort)|0\.0\.0\.0/0)|\baws\s+[^\n;&|]*(?:authorize-security-group-ingress|put-public-access-block)\b[^\n;&|]*(?:0\.0\.0\.0/0|block-public-acls\s+false))`), false}, {"filesystem-destruction", "recursive deletion of a broad or protected path is denied", regexp.MustCompile(`(?i)\b(?:rm\s+-[^\n;]*(?:r[^\n;]*f|f[^\n;]*r)|remove-item\s+[^\n;]*-recurse[^\n;]*-force)\s+(?:["']?(?:/|~|\$home|\$HOME|\.|\.\.)["']?\s*(?:;|&&|\|\||$)|[^\s;]*\*[^\s;]*)`), false}, {"git-history-destruction", "destructive Git cleanup or history replacement is denied", regexp.MustCompile(`(?i)\bgit\s+(?:reset\s+--hard\b|clean\s+-[^\s]*(?:f[^\s]*d|d[^\s]*f|x)[^\s]*|push\b[^\n]*(?:--force(?:-with-lease)?|-f\b))`), false}, {"infrastructure-destruction", "cloud or infrastructure destruction is operator-only", regexp.MustCompile(`(?i)\b(?:terraform|tofu|pulumi)\s+destroy\b|\bkubectl\s+delete\s+(?:namespace|cluster|persistentvolume|persistentvolumeclaim|pvc)\b|\bdocker\s+volume\s+(?:rm|prune)\b|\bgcloud\s+(?:projects|sql\s+instances|compute\s+(?:instances|disks))\s+delete\b|\baws\s+[^\n]*(?:delete-cluster|delete-db-instance|terminate-instances|delete-volume|delete-bucket)\b`), false}, @@ -173,7 +176,7 @@ var featuresCommandPathPattern = regexp.MustCompile(`(?i)(?:^|[\s"'=(])((?:\./)? var readOnlyHelperVerbs = map[string]bool{ "check-plan": true, "check-source-plan": true, "next-status": true, "delivery-status": true, "recovery-status": true, "repair-status": true, "operation-status": true, "check-safety": true, "workspace-status": true, "diagnose-hook": true, - "doctor": true, "version": true, "mutation-status": true, + "doctor": true, "version": true, "mutation-status": true, "authority-context": true, } // stageIndependentRecoveryVerbs mutate but self-guard, and are admitted at @@ -933,6 +936,12 @@ func ClassifyTool(repo, name string, input any) []SafetyFinding { if regexp.MustCompile(`(?:delete|destroy|reset|drop|truncate|terminate)`).MatchString(nameLower) && regexp.MustCompile(`(?:database|schema|project|cluster|namespace|volume|bucket|backup|snapshot|instance)`).MatchString(strings.ToLower(combined)) { findings = append(findings, SafetyFinding{Category: "external-resource-destruction", Reason: "destructive external-resource tools are operator-only", Source: "tool-input"}) } + if regexp.MustCompile(`(?:delete|destroy|remove|recreate)`).MatchString(nameLower) && regexp.MustCompile(`(?:branch|service|deployment|application|environment|resource)`).MatchString(strings.ToLower(combined)) { + findings = append(findings, SafetyFinding{Category: "external-resource-destruction", Reason: "destructive external-resource tools are operator-only", Source: "tool-input"}) + } + if regexp.MustCompile(`(?:public|unauthenticated|allow[_-]?unauthenticated|iam[_-]?policy|ingress)`).MatchString(strings.ToLower(combined)) && regexp.MustCompile(`(?:create|update|set|add|patch|apply|expose|deploy)`).MatchString(nameLower) { + findings = append(findings, SafetyFinding{Category: "external-public-exposure", Reason: "public or unauthenticated service exposure is operator-only", Source: "tool-input"}) + } return dedupeFindings(findings) } diff --git a/labs/12-product-engineering-loop/product-engineering-loop/safety_test.go b/labs/12-product-engineering-loop/product-engineering-loop/safety_test.go index 9b127030b..4e5c9768c 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/safety_test.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/safety_test.go @@ -46,23 +46,28 @@ func writeValidSavedFeaturePlan(t *testing.T, repo, feature string) string { func TestIrreversibleCommandCorpusIsDenied(t *testing.T) { repo := safetyTestRepo(t) cases := map[string]string{ - "schema drop": `psql -c "DROP SCHEMA public CASCADE"`, - "truncate": `psql -c "TRUNCATE TABLE accounts"`, - "unbounded delete": `psql -c "DELETE FROM accounts"`, - "multiline update": "psql <<'SQL'\nUPDATE accounts\nSET active = false;\nSQL", - "database reset": `supabase db reset`, - "recursive root": `rm -rf /`, - "wildcard deletion": `rm -rf build/*`, - "compound pipeline": `rg reset scripts | rm -rf .`, - "subshell": `echo $(git reset --hard HEAD~1)`, - "environment prefix": `TARGET=dev sh -c 'psql -c "DROP SCHEMA public CASCADE"'`, - "hard reset": `git reset --hard HEAD~1`, - "force push": `git push --force origin main`, - "cloud deletion": `gcloud sql instances delete primary`, - "namespace deletion": `kubectl delete namespace production`, - "volume deletion": `docker volume rm data-volume`, - "backup deletion": `aws rds delete-db-snapshot --db-snapshot-identifier backup-1`, - "powershell": `Remove-Item -Recurse -Force $HOME`, + "schema drop": `psql -c "DROP SCHEMA public CASCADE"`, + "truncate": `psql -c "TRUNCATE TABLE accounts"`, + "unbounded delete": `psql -c "DELETE FROM accounts"`, + "multiline update": "psql <<'SQL'\nUPDATE accounts\nSET active = false;\nSQL", + "database reset": `supabase db reset`, + "Supabase branch delete": `supabase branches delete dev_engineer --project-ref example --yes`, + "Supabase lifecycle downgrade": `supabase branches update dev_engineer --project-ref example --persistent=false --yes`, + "Cloud Run public exposure": `gcloud run deploy api --allow-unauthenticated`, + "Cloud Run public IAM": `gcloud run services add-iam-policy-binding api --member=allUsers --role=roles/run.invoker`, + "Kubernetes public service": `kubectl expose deployment api --type=LoadBalancer`, + "recursive root": `rm -rf /`, + "wildcard deletion": `rm -rf build/*`, + "compound pipeline": `rg reset scripts | rm -rf .`, + "subshell": `echo $(git reset --hard HEAD~1)`, + "environment prefix": `TARGET=dev sh -c 'psql -c "DROP SCHEMA public CASCADE"'`, + "hard reset": `git reset --hard HEAD~1`, + "force push": `git push --force origin main`, + "cloud deletion": `gcloud sql instances delete primary`, + "namespace deletion": `kubectl delete namespace production`, + "volume deletion": `docker volume rm data-volume`, + "backup deletion": `aws rds delete-db-snapshot --db-snapshot-identifier backup-1`, + "powershell": `Remove-Item -Recurse -Force $HOME`, } for name, command := range cases { t.Run(name, func(t *testing.T) { @@ -73,6 +78,118 @@ func TestIrreversibleCommandCorpusIsDenied(t *testing.T) { } } +// control-law: external-control-plane-effects-stay-operator-only +func TestExternalControlPlaneReadOnlyCommandsRemainAllowed(t *testing.T) { + repo := safetyTestRepo(t) + for _, command := range []string{ + "supabase branches list --project-ref example -o json", + "supabase branches get dev_engineer --project-ref example", + "gcloud run services describe api", + } { + if findings := ClassifyCommand(repo, command); len(findings) != 0 { + t.Fatalf("read-only control-plane command was denied: %s: %#v", command, findings) + } + } +} + +// control-law: external-control-plane-effects-stay-operator-only +func TestExternalControlPlaneInvocationVariantsAreDenied(t *testing.T) { + repo := safetyTestRepo(t) + for name, command := range map[string]string{ + "whitespace": "supabase\tbranches\tdelete dev_engineer", + "environment prefix": "env SUPABASE_ACCESS_TOKEN=example supabase branches delete dev_engineer", + "shell": "sh -c 'supabase branches update dev_engineer --persistent=false'", + "tokenized argv": `python -c "import subprocess; subprocess.run(['supabase','branches','delete','dev_engineer'])"`, + } { + t.Run(name, func(t *testing.T) { + if findings := ClassifyCommand(repo, command); len(findings) == 0 { + t.Fatalf("external mutation variant was allowed: %s", command) + } + }) + } +} + +// control-law: external-control-plane-effects-stay-operator-only +func TestSupabaseRecreateWrapperIsDeniedThroughExecutedFileInspection(t *testing.T) { + repo := safetyTestRepo(t) + script := filepath.Join(repo, "scripts", "dev_environment.py") + if err := os.MkdirAll(filepath.Dir(script), 0o755); err != nil { + t.Fatal(err) + } + value := []byte("import subprocess\nsubprocess.run(['supabase', 'branches', 'delete', 'dev_engineer'])\n") + if err := os.WriteFile(script, value, 0o644); err != nil { + t.Fatal(err) + } + findings := ClassifyCommand(repo, "python scripts/dev_environment.py recreate --confirm dev_engineer") + if len(findings) == 0 || findings[0].Category != "external-resource-destruction" || findings[0].Source != "scripts/dev_environment.py" { + t.Fatalf("destructive wrapper was not denied at the shared guard: %#v", findings) + } +} + +// control-law: external-control-plane-effects-stay-operator-only +func TestIndirectShellWrapperIsDeniedThroughExecutedFileInspection(t *testing.T) { + repo := safetyTestRepo(t) + script := filepath.Join(repo, "scripts", "delete-branch.sh") + if err := os.MkdirAll(filepath.Dir(script), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(script, []byte("#!/bin/sh\nsupabase branches delete dev_engineer\n"), 0o755); err != nil { + t.Fatal(err) + } + if findings := ClassifyCommand(repo, "bash scripts/delete-branch.sh"); len(findings) == 0 { + t.Fatal("indirect destructive shell wrapper was allowed") + } +} + +// control-law: external-control-plane-effects-stay-operator-only +func TestExternalControlPlaneMCPMutationsAreDenied(t *testing.T) { + repo := safetyTestRepo(t) + cases := []struct { + name string + input map[string]any + category string + }{ + {"mcp__supabase__delete_branch", map[string]any{"branch": "dev_engineer"}, "external-resource-destruction"}, + {"mcp__cloud__update_service", map[string]any{"service": "api", "allow_unauthenticated": true}, "external-public-exposure"}, + } + for _, test := range cases { + findings := ClassifyTool(repo, test.name, test.input) + if len(findings) == 0 || findings[0].Category != test.category { + t.Fatalf("%s was not denied as %s: %#v", test.name, test.category, findings) + } + } +} + +// control-law: external-control-plane-effects-stay-operator-only +func TestIncidentCommandsAreDeniedThroughCodexHook(t *testing.T) { + repo := safetyTestRepo(t) + script := filepath.Join(repo, "scripts", "dev_environment.py") + if err := os.MkdirAll(filepath.Dir(script), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(script, []byte("import subprocess\nsubprocess.run(['supabase', 'branches', 'delete', 'dev_engineer'])\n"), 0o644); err != nil { + t.Fatal(err) + } + commands := []string{ + "supabase branches delete dev_engineer --project-ref example --yes", + "supabase branches update dev_engineer --project-ref example --persistent=false --yes", + "python scripts/dev_environment.py recreate --confirm dev_engineer", + } + for _, command := range commands { + input, err := json.Marshal(map[string]any{ + "hook_event_name": "PreToolUse", "tool_name": "Bash", + "tool_input": map[string]any{"command": command}, + }) + if err != nil { + t.Fatal(err) + } + output, denied := HookDecision(SafetyHookOptions{Host: "codex", Repo: repo, Input: input}) + if !denied || !strings.Contains(string(output), `"permissionDecision":"deny"`) { + t.Fatalf("Codex hook allowed incident command %q: %s", command, output) + } + } +} + // control-law: reap-mutates-only-through-the-sanctioned-actuator // The sanctioned workspace-reap helper is allowed through the pre-activation // interlock, while the raw destructive equivalents an operator would otherwise