From 9a570b30644c0460530f783e755ebb23a2427238 Mon Sep 17 00:00:00 2001 From: "operator-stack-publisher[bot]" Date: Sun, 26 Jul 2026 22:05:07 +0000 Subject: [PATCH] Sync Boatstack from Intelligence Flow Labs @ 01dec93295af --- CONTRIBUTING.md | 2 +- UPSTREAM.json | 17 +- boatstack/pr_test.go | 32 +- boatstack/safety.go | 273 ++++++++++++++---- boatstack/safety_test.go | 127 +++++++- docs/evidence-engineered-coding.md | 2 +- docs/public-claims.json | 24 +- labs/diagram-json/plan.lock.json | 2 +- ...26-07-26-effect-based-destruction-guard.md | 32 ++ 9 files changed, 415 insertions(+), 96 deletions(-) create mode 100644 release-notes/2026-07-26-effect-based-destruction-guard.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b4c4db6..0153e87 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ # Contributing -Boatstack is a generated content distribution. Propose changes to workflow semantics, templates, evidence rules, or generated presentation in [Intelligence Flow](https://github.com/operatorstack/intelligence-flow/tree/64b1891a9eccda6ede3df1b5350462d8179ef112/labs/12-product-engineering-loop). +Boatstack is a generated content distribution. Propose changes to workflow semantics, templates, evidence rules, or generated presentation in [Intelligence Flow](https://github.com/operatorstack/intelligence-flow/tree/01dec93295af21787a594de9479acc16e0f85bba/labs/12-product-engineering-loop). The Boatstack repository receives product/runtime changes through a generated pull request. Review the PR's `UPSTREAM.json`, tests, adapter diff, and context-size change; do not hand-edit generated output on `main`. `.github/workflows` is the exception: it is Boatstack's executable control plane, excluded from scheduled projection and changed only through a separate manually reviewed Boatstack PR. diff --git a/UPSTREAM.json b/UPSTREAM.json index 6fc8ec0..c4aae49 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -12,7 +12,7 @@ }, "files": { ".gitignore": "a7079e923a776f14f1bb3a6aa0a11a133a8e1dfb35af020f327623357b7e3957", - "CONTRIBUTING.md": "57661de7b9cc07e16e5142bb2615a8eb8a0621632876b0ec713a3795403862dc", + "CONTRIBUTING.md": "01ec94c6a5b13837bcfa11b05bb1a9be58bd643fb144c1d4f17e3b6287e88c92", "README.md": "3ce3e95e511089b44e946a44b8d5f4f81d019ece5336db65b2cab1f9dc4d4dad", "assets/boatstack-journey.svg": "e465befc50c8ce30f3e07e8fd97012931beeb053392c8fbf38ad645023b3cc63", "assets/boatstack-mark.svg": "be1f984da1bfa69fa5d1f986d8343d21f7e20921b71db888c928b4d2e54b09b5", @@ -133,7 +133,7 @@ "boatstack/planning.go": "d33b661f448d5c009454f012f16e3dd3daa6756d26b855c93d3066b1aaf923c8", "boatstack/planning_test.go": "c105a9c78c342be06614bf54d0bc1b661b0f7af64d63b79e43bd1fcc2769edd5", "boatstack/pr.go": "b6df3e000dd6d34ecb6575385e44b2f6ee84a8f35ad5696e299177a7cddd7629", - "boatstack/pr_test.go": "7f82954d94c1ceae848a581dda25e58af92251d78a5a94ed2d672bedf5a0349e", + "boatstack/pr_test.go": "2e7709e2f163489a29ea3e7eb4bc932cfe6829b30d168f1aea9e942acbc3b4e7", "boatstack/provenance.go": "d44dcd5421306269326f1202ba1d52df8c252490550270ef9d022e8ec2b65210", "boatstack/provision.go": "eb7333a73331b011adc93f59a2d97415d850c2e588f0e2bbb5116984e2ef927d", "boatstack/provision_test.go": "214e9edb991a66d5bbb696a7c1b63876d2f799f2cab4e3f40785f4e8f1eac57b", @@ -160,8 +160,8 @@ "boatstack/runtime_cache.go": "e026ffc1906f7e1e98b768bae63e6658164d2826c07169c9121ce0f23c73faf8", "boatstack/runtime_cache_test.go": "b981467ddc9f0f562da6bff5de7a80a9fe5a433a0317541d1e48df268546ac85", "boatstack/runtime_provenance_test.go": "1d52f1e6b0691cf4667729cc9b9f3c55c128f0aa3321f3a2843a9aa6fd0e73dc", - "boatstack/safety.go": "15ce84911ad4b24e054f4e56ba783613c74d21c2cb136d63585f43dd95a94dbf", - "boatstack/safety_test.go": "2741610de4b8a47d66b1a5f18a028ab63417826789636587745b4e71dd2d59c3", + "boatstack/safety.go": "3f02b6be7d0a209da5afb2d23a5e5f1cb1c2578f1c4b430cea1d5a30a96e06ad", + "boatstack/safety_test.go": "02260654d0b93ad48585c40391b310810876a6abcafc3d6c074f1f4e4e633f76", "boatstack/safety_update_publisher_test.go": "ed3f8187036623694dfe7c395cdae00fdae14609bab6124d1fdfc6fe73fa2196", "boatstack/skill_frontmatter.go": "73364df463ce828c2d005aab55f72bb92f7a34d99cf3f53d4e0cd5a4da9dbd0e", "boatstack/skill_frontmatter_test.go": "a3ec52e7df357a72265c95dd66db15d9c0effc7e5f90f14ce69c27792ce394eb", @@ -187,10 +187,10 @@ "docs/benchmark-corpus-audit.md": "f2d206fe8579a514f9da82b2c96c19b343ac004be67617e1bd34f0f8e0e5e6c6", "docs/benchmark-submission-audit.md": "9518abdd17690729c6423f87cab20418ed47b0915b5faa44b9ef975e9e9c3b79", "docs/configuration.md": "060775c73431f28bd16066bdf9e0f89034d2855c7ca0f5544f660d24b91211d0", - "docs/evidence-engineered-coding.md": "f2596fdb5e3628dc9cf19c3f29df177cd2fa63882c924e5c0253b8628c20a75b", + "docs/evidence-engineered-coding.md": "36de4b7c7f2bbff5e250a3613e36a7e756b350f4bf44cf9f55feb944df746b3b", "docs/generated-files.md": "437791765b0a4015032ae21d1a6618563cad92b7402819e4f963bf5ae16284a3", "docs/getting-started.md": "51c2823f21e35140d31e6d5083dc4b89fddd24721ac6acc474154a4da53ee9f8", - "docs/public-claims.json": "0bddadb9ba7bb813e383f32d1b3422002376f069deb0834db3e42c99d68a80ab", + "docs/public-claims.json": "05bce9b3fab43563ca89db5e69b685bb2ee75b510cc7cc4eabfcac7754035209", "docs/public-surface.md": "713f7a050b5f339cf948299103ef3800417dccfecf2cc1a4166397ea6f978907", "docs/research-and-design.md": "8d78678108f0a6c924e1ff9b32c0f81aae9d1f779e0082843b6f99ad993ae2b6", "docs/safety.md": "7b9b5c515d36e683767ec8d3d9d6d119ac93650b2f629d351deadd4c600ed6a6", @@ -204,7 +204,7 @@ "labs/diagram-json/compiled/evidence.md": "1ba1c989ade070a8ef9a508fbd788d100d7292f2dbacbb2bce895468019f619d", "labs/diagram-json/compiled/tasks.json": "88f60851abf79d851e9fccc754ff3040034ae595306bc87d64784c19eb403e71", "labs/diagram-json/compiled/test-matrix.json": "424657ff505768e50fa113801fd8363364a18269d5297480907a993d44063a39", - "labs/diagram-json/plan.lock.json": "7bee0e448274d00342794f023a021816650085554dff4d6ed1288f59f0b2ab6b", + "labs/diagram-json/plan.lock.json": "d537e3b15bfa7312f12892f2a1c59fd34078fec2dea37c28362785c3fd17d1c3", "labs/diagram-json/plan.md": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51", "labs/diagram-json/questions.md": "74733b015002c8a6777c558e7e997fa48c94850b9bd39054fe9366c97ecf728d", "labs/diagram-json/request.md": "0808fc41c36779c404f4a3a121167da6e76cac56df526e70f9ed6d3e0d4c02ed", @@ -313,6 +313,7 @@ "release-notes/2026-07-26-detached-activation.md": "97cf968c3bd57d5f88bd91c04672dae3cedba88e460758323cffe44532d2ec2c", "release-notes/2026-07-26-detached-context-and-guard.md": "ed58fc69752bd9b48403e3bdd8e3459fa84a663bc3caa1e8246be3abc3ac6d6c", "release-notes/2026-07-26-detached-supervision.md": "e8062f5f39e5ad86e9104f20b7b6b1581a95215aff17acbe916e0715e2424b11", + "release-notes/2026-07-26-effect-based-destruction-guard.md": "0e88c879dae420f31cff56781547de5220ac4e1f9a80a3d66735b0d8b010a471", "release-notes/2026-07-26-guard-etxtbsy-retry.md": "4238591804be62f8b9a76dd5cda18923af60ef67932d40d70cab0d815124bcaf", "release-notes/2026-07-26-guard-hydrate-double-check.md": "83a5591aba6bf30c9f4008ba8d26bf1994ef3fd61145f46fcdd1678912b9990b", "release-notes/2026-07-26-hidden-jflow-design-note.md": "f60ed9dbbfb46a172ac9d33dd758a3166f820007b1673029f29f0fbefa0e5c0a", @@ -321,7 +322,7 @@ "generator": "operatorstack/intelligence-flow:boatstack-distribution", "schema_version": 1, "source": { - "commit": "64b1891a9eccda6ede3df1b5350462d8179ef112", + "commit": "01dec93295af21787a594de9479acc16e0f85bba", "path": "labs/12-product-engineering-loop", "repository": "operatorstack/intelligence-flow" } diff --git a/boatstack/pr_test.go b/boatstack/pr_test.go index 32c7a8b..6e5f27b 100644 --- a/boatstack/pr_test.go +++ b/boatstack/pr_test.go @@ -440,20 +440,46 @@ No migration is required; revert the feature commit to roll back. func TestManagedPRBlocksCommittedIrreversibleCapability(t *testing.T) { repo := prTestRepo(t) activateManagedFeature(t, repo, "reviewer-ready") - path := filepath.Join(repo, "scripts", "recover.sql") + // A committed SCRIPT that runs a destructive reset is a live capability the + // deploy pipeline would execute — the managed PR must block it. (A declarative + // .sql migration is data and is intentionally allowed; see safety_test.go.) + path := filepath.Join(repo, "scripts", "recover.sh") if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } - if err := os.WriteFile(path, []byte("DROP SCHEMA public CASCADE;\n"), 0o644); err != nil { + if err := os.WriteFile(path, []byte("#!/usr/bin/env bash\nsupabase db reset --linked\n"), 0o644); err != nil { t.Fatal(err) } - runGit(t, repo, "add", "scripts/recover.sql") + runGit(t, repo, "add", "scripts/recover.sh") runGit(t, repo, "commit", "-m", "add unsafe recovery") if _, err := PreparePRContext(PRContextOptions{Repo: repo, Feature: "reviewer-ready"}); err == nil || !strings.Contains(err.Error(), "irreversible capability") { t.Fatalf("managed PR did not block committed destructive code: %v", err) } } +// A managed delivery that commits a declarative migration must NOT be blocked as +// an irreversible capability — regenerating and committing schema SQL is the +// normal migration step, applied later by the controlled deploy pipeline. This is +// the positive counterpart that dissolves the migration-bearing-delivery deadlock. +func TestManagedPRAllowsDeclarativeMigration(t *testing.T) { + repo := prTestRepo(t) + activateManagedFeature(t, repo, "reviewer-ready") + path := filepath.Join(repo, "schema", "generated", "staging.sql") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("DROP SCHEMA public CASCADE;\nCREATE SCHEMA public;\n"), 0o644); err != nil { + t.Fatal(err) + } + runGit(t, repo, "add", "schema/generated/staging.sql") + runGit(t, repo, "commit", "-m", "regenerate staging schema") + // PreparePRContext may fail for unrelated reasons in this fixture; the delivery + // deadlock only exists if it fails SPECIFICALLY on the migration capability. + if _, err := PreparePRContext(PRContextOptions{Repo: repo, Feature: "reviewer-ready"}); err != nil && strings.Contains(err.Error(), "irreversible capability") { + t.Fatalf("declarative migration wrongly blocked the managed delivery: %v", err) + } +} + func TestManagedPRRequiresCurrentApprovalLockAndGateEvidence(t *testing.T) { repo := prTestRepo(t) directory := activateManagedFeature(t, repo, "reviewer-ready") diff --git a/boatstack/safety.go b/boatstack/safety.go index ea88cff..65f8f39 100644 --- a/boatstack/safety.go +++ b/boatstack/safety.go @@ -51,21 +51,40 @@ func malformedHookInput(code string) error { return hookDecodeError{code: code} } -var readOnlyStage = regexp.MustCompile(`(?i)^\s*(?:env\s+[^ ]+\s+)*(?:rg|grep|git\s+(?:grep|diff|status|show|log)|cat|sed|head|tail|less|find\s+[^\n]*-(?:print|ls)|psql\s+[^\n]*\s-c\s+["']?\s*select\b)`) +var readOnlyStage = regexp.MustCompile(`(?i)^\s*(?:env\s+[^ ]+\s+)*(?:rg|grep|git\s+(?:grep|diff|status|show|log)|cat|sed|head|tail|less|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)`) +// irreversiblePatterns classify destruction by text. Rules whose regex names its +// own EXECUTOR (rm, git, terraform, supabase db reset, …) are self-executing: +// matching the text is sound because the text IS the command. Rules marked +// sqlEffect match bare SQL grammar (DROP TABLE, TRUNCATE) that is inert as data +// and destructive only when a live database client runs it — so they are applied +// only in an executor context (see classifySafetyText's scanSQL argument). This +// is what stops `git add staging.sql` or a committed migration from being read as +// a live drop while `psql -c "DROP TABLE"` is still denied. var irreversiblePatterns = []struct { - category string - reason string - pattern *regexp.Regexp + category string + reason string + pattern *regexp.Regexp + sqlEffect bool }{ - {"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`)}, - {"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))`)}, - {"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;]*)`)}, - {"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))`)}, - {"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`)}, - {"recovery-destruction", "backup deletion or recovery disablement is operator-only", regexp.MustCompile(`(?i)\b(?:delete|remove|disable)\b[^\n;]*(?:backup|snapshot|point-in-time|pitr|recovery)\b`)}, + {"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}, + {"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}, + {"recovery-destruction", "backup deletion or recovery disablement is operator-only", regexp.MustCompile(`(?i)\b(?:delete|remove|disable)\b[^\n;]*(?:backup|snapshot|point-in-time|pitr|recovery)\b`), false}, } +// liveSQLClientPattern matches the executable of a command that runs SQL against a +// live database connection. Reading, committing, or diffing a file that CONTAINS +// SQL is not such a command; only these executors actually apply DDL/DML. +var liveSQLClientPattern = regexp.MustCompile(`(?i)^(?:psql|mysql|mariadb|mongo|mongosh|cockroach|sqlcmd|usql|clickhouse-client)$`) + +// fileRunnerPattern matches an executable that EXECUTES a file argument, so that +// file's contents are a live capability — unlike git/cp/cat, which treat a named +// file as data. SQL clients are added at the use site (they run a file via -f/<). +var fileRunnerPattern = regexp.MustCompile(`(?i)^(?:python[0-9.]*|sh|bash|zsh|ksh|dash|ruby|node|deno|bun|perl|php|pwsh|powershell)$`) + var operationalPathPattern = regexp.MustCompile(`(?i)(?:^|/)(?:scripts?|migrations?|schema|database|db|deploy|infra|ops|terraform|k8s)(?:/|$)|\.(?:sql|ps1|sh|bash|py)$`) // Match SQL mutation grammar rather than isolated English or command tokens. @@ -86,6 +105,7 @@ var approvedPublisherPattern = regexp.MustCompile(`(?i)^\s*(?:[^\s]*/)?boatstack // boatstack-helper_darwin_arm64) that a running update may invoke after the installed // helper is swapped or removed. var approvedUpdatePublisherPattern = regexp.MustCompile(`(?i)^\s*(?:[^\s]*/)?boatstack-helper(?:[_.-][a-z0-9._-]+)?\s+publish-update-pr\b[^\n;&|]*$`) + // deliveryStatePathPattern matches Boatstack's managed runtime/control state so // the guard denies direct model mutation of it. It covers the embedded homes // (boatstack/deliveries and any .git/.../boatstack subtree) and the Detached @@ -369,23 +389,36 @@ func publicationBypassFinding(repo, reason, source string) (SafetyFinding, bool) return finding, true } -func classifySafetyText(value, source string) []SafetyFinding { +// classifySafetyText matches destructive-operation text. scanSQL gates the rules +// whose grammar is inert as data and destructive only when a live database client +// runs it — bare DDL (DROP TABLE) and unbounded DML (DELETE FROM … with no WHERE). +// Callers pass scanSQL=true only in an executor context (a command that invokes a +// SQL client, a file the command executes, or a live SQL tool); they pass false +// for a committed artifact, a git operand, or a document edit, so declarative SQL +// is treated as data. Self-executing rules (rm, git, terraform, supabase db reset) +// name their own executor and always apply. +func classifySafetyText(value, source string, scanSQL bool) []SafetyFinding { if isPureReadOnlyCommand(value) { return nil } findings := []SafetyFinding{} seen := map[string]bool{} for _, rule := range irreversiblePatterns { + if rule.sqlEffect && !scanSQL { + continue + } if rule.pattern.MatchString(value) && !seen[rule.category] { seen[rule.category] = true findings = append(findings, SafetyFinding{Category: rule.category, Reason: rule.reason, Source: source}) } } - for _, statement := range mutationStatementPattern.FindAllString(strings.ToLower(value), -1) { - normalized := " " + strings.Join(strings.Fields(statement), " ") + " " - if !strings.Contains(normalized, " where ") { - findings = append(findings, SafetyFinding{Category: "unbounded-data-mutation", Reason: "unbounded data deletion or update is denied", Source: source}) - break + if scanSQL { + for _, statement := range mutationStatementPattern.FindAllString(strings.ToLower(value), -1) { + normalized := " " + strings.Join(strings.Fields(statement), " ") + " " + if !strings.Contains(normalized, " where ") { + findings = append(findings, SafetyFinding{Category: "unbounded-data-mutation", Reason: "unbounded data deletion or update is denied", Source: source}) + break + } } } return findings @@ -449,44 +482,160 @@ func shellPipelineStages(value string) ([]string, bool) { return stages, true } -func safeRepositoryPath(repo, candidate string) (string, bool) { - candidate = strings.Trim(candidate, "\"'`;,()[]{}") - if candidate == "" || strings.HasPrefix(candidate, "-") { - return "", false - } - ext := strings.ToLower(filepath.Ext(candidate)) - if ext != ".py" && ext != ".sh" && ext != ".bash" && ext != ".ps1" && ext != ".sql" { - return "", false +// shellSegments splits a command into simple-command segments on unquoted shell +// operators (; & | and newline), so each segment's first word is its executor. +// && and || reduce to their operator characters, which still segments correctly. +func shellSegments(value string) []string { + segments := []string{} + start := 0 + var quote rune + escaped := false + for index, char := range value { + if escaped { + escaped = false + continue + } + if char == '\\' && quote != '\'' { + escaped = true + continue + } + if quote != 0 { + if char == quote { + quote = 0 + } + continue + } + if char == '\'' || char == '"' { + quote = char + continue + } + if char == ';' || char == '&' || char == '|' || char == '\n' { + segments = append(segments, value[start:index]) + start = index + 1 + } } - path := candidate - if !filepath.IsAbs(path) { - path = filepath.Join(repo, filepath.FromSlash(candidate)) + return append(segments, value[start:]) +} + +// segmentExecutor returns the executable basename of a simple command, skipping +// leading VAR=value assignments and benign wrappers (env, sudo, time, …). It +// returns "" when the segment has no command word. +func segmentExecutor(segment string) string { + fields := strings.Fields(segment) + for len(fields) > 0 { + field := fields[0] + if strings.HasPrefix(field, "-") { + return "" + } + if eq := strings.IndexByte(field, '='); eq > 0 && !strings.ContainsAny(field[:eq], "/\\") { + fields = fields[1:] + continue + } + base := strings.TrimSuffix(strings.ToLower(filepath.Base(field)), ".exe") + switch base { + case "env", "sudo", "time", "nohup", "xargs", "command", "doas", "stdbuf": + fields = fields[1:] + continue + } + return base } - abs, err := filepath.Abs(path) - if err != nil { + return "" +} + +// shellDashCScript returns the script passed to a shell's -c flag, so an executor +// hidden inside `bash -c "…"` is analyzed at the same fidelity as a top-level one. +func shellDashCScript(executor, segment string) (string, bool) { + switch executor { + case "sh", "bash", "zsh", "ksh", "dash": + default: return "", false } - rel, err := filepath.Rel(repo, abs) - if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { - return "", false + fields := strings.Fields(segment) + for index, field := range fields { + if field == "-c" && index+1 < len(fields) { + return strings.Trim(strings.Join(fields[index+1:], " "), "\"'"), true + } } - info, err := os.Lstat(abs) - if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { - return "", false + return "", false +} + +// commandExecutesLiveSQL reports whether any segment of the command invokes a live +// database client. Quoted prose in an unrelated command (git commit -m "DROP +// TABLE …") is not an executor and returns false; a nested shell -c script is +// unwrapped so `bash -c "psql … DROP …"` returns true. +func commandExecutesLiveSQL(command string) bool { + for _, segment := range shellSegments(command) { + executor := segmentExecutor(segment) + if liveSQLClientPattern.MatchString(executor) { + return true + } + if inline, ok := shellDashCScript(executor, segment); ok && commandExecutesLiveSQL(inline) { + return true + } } - return abs, true + return false } -func invokedRepositoryFiles(repo, command string) []string { - paths := []string{} +// executedRepositoryFiles returns repository files the command actually EXECUTES, +// split into regular files (whose contents are inspected) and symlinked +// entrypoints (reported, never followed). A file merely named as data — git add +// x.sql, cp, cat — is returned by neither, because only runner segments (an +// interpreter or a SQL client that runs a file) are considered. +func executedRepositoryFiles(repo, command string) (content []string, symlinks []string) { seen := map[string]bool{} - for _, token := range strings.Fields(command) { - if path, ok := safeRepositoryPath(repo, token); ok && !seen[path] { - seen[path] = true - paths = append(paths, path) + for _, segment := range shellSegments(command) { + executor := segmentExecutor(segment) + if !fileRunnerPattern.MatchString(executor) && !liveSQLClientPattern.MatchString(executor) { + continue + } + for _, token := range strings.Fields(segment) { + candidate := strings.Trim(token, "\"'`;,()[]{}") + if candidate == "" || strings.HasPrefix(candidate, "-") { + continue + } + ext := strings.ToLower(filepath.Ext(candidate)) + if ext != ".py" && ext != ".sh" && ext != ".bash" && ext != ".ps1" && ext != ".sql" { + continue + } + path := candidate + if !filepath.IsAbs(path) { + path = filepath.Join(repo, filepath.FromSlash(candidate)) + } + abs, err := filepath.Abs(path) + if err != nil { + continue + } + rel, err := filepath.Rel(repo, abs) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + continue + } + if seen[abs] { + continue + } + info, err := os.Lstat(abs) + if err != nil { + continue + } + seen[abs] = true + switch { + case info.Mode()&os.ModeSymlink != 0: + symlinks = append(symlinks, abs) + case info.Mode().IsRegular(): + content = append(content, abs) + } } } - return paths + return content, symlinks +} + +// sqlExecutorToolPattern matches a tool name that denotes a database client +// executing SQL against a live connection (an MCP execute_sql / db query tool), +// so its arguments are a live capability rather than inert text. +var sqlExecutorToolPattern = regexp.MustCompile(`(?i)(?:execute|run|exec)[_-]?sql|sql[_-]?(?:exec|execute|query|statement)|db[_-]?(?:execute|exec|query)`) + +// toolExecutesLiveSQL reports whether the tool runs SQL against a live database. +func toolExecutesLiveSQL(name string) bool { + return sqlExecutorToolPattern.MatchString(strings.ToLower(name)) } func ClassifyCommand(repo, command string) []SafetyFinding { @@ -504,7 +653,7 @@ func ClassifyCommand(repo, command string) []SafetyFinding { if strings.Contains(command, "workspace-sync") && !isPureReadOnlyCommand(command) && !controlledWorkspaceSync(repo, command) { return []SafetyFinding{{Category: "workspace-sync-bypass", Reason: "recoverable branch alignment must use the exact project-local Boatstack helper", Source: "command"}} } - findings := classifySafetyText(command, "command") + findings := classifySafetyText(command, "command", commandExecutesLiveSQL(command)) if len(findings) > 0 { return dedupeFindings(findings) } @@ -519,20 +668,15 @@ func ClassifyCommand(repo, command string) []SafetyFinding { if len(findings) > 0 || isPureReadOnlyCommand(command) { return dedupeFindings(findings) } - for _, token := range strings.Fields(command) { - candidate := strings.Trim(token, "\"'`;,()[]{}") - if ext := strings.ToLower(filepath.Ext(candidate)); ext != ".py" && ext != ".sh" && ext != ".bash" && ext != ".ps1" && ext != ".sql" { - continue - } - path := candidate - if !filepath.IsAbs(path) { - path = filepath.Join(repo, filepath.FromSlash(path)) - } - if info, err := os.Lstat(path); err == nil && info.Mode()&os.ModeSymlink != 0 { - return []SafetyFinding{{Category: "symlink-entrypoint", Reason: "an invoked repository entrypoint is a symlink and cannot be inspected safely", Source: filepath.Base(path)}} - } + // Only inspect files the command actually EXECUTES (interpreter / SQL-client + // segments). A file merely named as data (git add x.sql, cp, cat) is not an + // executed capability, so its SQL content is never classified. An executed file + // IS a live capability, so its contents are scanned with scanSQL=true. + contentFiles, symlinkFiles := executedRepositoryFiles(repo, command) + if len(symlinkFiles) > 0 { + return []SafetyFinding{{Category: "symlink-entrypoint", Reason: "an invoked repository entrypoint is a symlink and cannot be inspected safely", Source: filepath.Base(symlinkFiles[0])}} } - for _, path := range invokedRepositoryFiles(repo, command) { + for _, path := range contentFiles { value, err := os.ReadFile(path) if err != nil { return []SafetyFinding{{Category: "unreadable-entrypoint", Reason: "an invoked repository entrypoint could not be inspected", Source: filepath.Base(path)}} @@ -541,7 +685,7 @@ func ClassifyCommand(repo, command string) []SafetyFinding { if relErr != nil { relative = filepath.Base(path) } - findings = append(findings, classifySafetyText(string(value), filepath.ToSlash(relative))...) + findings = append(findings, classifySafetyText(string(value), filepath.ToSlash(relative), true)...) } return dedupeFindings(findings) } @@ -557,7 +701,10 @@ func ClassifyTool(repo, name string, input any) []SafetyFinding { return []SafetyFinding{{Category: "malformed-tool-input", Reason: "invalid-tool-input", Source: "tool-input"}} } combined := name + " " + string(value) - findings := classifySafetyText(combined, "tool-input") + // Bare SQL grammar in a tool's arguments is a live capability only when the tool + // itself executes SQL (an MCP execute_sql / db query tool). A Write/Edit/Read + // whose content merely contains DDL is a document, not an execution. + findings := classifySafetyText(combined, "tool-input", toolExecutesLiveSQL(name)) nameLower := strings.ToLower(name) attemptedPath := attemptedRepositoryPath(repo, input) mutationCapable := mutationToolPattern.MatchString(nameLower) || (strings.HasPrefix(nameLower, "mcp__") && !externalReadOnlyToolPattern.MatchString(nameLower)) @@ -1144,7 +1291,13 @@ func CheckRepositorySafety(repoPath string) (SafetyReport, error) { if readErr != nil { return SafetyReport{}, readErr } - findings = append(findings, classifySafetyText(string(value), relative)...) + // A committed file in the delivery diff is a DATA ARTIFACT, not an execution: + // a declarative migration .sql or a schema dump is applied later by the + // controlled deploy pipeline (the operator boundary), so its bare SQL is not a + // capability the agent is exercising now (scanSQL=false). Self-executing + // destruction committed into a script (supabase db reset, terraform destroy) + // still blocks, because those rules name their own executor. + findings = append(findings, classifySafetyText(string(value), relative, false)...) } findings = dedupeFindings(findings) status := "PASS" diff --git a/boatstack/safety_test.go b/boatstack/safety_test.go index ae22155..82904b1 100644 --- a/boatstack/safety_test.go +++ b/boatstack/safety_test.go @@ -55,7 +55,7 @@ func TestIrreversibleCommandCorpusIsDenied(t *testing.T) { "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 'DROP SCHEMA public CASCADE'`, + "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`, @@ -194,6 +194,97 @@ func TestSafeDiagnosticsAndFixForwardCommandsRemainAllowed(t *testing.T) { } } +func containsCategory(findings []SafetyFinding, category string) bool { + for _, finding := range findings { + if finding.Category == category { + return true + } + } + return false +} + +// Git plumbing and file I/O never execute SQL, so naming a DDL-laden file (or +// spelling a keyword in a message or a note) must not be classified as database +// destruction. This is the recurring migration-commit false positive. +func TestDataOperationsOnSQLAreNotDestruction(t *testing.T) { + repo := safetyTestRepo(t) + dump := filepath.Join(repo, "schema", "generated", "staging.sql") + if err := os.MkdirAll(filepath.Dir(dump), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dump, []byte("DROP SCHEMA public CASCADE;\nTRUNCATE TABLE accounts;\n"), 0o644); err != nil { + t.Fatal(err) + } + allowed := []string{ + "git add schema/generated/staging.sql", + `git commit -m "regenerate staging schema (adds DROP TABLE stale)"`, + "git diff --stat schema/generated/staging.sql", + "git restore --staged schema/generated/staging.sql", + "git status", + "cat schema/generated/staging.sql", + } + for _, command := range allowed { + if findings := ClassifyCommand(repo, command); len(findings) != 0 { + t.Errorf("data operation wrongly blocked: %q -> %#v", command, findings) + } + } + // A note that merely mentions the keywords is a document, not an execution. + edit := ClassifyTool(repo, "Edit", map[string]any{ + "file_path": filepath.Join(repo, "notes.md"), + "content": "Recovery runbook: operator runs `psql -c \"DROP SCHEMA public CASCADE\"` off-hours.", + }) + if containsCategory(edit, "database-destruction") { + t.Errorf("document edit wrongly flagged as database-destruction: %#v", edit) + } +} + +// The real boundary must stay intact: a command whose EXECUTOR runs SQL against a +// live database is still denied, whether the DDL is inline, in a file it executes, +// or in a live SQL tool. +func TestLiveSQLExecutionStillBlocked(t *testing.T) { + repo := safetyTestRepo(t) + migration := filepath.Join(repo, "scripts", "drop.sql") + if err := os.MkdirAll(filepath.Dir(migration), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(migration, []byte("DROP SCHEMA public CASCADE;\n"), 0o644); err != nil { + t.Fatal(err) + } + pyRunner := filepath.Join(repo, "scripts", "run_ddl.py") + if err := os.WriteFile(pyRunner, []byte("cur.execute('DROP TABLE accounts')\n"), 0o644); err != nil { + t.Fatal(err) + } + blocked := []string{ + `psql -c "DROP SCHEMA public CASCADE"`, // inline DDL via a live client + "psql -f scripts/drop.sql", // client executes the file + "python scripts/run_ddl.py", // interpreter executes DDL-running code + } + for _, command := range blocked { + if findings := ClassifyCommand(repo, command); !containsCategory(findings, "database-destruction") { + t.Errorf("live SQL execution not blocked: %q -> %#v", command, findings) + } + } + // A live SQL tool (MCP execute_sql) running DDL is still denied. + tool := ClassifyTool(repo, "mcp__db__execute_sql", map[string]any{"query": "DROP TABLE users"}) + if !containsCategory(tool, "database-destruction") { + t.Errorf("live SQL tool not blocked: %#v", tool) + } +} + +// Read-only helper status commands may be piped for inspection during recovery +// without dropping into the full classifier. +func TestReadOnlyHelperStatusIsPipeable(t *testing.T) { + repo := safetyTestRepo(t) + for _, command := range []string{ + "boatstack-helper mutation-status | grep active", + "boatstack-helper recovery-status | head -20", + } { + if findings := ClassifyCommand(repo, command); len(findings) != 0 { + t.Errorf("read-only helper pipe wrongly blocked: %q -> %#v", command, findings) + } + } +} + func TestAPIMethodNamesDoNotMasqueradeAsSQLMutations(t *testing.T) { repo := safetyTestRepo(t) path := filepath.Join(repo, "api", "main.py") @@ -390,28 +481,44 @@ func TestBlockedHookNeverCreatesSentinelSideEffect(t *testing.T) { } } -func TestOperationalDiffBlocksGateProgression(t *testing.T) { +// A committed declarative migration is a DATA ARTIFACT applied later by the +// controlled deploy pipeline, not a capability the agent is exercising now, so it +// must not block gate progression — regenerating and committing schema SQL is the +// normal migration step. A self-executing destructive capability committed into a +// SCRIPT still blocks, because those rules name their own executor. +func TestOperationalDiffTreatsDeclarativeSQLAsData(t *testing.T) { repo := safetyTestRepo(t) - path := filepath.Join(repo, "scripts", "recover.sql") - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + sqlPath := filepath.Join(repo, "schema", "generated", "staging.sql") + if err := os.MkdirAll(filepath.Dir(sqlPath), 0o755); err != nil { t.Fatal(err) } - if err := os.WriteFile(path, []byte("DROP SCHEMA public CASCADE;\n"), 0o644); err != nil { + // A pg_dump-style artifact, legitimately full of DROP/DDL. + if err := os.WriteFile(sqlPath, []byte("DROP SCHEMA public CASCADE;\nCREATE SCHEMA public;\n"), 0o644); err != nil { t.Fatal(err) } report, err := CheckRepositorySafety(repo) if err != nil { t.Fatal(err) } - if report.Status != "BLOCKED" || len(report.Findings) == 0 { - t.Fatalf("operational destructive capability did not block gates: %#v", report) + if report.Status != "PASS" { + t.Fatalf("declarative migration .sql was wrongly blocked: %#v", report) + } + + // A committed script that RUNS a destructive reset is a live capability the + // deploy pipeline would execute — it must still block. + scriptPath := filepath.Join(repo, "scripts", "reset.sh") + if err := os.MkdirAll(filepath.Dir(scriptPath), 0o755); err != nil { + t.Fatal(err) } - if err := os.WriteFile(path, []byte("BEGIN;\nSELECT current_database();\nCOMMIT;\n"), 0o644); err != nil { + if err := os.WriteFile(scriptPath, []byte("#!/usr/bin/env bash\nsupabase db reset --linked\n"), 0o644); err != nil { t.Fatal(err) } report, err = CheckRepositorySafety(repo) - if err != nil || report.Status != "PASS" { - t.Fatalf("fix-forward operational diff did not pass: %#v %v", report, err) + if err != nil { + t.Fatal(err) + } + if report.Status != "BLOCKED" || len(report.Findings) == 0 { + t.Fatalf("committed self-executing destructive script did not block gates: %#v", report) } } diff --git a/docs/evidence-engineered-coding.md b/docs/evidence-engineered-coding.md index 1a397c1..a9a048d 100644 --- a/docs/evidence-engineered-coding.md +++ b/docs/evidence-engineered-coding.md @@ -146,6 +146,6 @@ Delivery and system improvement also remain separate. A failed task may suggest ## What is evidence-backed -The current moves were derived from the Intelligence Flow benchmark corpus and product-repository studies. The generated source commit is [`64b1891a9eccda6ede3df1b5350462d8179ef112`](https://github.com/operatorstack/intelligence-flow/tree/64b1891a9eccda6ede3df1b5350462d8179ef112/labs/12-product-engineering-loop). +The current moves were derived from the Intelligence Flow benchmark corpus and product-repository studies. The generated source commit is [`01dec93295af21787a594de9479acc16e0f85bba`](https://github.com/operatorstack/intelligence-flow/tree/01dec93295af21787a594de9479acc16e0f85bba/labs/12-product-engineering-loop). The evidence supports specific failure mechanisms and guardrails. It does not establish that Boatstack is optimal, that control-theory notation proves software quality, or that one workflow dominates every team. Those are evaluation questions, so the distribution preserves measurements, provenance, gaps, and negative results. diff --git a/docs/public-claims.json b/docs/public-claims.json index 48f8979..6976b28 100644 --- a/docs/public-claims.json +++ b/docs/public-claims.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "source_commit": "64b1891a9eccda6ede3df1b5350462d8179ef112", + "source_commit": "01dec93295af21787a594de9479acc16e0f85bba", "statuses": ["verified", "observed", "still_being_evaluated"], "claims": [ { @@ -12,7 +12,7 @@ "readable_evidence": "why-these-steps.md#portable-workflow-and-state", "implementation": ["../boatstack/export.go", "../boatstack/references/artifacts.md", "../boatstack/references/workflow.md"], "verification": ["../boatstack/export_test.go"], - "last_verified_version": "source:64b1891a9eccda6ede3df1b5350462d8179ef112" + "last_verified_version": "source:01dec93295af21787a594de9479acc16e0f85bba" }, { "id": "human-decisions", @@ -23,7 +23,7 @@ "readable_evidence": "why-these-steps.md#human-decisions", "implementation": ["../boatstack/references/workflow.md", "../boatstack/plan.go"], "verification": ["../boatstack/plan_test.go", "../boatstack/planning_test.go"], - "last_verified_version": "source:64b1891a9eccda6ede3df1b5350462d8179ef112" + "last_verified_version": "source:01dec93295af21787a594de9479acc16e0f85bba" }, { "id": "validation-provenance", @@ -34,7 +34,7 @@ "readable_evidence": "why-these-steps.md#validation-provenance", "implementation": ["validation-and-evidence.md", "../boatstack/plan.go"], "verification": ["../boatstack/plan_test.go"], - "last_verified_version": "source:64b1891a9eccda6ede3df1b5350462d8179ef112" + "last_verified_version": "source:01dec93295af21787a594de9479acc16e0f85bba" }, { "id": "irreversible-operations", @@ -46,7 +46,7 @@ "readable_evidence": "why-these-steps.md#irreversible-operations", "implementation": ["safety.md", "../boatstack/safety.go", "../boatstack/hooks.go"], "verification": ["../boatstack/safety_test.go", "../boatstack/hooks_test.go"], - "last_verified_version": "source:64b1891a9eccda6ede3df1b5350462d8179ef112" + "last_verified_version": "source:01dec93295af21787a594de9479acc16e0f85bba" }, { "id": "reviewer-ready-pr", @@ -57,7 +57,7 @@ "readable_evidence": "why-these-steps.md#reviewer-ready-pr", "implementation": ["../boatstack/pr.go", "getting-started.md"], "verification": ["../boatstack/pr_test.go"], - "last_verified_version": "source:64b1891a9eccda6ede3df1b5350462d8179ef112" + "last_verified_version": "source:01dec93295af21787a594de9479acc16e0f85bba" }, { "id": "phase-scoped-delivery", @@ -68,7 +68,7 @@ "readable_evidence": "why-these-steps.md#phase-scoped-delivery", "implementation": ["../boatstack/delivery.go", "../boatstack/safety.go", "../boatstack/hooks.go", "../boatstack/references/workflow.md"], "verification": ["../boatstack/delivery_test.go", "../boatstack/pr_test.go"], - "last_verified_version": "source:64b1891a9eccda6ede3df1b5350462d8179ef112" + "last_verified_version": "source:01dec93295af21787a594de9479acc16e0f85bba" }, { "id": "model-neutral-contract", @@ -79,7 +79,7 @@ "readable_evidence": "why-these-steps.md#model-choice-and-budget", "implementation": ["research-and-design.md", "../boatstack/references/workflow.md"], "verification": ["../boatstack/export_test.go", "../boatstack/planning_test.go"], - "last_verified_version": "source:64b1891a9eccda6ede3df1b5350462d8179ef112" + "last_verified_version": "source:01dec93295af21787a594de9479acc16e0f85bba" }, { "id": "cross-model-failures", @@ -90,7 +90,7 @@ "readable_evidence": "why-these-steps.md#model-choice-and-budget", "implementation": ["research-and-design.md"], "verification": ["benchmark-corpus-audit.md", "benchmark-submission-audit.md"], - "last_verified_version": "source:64b1891a9eccda6ede3df1b5350462d8179ef112" + "last_verified_version": "source:01dec93295af21787a594de9479acc16e0f85bba" }, { "id": "lower-cost-outcomes", @@ -101,7 +101,7 @@ "readable_evidence": "why-these-steps.md#model-choice-and-budget", "implementation": ["research-and-design.md"], "verification": ["benchmark-corpus-audit.md", "benchmark-submission-audit.md"], - "last_verified_version": "source:64b1891a9eccda6ede3df1b5350462d8179ef112" + "last_verified_version": "source:01dec93295af21787a594de9479acc16e0f85bba" }, { "id": "git-worktree-activation", @@ -112,7 +112,7 @@ "readable_evidence": "why-these-steps.md#git-worktree-activation", "implementation": ["../boatstack/runtime_cache.go", "../boatstack/hooks.go"], "verification": ["../boatstack/runtime_cache_test.go", "../boatstack/hooks_test.go"], - "last_verified_version": "source:64b1891a9eccda6ede3df1b5350462d8179ef112" + "last_verified_version": "source:01dec93295af21787a594de9479acc16e0f85bba" }, { "id": "visible-updates", @@ -123,7 +123,7 @@ "readable_evidence": "why-these-steps.md#visible-updates", "implementation": ["../boatstack/update.go", "../boatstack/init.go"], "verification": ["../boatstack/update_test.go", "../boatstack/init_test.go", "../boatstack/export_test.go"], - "last_verified_version": "source:64b1891a9eccda6ede3df1b5350462d8179ef112" + "last_verified_version": "source:01dec93295af21787a594de9479acc16e0f85bba" } ] } diff --git a/labs/diagram-json/plan.lock.json b/labs/diagram-json/plan.lock.json index 7026d0a..d1bac33 100644 --- a/labs/diagram-json/plan.lock.json +++ b/labs/diagram-json/plan.lock.json @@ -6,7 +6,7 @@ "plan_path": "labs/diagram-json/plan.md", "plan_sha256": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51", "schema_version": 1, - "source_commit": "64b1891a9eccda6ede3df1b5350462d8179ef112", + "source_commit": "01dec93295af21787a594de9479acc16e0f85bba", "source_plan_path": "labs/diagram-json/source-plan.md", "source_plan_sha256": "e10593ddaa7522ab80cc991d0a09399257139799e37f737794cd49d68a39985b", "spec_path": "labs/diagram-json/spec.md", diff --git a/release-notes/2026-07-26-effect-based-destruction-guard.md b/release-notes/2026-07-26-effect-based-destruction-guard.md new file mode 100644 index 0000000..6dac36c --- /dev/null +++ b/release-notes/2026-07-26-effect-based-destruction-guard.md @@ -0,0 +1,32 @@ +### The guard now judges destruction by what a command does, not by words in a file + +The safety guard decides whether an action is destructive. It used to decide by +reading text: it matched database keywords such as DROP, TRUNCATE, and reset +anywhere in a command, a file it named, or a tool input. Text is not an effect. +Committing a schema file that contains DROP does not drop anything. Git never runs +SQL. So the guard denied safe, routine work whose only fault was that a file or a +message spelled a keyword. + +Three everyday steps were blocked by mistake. Committing a generated schema dump — +a file that is legitimately full of DROP and DDL — was read as a live drop, so +`git add`, `git commit`, and even `git diff` on it were denied. Activating a +managed delivery scanned the committed diff and flagged the migration file the +same way, so a delivery that carried a migration could not activate. Editing a note +whose prose mentioned the keywords was denied too. + +The guard now classifies by executor and effect. A database category applies only +when the command's executor actually runs SQL against a live database — a client +such as `psql` or `supabase`, a file that such a client or an interpreter +executes, or a tool that executes SQL. A file named as data by git, `cp`, or `cat` +is data, and its contents are never scanned. A committed migration or schema dump +in a delivery diff is a data artifact, applied later by the controlled deploy +pipeline, so it no longer blocks activation. A note or source edit that merely +mentions a keyword is a document. + +The real boundary is unchanged. Running destructive SQL against a live database is +still denied: `psql -c "DROP SCHEMA public CASCADE"`, a client running a migration +file, an interpreter running code that issues DDL, and a live SQL tool are all +still blocked. Self-executing destruction that names its own executor — `rm -rf`, +`git reset --hard`, `terraform destroy`, `supabase db reset` — is unchanged, +including when committed into a script. Read-only status helpers may now be piped +for inspection, so ordinary compound commands work during recovery.