|
| 1 | +package boatstack |
| 2 | + |
| 3 | +import ( |
| 4 | + "path/filepath" |
| 5 | + "strings" |
| 6 | + "testing" |
| 7 | +) |
| 8 | + |
| 9 | +// control-law: written-content-is-data-not-effect |
| 10 | +// |
| 11 | +// A file-writer tool's document body is inert until something executes it, so |
| 12 | +// the guard grades a Write/Edit by its NAME and STRUCTURAL fields (paths, |
| 13 | +// destinations) — never by prose inside the body. Previously the text |
| 14 | +// classifiers ran over the full marshaled input, so a runbook that mentioned |
| 15 | +// `terraform destroy` or docs that named `.git/boatstack/` were denied as if |
| 16 | +// the words were the act. The floor is unchanged: the same strings as a Bash |
| 17 | +// command still block (the text IS the command), a live SQL executor's |
| 18 | +// arguments still block (they are executed), and a protected path in any |
| 19 | +// structural field still blocks (redaction never launders the target). |
| 20 | + |
| 21 | +// contentDocCases name every non-sqlEffect destruction rule once, as prose. |
| 22 | +var contentDocCases = []struct { |
| 23 | + name string |
| 24 | + content string |
| 25 | +}{ |
| 26 | + {"database-reset", "Ask the operator before `supabase db reset` on staging."}, |
| 27 | + {"filesystem-destruction", "Cleanup appendix: `rm -rf ~` is the classic footgun; never script it."}, |
| 28 | + {"git-history-destruction", "Policy: `git push --force origin main` is forbidden; use revert PRs."}, |
| 29 | + {"infrastructure-destruction", "Teardown runbook: `terraform destroy -auto-approve` after sign-off only."}, |
| 30 | + {"recovery-destruction", "Quarterly drill: delete backup snapshots older than the retention window."}, |
| 31 | + {"guard-state-doc", "Delivery state lives under `.git/boatstack/deliveries/` — helper-owned."}, |
| 32 | +} |
| 33 | + |
| 34 | +// Positive: documents whose bodies name every destruction rule (and the |
| 35 | +// managed state tree) pass as file-writer tool calls. |
| 36 | +func TestWrittenContentIsNotClassifiedAsEffect(t *testing.T) { |
| 37 | + repo := safetyTestRepo(t) |
| 38 | + for _, c := range contentDocCases { |
| 39 | + c := c |
| 40 | + t.Run("write/"+c.name, func(t *testing.T) { |
| 41 | + findings := ClassifyTool(repo, "Write", map[string]any{ |
| 42 | + "file_path": filepath.Join(repo, "docs", c.name+".md"), |
| 43 | + "content": c.content, |
| 44 | + }) |
| 45 | + if len(findings) > 0 { |
| 46 | + t.Fatalf("document content classified as effect: %#v", findings) |
| 47 | + } |
| 48 | + }) |
| 49 | + t.Run("edit/"+c.name, func(t *testing.T) { |
| 50 | + findings := ClassifyTool(repo, "Edit", map[string]any{ |
| 51 | + "file_path": filepath.Join(repo, "docs", c.name+".md"), |
| 52 | + "old_string": "TODO", |
| 53 | + "new_string": c.content, |
| 54 | + }) |
| 55 | + if len(findings) > 0 { |
| 56 | + t.Fatalf("edit content classified as effect: %#v", findings) |
| 57 | + } |
| 58 | + }) |
| 59 | + } |
| 60 | +} |
| 61 | + |
| 62 | +// Negative: the executor contexts keep the boundary — the same text blocks |
| 63 | +// when it IS the command, and executed SQL arguments stay live. |
| 64 | +func TestExecutorContextsStillBlockAfterRedaction(t *testing.T) { |
| 65 | + repo := safetyTestRepo(t) |
| 66 | + for _, command := range []string{ |
| 67 | + `terraform destroy -auto-approve`, |
| 68 | + `git push --force origin main`, |
| 69 | + `supabase db reset`, |
| 70 | + } { |
| 71 | + if findings := ClassifyCommand(repo, command); len(findings) == 0 { |
| 72 | + t.Fatalf("live command must still block: %q", command) |
| 73 | + } |
| 74 | + } |
| 75 | + if findings := ClassifyTool(repo, "mcp__db__execute_sql", map[string]any{"query": "DROP TABLE users"}); len(findings) == 0 { |
| 76 | + t.Fatal("SQL executor arguments are executed, not stored — must still block") |
| 77 | + } |
| 78 | +} |
| 79 | + |
| 80 | +// Relation: one table drives both outcomes for the identical hook-shaped tool |
| 81 | +// call — content alone allows, a protected structural path denies. |
| 82 | +func TestContentAllowsWhilePathDenies(t *testing.T) { |
| 83 | + repo := safetyTestRepo(t) |
| 84 | + content := "Ops note: state is under .git/boatstack/deliveries/ and terraform destroy is operator-only." |
| 85 | + |
| 86 | + if findings := ClassifyTool(repo, "Write", map[string]any{ |
| 87 | + "file_path": filepath.Join(repo, "docs", "ops.md"), |
| 88 | + "content": content, |
| 89 | + }); len(findings) > 0 { |
| 90 | + t.Fatalf("content-only mention must pass: %#v", findings) |
| 91 | + } |
| 92 | + |
| 93 | + findings := ClassifyTool(repo, "Write", map[string]any{ |
| 94 | + "file_path": ".git/boatstack/deliveries/checkout/state.json", |
| 95 | + "content": content, |
| 96 | + }) |
| 97 | + if len(findings) == 0 { |
| 98 | + t.Fatal("write INTO managed state must deny regardless of content") |
| 99 | + } |
| 100 | + if findings[0].Category != "workflow-state-tamper" { |
| 101 | + t.Fatalf("wrong category for state tamper: %#v", findings) |
| 102 | + } |
| 103 | +} |
| 104 | + |
| 105 | +// Bypass: redaction drops only content fields — a protected path smuggled in |
| 106 | +// any structural field (file_path, destination, nested) still blocks, and a |
| 107 | +// non-writer tool keeps full-input grading. |
| 108 | +func TestRedactionCannotLaunderProtectedTargets(t *testing.T) { |
| 109 | + repo := safetyTestRepo(t) |
| 110 | + |
| 111 | + for name, input := range map[string]map[string]any{ |
| 112 | + "file_path": {"file_path": ".git/boatstack/flow/trajectory.jsonl", "content": "x"}, |
| 113 | + "destination": {"file_path": "notes.md", "destination": ".git/boatstack/deliveries/x", "content": "x"}, |
| 114 | + "nested": {"file_path": "notes.md", "meta": map[string]any{"target_path": ".git/boatstack/runtimes/v1"}, "content": "x"}, |
| 115 | + } { |
| 116 | + if findings := ClassifyTool(repo, "Write", input); len(findings) == 0 { |
| 117 | + t.Fatalf("structural field %s must survive redaction and deny: %#v", name, input) |
| 118 | + } |
| 119 | + } |
| 120 | + |
| 121 | + // A tool with no extracted path is not a file-writer: full-input grading holds. |
| 122 | + if findings := ClassifyTool(repo, "mcp__infra__delete_resource", map[string]any{ |
| 123 | + "kind": "database", "note": "drop the staging cluster", |
| 124 | + }); len(findings) == 0 { |
| 125 | + t.Fatal("non-writer destructive tool must keep full-input grading") |
| 126 | + } |
| 127 | +} |
| 128 | + |
| 129 | +// Failure-state: redaction is pure — the caller's input map is never mutated, |
| 130 | +// and classification performs no I/O on the named document. |
| 131 | +func TestRedactionIsPureAndReadOnly(t *testing.T) { |
| 132 | + repo := safetyTestRepo(t) |
| 133 | + input := map[string]any{ |
| 134 | + "file_path": filepath.Join(repo, "docs", "ops.md"), |
| 135 | + "content": "terraform destroy notes", |
| 136 | + "meta": map[string]any{"body": "git reset --hard"}, |
| 137 | + } |
| 138 | + _ = ClassifyTool(repo, "Write", input) |
| 139 | + |
| 140 | + if input["content"] != "terraform destroy notes" { |
| 141 | + t.Fatalf("caller input mutated: %#v", input) |
| 142 | + } |
| 143 | + if meta := input["meta"].(map[string]any); meta["body"] != "git reset --hard" { |
| 144 | + t.Fatalf("nested caller input mutated: %#v", meta) |
| 145 | + } |
| 146 | + if strings.Contains(strings.ToLower("docs/ops.md"), "boatstack") { |
| 147 | + t.Fatal("fixture invariant") |
| 148 | + } |
| 149 | +} |
0 commit comments