graphban-cli 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: graphban-cli
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: gban — a Graphban client for the human at a terminal
5
5
  Project-URL: Homepage, https://github.com/asc-me/graphban
6
6
  Project-URL: Repository, https://github.com/asc-me/graphban
@@ -150,6 +150,22 @@ Three properties worth knowing, each of which is a bug this command exists to no
150
150
  `.mcp.json` is refused rather than warned about, because a warning attached to committing a
151
151
  credential still commits it.
152
152
 
153
+ ## Wiring a checkout to Swamp
154
+
155
+ ```bash
156
+ gban swamp setup
157
+ ```
158
+
159
+ Steps 3 and 5 of [the Swamp runbook](https://github.com/asc-me/graphban/blob/main/docs/swamp.md):
160
+ `repo init`, `extension source add`, `vault create`, and the **gate** credential — minted,
161
+ piped to `swamp vault put` on stdin, then checked for the scopes it actually came back with.
162
+ Every step is skipped when already done.
163
+
164
+ Two things it will not do. It does not install Swamp, because that install pipes a remote
165
+ script into a shell. And it never writes the gate key into an MCP config: `gban setup`'s agent
166
+ key must not carry `gate`, or the agent doing the work attests its own completion — which
167
+ fails silently, since a gate that always says yes looks exactly like a gate that held.
168
+
153
169
  ## `gban login` wants a real terminal
154
170
 
155
171
  It refuses without one, rather than prompting. `getpass` falls back to a plain **echoing**
@@ -135,6 +135,22 @@ Three properties worth knowing, each of which is a bug this command exists to no
135
135
  `.mcp.json` is refused rather than warned about, because a warning attached to committing a
136
136
  credential still commits it.
137
137
 
138
+ ## Wiring a checkout to Swamp
139
+
140
+ ```bash
141
+ gban swamp setup
142
+ ```
143
+
144
+ Steps 3 and 5 of [the Swamp runbook](https://github.com/asc-me/graphban/blob/main/docs/swamp.md):
145
+ `repo init`, `extension source add`, `vault create`, and the **gate** credential — minted,
146
+ piped to `swamp vault put` on stdin, then checked for the scopes it actually came back with.
147
+ Every step is skipped when already done.
148
+
149
+ Two things it will not do. It does not install Swamp, because that install pipes a remote
150
+ script into a shell. And it never writes the gate key into an MCP config: `gban setup`'s agent
151
+ key must not carry `gate`, or the agent doing the work attests its own completion — which
152
+ fails silently, since a gate that always says yes looks exactly like a gate that held.
153
+
138
154
  ## `gban login` wants a real terminal
139
155
 
140
156
  It refuses without one, rather than prompting. `getpass` falls back to a plain **echoing**
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "graphban-cli"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "gban — a Graphban client for the human at a terminal"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -15,7 +15,7 @@ import subprocess
15
15
  import sys
16
16
  from pathlib import Path
17
17
 
18
- from gban import config, doctor as doctor_mod, setup as setup_mod
18
+ from gban import config, doctor as doctor_mod, setup as setup_mod, swamp as swamp_mod
19
19
  from gban.client import (EXIT_NO_SESSION, EXIT_NO_SUPERVISOR, EXIT_REFUSED, EXIT_UNREACHABLE,
20
20
  Client, NoSession,
21
21
  Refused, Unreachable, authenticated, login)
@@ -73,6 +73,21 @@ def _parser() -> argparse.ArgumentParser:
73
73
  setup_cmd.add_argument("--no-install", action="store_true",
74
74
  help="do not install the supervisor")
75
75
 
76
+ swamp_cmd = sub.add_parser(
77
+ "swamp", help="wire this checkout to Swamp's Graphban adapter",
78
+ description=("Mints the GATE credential — the one that attests completion — and puts "
79
+ "it in the Swamp vault and nowhere else. It is deliberately not the "
80
+ "agent key `gban setup` writes into your MCP config: an agent that can "
81
+ "attest its own work turns the completion gate into theatre, and nothing "
82
+ "errors when it does. Does not install Swamp; a remote install script is "
83
+ "for a person to read and run."))
84
+ swamp_do = swamp_cmd.add_subparsers(dest="act")
85
+ swamp_setup = swamp_do.add_parser("setup", help="init, source, vault, and the gate key")
86
+ swamp_setup.add_argument("--adapter", default=swamp_mod.DEFAULT_ADAPTER,
87
+ help=f"the graphban-swamp checkout (default: {swamp_mod.DEFAULT_ADAPTER})")
88
+ swamp_setup.add_argument("--no-init", action="store_true",
89
+ help="refuse rather than run `swamp repo init` here")
90
+
76
91
  fleet = sub.add_parser(
77
92
  "fleet", help="hand off to gbfleet (the supervisor)",
78
93
  description=("Passes everything through to `gbfleet` and returns its exit code "
@@ -330,6 +345,37 @@ def _setup_auto(args, url: str, client) -> int:
330
345
  return worst
331
346
 
332
347
 
348
+ def cmd_swamp(args) -> int:
349
+ """Wire this checkout to Swamp's Graphban adapter (GRPH-796).
350
+
351
+ Mints a SECOND credential — the gate key — and puts it in the vault and nowhere else. The
352
+ agent key `gban setup` writes into the MCP config must never carry `gate`: an agent that
353
+ can attest its own completion turns the gate into theatre, and nothing errors when it does.
354
+ """
355
+ if args.act != "setup":
356
+ print(f"{PROG}: usage: {PROG} swamp setup", file=sys.stderr)
357
+ return EXIT_REFUSED
358
+ url = _server(args)
359
+ client = authenticated(url, act="swamp setup")
360
+ repo = Path.cwd()
361
+ asked = (args.project or os.environ.get(config.PROJECT_ENV) or "").strip()
362
+ try:
363
+ project, how = setup_mod.resolve_project(
364
+ client, repo, asked, config.settings().get("project", ""))
365
+ except setup_mod.Unresolved as exc:
366
+ print(f"{PROG}: {exc}", file=sys.stderr)
367
+ return EXIT_REFUSED
368
+ lines, code = swamp_mod.setup(client, url, project, repo,
369
+ Path(args.adapter).expanduser(), init=not args.no_init)
370
+ human = [f"{PROG}: {project} — {how}" if how != "named" else f"{PROG}: {project}",
371
+ doctor_mod.render(lines)]
372
+ if code == 0:
373
+ human.append(f"\n The gate key is in the vault and in no MCP config. Keep it that "
374
+ f"way: an agent that can attest its own work is not gated.")
375
+ _out({"lines": lines, "ok": code == 0, "project": project}, "\n".join(human), args.as_json)
376
+ return code
377
+
378
+
333
379
  def cmd_fleet(args) -> int:
334
380
  """A subprocess, never an import (D5).
335
381
 
@@ -481,7 +527,7 @@ def cmd_keys(args) -> int:
481
527
 
482
528
  COMMANDS = {"login": cmd_login, "logout": cmd_logout, "whoami": cmd_whoami,
483
529
  "doctor": cmd_doctor, "setup": cmd_setup, "fleet": cmd_fleet, "seats": cmd_seats,
484
- "agents": cmd_agents, "keys": cmd_keys}
530
+ "agents": cmd_agents, "keys": cmd_keys, "swamp": cmd_swamp}
485
531
 
486
532
 
487
533
  def main(argv: list[str] | None = None) -> int:
@@ -0,0 +1,278 @@
1
+ """Wire a checkout to Swamp's Graphban adapter (GRPH-796).
2
+
3
+ `docs/swamp.md` §3 and §5 are five commands and one credential, and the credential is the
4
+ part worth automating carefully rather than the commands.
5
+
6
+ **The gate key is not the agent key, and this is the whole reason to be careful.** `gban
7
+ setup` mints an agent credential and writes it into the harness MCP config, where the agent
8
+ doing the work reads it. A gate key attests completion: `update_item(status="done")` refuses
9
+ without one. Put a gate key where the building agent can read it and the completion gate stops
10
+ being a gate — an agent would attest its own work, and nothing would error. So this mints a
11
+ SECOND, separate credential and puts it in exactly one place: the Swamp vault. It never writes
12
+ to an MCP config, and `test_swamp.py` asserts that rather than trusting the code to stay that
13
+ way.
14
+
15
+ **It does not install Swamp.** The documented install is `curl -fsSL … | sh`, and running a
16
+ remote script is categorically different from `uv tool install graphban-fleet`, which names a
17
+ package in a package manager. A missing `swamp` is reported with the command to run, for a
18
+ person to run.
19
+
20
+ **It shells out; it does not wrap.** Same shape as `gban fleet` → `gbfleet`, and here it is
21
+ also a licensing boundary: Swamp is AGPL-3.0 with an extension exception that forbids copies
22
+ of its source. Calling a binary is not copying one.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import shutil
28
+ import subprocess
29
+ from pathlib import Path
30
+
31
+ from gban.client import Client, Refused, Unreachable
32
+ from gban.doctor import FAIL, PASS, UNKNOWN, _line
33
+
34
+ #: What `docs/swamp.md` §5 names. Changing either renames somebody's existing wiring, so they
35
+ #: are constants rather than options.
36
+ VAULT = "secrets"
37
+ SECRET_KEY = "graphban-api-key"
38
+ VAULT_TYPE = "local_encryption"
39
+
40
+ #: The adapter repository, a SIBLING of the checkout — `docs/swamp.md` says "sibling, not this
41
+ #: tree", because a nested copy is one `git add` from being committed into a repository whose
42
+ #: licence forbids carrying it.
43
+ DEFAULT_ADAPTER = "../graphban-swamp"
44
+
45
+ #: Reported, never run. See the module docstring.
46
+ INSTALL = "curl -fsSL https://swamp-club.com/install.sh | sh # review it first"
47
+
48
+ #: `read` and `write` ride along with `gate` deliberately: `attest_ci.py` attests through
49
+ #: `update_item`, which `mcp_server` refuses without `write`, so a `gate`-only key mints
50
+ #: successfully and 403s on the first real attestation. `fleet.mint` carries all three for the
51
+ #: same reason.
52
+ GATE_SCOPES = ["read", "write", "gate"]
53
+
54
+
55
+ def find() -> str:
56
+ return shutil.which("swamp") or ""
57
+
58
+
59
+ def run(repo: Path, *args: str, stdin: str | None = None,
60
+ timeout: float = 120.0) -> subprocess.CompletedProcess:
61
+ """One swamp invocation, always `--json`, always scoped to `repo` by WORKING DIRECTORY.
62
+
63
+ Not `--repo-dir`. Swamp's own "Not a swamp repository" error recommends that flag —
64
+ "specify an existing repository with `swamp <command> --repo-dir /path/to/repo`" — and
65
+ `swamp 20260830` rejects it on every verb tried, `repo init`, `vault list` and
66
+ `extension source list` alike: *Unknown option "--repo-dir"*. Taking a tool's advice about
67
+ its own flags is exactly the kind of assumption that survives a test suite and dies on
68
+ contact, which is what happened here.
69
+
70
+ `cwd` is what swamp actually uses to find a repository, so it is what scopes this. The
71
+ caller's own directory is never relied on.
72
+ """
73
+ return subprocess.run(["swamp", *args, "--json"], input=stdin, cwd=str(repo),
74
+ capture_output=True, text=True, timeout=timeout)
75
+
76
+
77
+ def spoke(done: subprocess.CompletedProcess) -> dict:
78
+ """Swamp answers in JSON on both paths, including its errors — PRETTY-PRINTED across
79
+ several lines, which is why this parses whole streams rather than lines.
80
+
81
+ Reading line by line found nothing and left the caller falling back to "the last line of
82
+ stderr", which for a formatted object is `}`. An error report of `}` is worse than none:
83
+ it looks like a real message.
84
+ """
85
+ for stream in (done.stdout, done.stderr):
86
+ text = (stream or "").strip()
87
+ if not text:
88
+ continue
89
+ try:
90
+ got = json.loads(text)
91
+ except ValueError:
92
+ start = text.find("{")
93
+ try:
94
+ got = json.loads(text[start:]) if start >= 0 else None
95
+ except ValueError:
96
+ got = None
97
+ if isinstance(got, dict):
98
+ return got
99
+ return {}
100
+
101
+
102
+ def initialised(repo: Path) -> bool:
103
+ """A `.swamp.yaml` is the repository. Asked of the FILE rather than by running a command,
104
+ so a swamp that is missing or broken does not read as an uninitialised checkout."""
105
+ return (repo / ".swamp.yaml").exists()
106
+
107
+
108
+ # The response keys below are what `swamp 20260830` ACTUALLY returns, captured by running it.
109
+ # The first draft guessed `vaults` and `keys`; the real names are `results` and `secretKeys`,
110
+ # and the guesses failed silently in the worst direction — `secret_present` would have returned
111
+ # False forever, so every run would mint another live gate credential and overwrite the vault
112
+ # entry. The test double agreed, because it was built from the same guess.
113
+
114
+ def vaults(repo: Path) -> list[str]:
115
+ got = spoke(run(repo, "vault", "list"))
116
+ rows = got.get("results") if isinstance(got, dict) else None
117
+ if not isinstance(rows, list):
118
+ return []
119
+ return [str(r.get("name") or "") for r in rows if isinstance(r, dict)]
120
+
121
+
122
+ def secret_present(repo: Path) -> bool:
123
+ """`list-keys` names the keys and NOT their values, which is why idempotence can be
124
+ decided without reading a credential into this process at all."""
125
+ got = spoke(run(repo, "vault", "list-keys", VAULT))
126
+ keys = got.get("secretKeys") if isinstance(got, dict) else None
127
+ if isinstance(keys, list):
128
+ return any(SECRET_KEY == (k if isinstance(k, str) else str((k or {}).get("key", "")))
129
+ for k in keys)
130
+ return False
131
+
132
+
133
+ def sources(repo: Path) -> list[str]:
134
+ got = spoke(run(repo, "extension", "source", "list"))
135
+ rows = got.get("sources") if isinstance(got, dict) else None
136
+ if not isinstance(rows, list):
137
+ return []
138
+ return [str(r.get("path") or r) if isinstance(r, dict) else str(r) for r in rows]
139
+
140
+
141
+ def mint_gate(client: Client, project: str) -> dict:
142
+ """A gate credential, pinned to one project because the server insists and because the
143
+ insistence is right: `key_gate_ids` falls back to every writable project when
144
+ `project_id` is null, so one leaked CI secret would attest completions across all of them.
145
+
146
+ No expiry, for the same reason `gban setup`'s key has none — a credential that dies
147
+ overnight makes "CI can attest" quietly stop being true, and the failure surfaces as a
148
+ completion refusal nobody connects to a key.
149
+ """
150
+ return client.call("POST", "/api/api-keys",
151
+ {"name": f"{project} swamp gate", "project_id": project,
152
+ "expires_in_days": None, "scopes": GATE_SCOPES})
153
+
154
+
155
+ def verify(url: str, key: str, project: str) -> list[dict]:
156
+ """Ask the credential what it is, rather than trusting the mint.
157
+
158
+ A gate key that cannot WRITE is the specific dead key this checks for: `attest_ci.py`
159
+ attests through `update_item`, so `gate` alone mints fine and 403s on the first real
160
+ attestation — months later, in CI.
161
+ """
162
+ try:
163
+ got = Client(url, api_key=key).call(
164
+ "POST", "/api/mcp",
165
+ {"jsonrpc": "2.0", "id": 1, "method": "tools/call",
166
+ "params": {"name": "get_context", "arguments": {"project_id": project}}})
167
+ ctx = json.loads(got["result"]["content"][0]["text"])
168
+ except (Unreachable, Refused) as exc:
169
+ return [_line("ledger", FAIL, "gate key", f"minted, but unusable: {exc}")]
170
+ except (KeyError, IndexError, TypeError, ValueError):
171
+ return [_line("ledger", UNKNOWN, "gate key",
172
+ "the server answered get_context in a shape this version cannot read")]
173
+ scopes = set(ctx.get("scopes") or [])
174
+ missing = [s for s in ("write", "gate") if s not in scopes]
175
+ if missing:
176
+ return [_line("ledger", FAIL, "gate key",
177
+ f"minted without {', '.join(missing)} — it would 403 on the first real "
178
+ "attestation rather than here")]
179
+ return [_line("ledger", PASS, "gate key",
180
+ f"read+write+gate on {project}, and it is not the agent's key")]
181
+
182
+
183
+ def setup(client: Client, url: str, project: str, repo: Path, adapter: Path,
184
+ *, init: bool = True) -> tuple[list[dict], int]:
185
+ """Wire this checkout. Every step is skipped when already done and says which.
186
+
187
+ Nothing here writes an MCP config. The gate key goes into the vault and nowhere else.
188
+ """
189
+ lines: list[dict] = []
190
+ if not find():
191
+ return [_line("local", UNKNOWN, "swamp",
192
+ f"not installed. Install it yourself — this does not run remote "
193
+ f"scripts:\n{INSTALL}")], 0
194
+
195
+ if not initialised(repo):
196
+ if not init:
197
+ return lines + [_line("local", FAIL, "repository",
198
+ f"{repo} has no .swamp.yaml and --no-init was given")], 1
199
+ done = run(repo, "repo", "init", "--tool", "none")
200
+ if done.returncode != 0:
201
+ return lines + [_line("local", FAIL, "repository",
202
+ f"swamp repo init failed: {_why(done)}")], 1
203
+ lines.append(_line("local", PASS, "repository", f"initialised {repo/'.swamp.yaml'}"))
204
+ else:
205
+ # NEVER --force. `docs/swamp.md`: do not re-init a tree that already has a vault.
206
+ lines.append(_line("local", PASS, "repository", ".swamp.yaml already here"))
207
+
208
+ if not adapter.exists():
209
+ lines.append(_line("local", FAIL, "adapter",
210
+ f"{adapter} does not exist — clone asc-me/graphban-swamp beside "
211
+ "this checkout, or pass --adapter"))
212
+ return lines, 1
213
+ if adapter.resolve().is_relative_to(repo.resolve()):
214
+ # The runbook says "sibling, not this tree", and the reason is a licence: a nested
215
+ # copy is one `git add` from being committed into a repository that may not carry it.
216
+ lines.append(_line("local", UNKNOWN, "adapter",
217
+ f"{adapter} is INSIDE this checkout; the runbook asks for a "
218
+ "sibling, because a nested copy can be committed by accident"))
219
+ known = sources(repo)
220
+ if any(Path(s).resolve() == adapter.resolve() for s in known if s):
221
+ lines.append(_line("local", PASS, "adapter", f"{adapter} already a source"))
222
+ else:
223
+ done = run(repo, "extension", "source", "add", str(adapter))
224
+ lines.append(_line("local", PASS if done.returncode == 0 else FAIL, "adapter",
225
+ str(adapter) if done.returncode == 0
226
+ else f"source add failed: {_why(done)}"))
227
+ if done.returncode != 0:
228
+ return lines, 1
229
+
230
+ if VAULT in vaults(repo):
231
+ lines.append(_line("local", PASS, "vault", f"{VAULT!r} already exists"))
232
+ else:
233
+ done = run(repo, "vault", "create", VAULT_TYPE, VAULT)
234
+ if done.returncode != 0:
235
+ return lines + [_line("local", FAIL, "vault",
236
+ f"vault create failed: {_why(done)}")], 1
237
+ lines.append(_line("local", PASS, "vault", f"created {VAULT!r} ({VAULT_TYPE})"))
238
+
239
+ if secret_present(repo):
240
+ # Left alone rather than replaced. Overwriting would strand a credential that is
241
+ # still live on the server, and the person may have put a deliberate one there.
242
+ lines.append(_line("local", PASS, "secret",
243
+ f"{VAULT}/{SECRET_KEY} already set — left alone. Delete it first "
244
+ "to mint a new one"))
245
+ return lines, _worst(lines)
246
+
247
+ try:
248
+ minted = mint_gate(client, project)
249
+ except Refused as exc:
250
+ return lines + [_line("ledger", FAIL, "mint", str(exc))], 1
251
+ key = minted.get("plaintext") or ""
252
+ if not key:
253
+ return lines + [_line("ledger", FAIL, "mint", "the server returned no key")], 1
254
+
255
+ # STDIN, never argv. Swamp's own help says so: "Piping via stdin is recommended for
256
+ # scripts and CI to avoid exposing secrets in the process argument list."
257
+ done = run(repo, "vault", "put", VAULT, SECRET_KEY, stdin=key)
258
+ if done.returncode != 0:
259
+ return lines + [_line("local", FAIL, "secret",
260
+ f"the key was MINTED and not stored ({_why(done)}) — revoke it "
261
+ "in Settings → API keys, it is live and in nothing")], 1
262
+ lines.append(_line("local", PASS, "secret", f"{VAULT}/{SECRET_KEY} stored from stdin"))
263
+ lines += verify(url, key, project)
264
+ return lines, _worst(lines)
265
+
266
+
267
+ def _why(done: subprocess.CompletedProcess) -> str:
268
+ got = spoke(done)
269
+ if isinstance(got, dict) and got.get("error"):
270
+ return str(got["error"])[:200]
271
+ tail = (done.stderr or done.stdout or "").strip().splitlines()
272
+ return (tail[-1][:200] if tail else f"exit {done.returncode}")
273
+
274
+
275
+ def _worst(lines: list[dict]) -> int:
276
+ from gban.doctor import SEVERITY
277
+
278
+ return 1 if max((SEVERITY[l["status"]] for l in lines), default=0) == SEVERITY[FAIL] else 0
@@ -164,7 +164,7 @@ INVOCATIONS = {
164
164
  #: Driven separately: `login` sends a password (its own test), `doctor` and `fleet` are PR 2,
165
165
  #: `setup` writes config files and so needs a repository and a target of its own
166
166
  #: (`test_setup.py`, which makes the same route-documentation assertion this file does).
167
- NOT_DRIVEN_HERE = {"login", "doctor", "fleet", "setup"}
167
+ NOT_DRIVEN_HERE = {"login", "doctor", "fleet", "setup", "swamp"}
168
168
 
169
169
 
170
170
  def _documented() -> list[re.Pattern]:
@@ -0,0 +1,322 @@
1
+ """`gban swamp setup` — the gate credential, and where it must not go (GRPH-796).
2
+
3
+ The load-bearing test in this file is `test_the_gate_key_never_reaches_an_mcp_config`. Every
4
+ other failure here is loud; that one is silent. A gate key written where `gban setup` writes
5
+ the agent key hands a completion-attesting credential to the agent doing the work — and then
6
+ `update_item(status="done")` succeeds, CI is green, the board says the gate held, and it did
7
+ not. There is no error to notice.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import subprocess
13
+ from pathlib import Path
14
+
15
+ import pytest
16
+
17
+ from gban import setup as setup_mod, swamp as swamp_mod
18
+ from gban.client import Refused
19
+
20
+ URL = "http://gb.invalid"
21
+
22
+
23
+ class Server:
24
+ def __init__(self, *, scopes=("read", "write", "gate"), refuse=None):
25
+ self.scopes, self.refuse, self.minted = list(scopes), refuse, []
26
+
27
+ def call(self, method, path, body=None):
28
+ if path == "/api/api-keys":
29
+ if self.refuse is not None:
30
+ raise self.refuse
31
+ self.minted.append(body)
32
+ return {"id": "k1", "plaintext": "gb_sk_gate1"}
33
+ raise AssertionError(f"unexpected {method} {path}")
34
+
35
+
36
+ @pytest.fixture()
37
+ def ctx(monkeypatch):
38
+ """`get_context` through the minted key, as the deployment answers it."""
39
+ def _wire(server):
40
+ def call(self, method, path, body=None):
41
+ return {"result": {"content": [{"text": json.dumps({"scopes": server.scopes})}]}}
42
+ monkeypatch.setattr(swamp_mod.Client, "call", call)
43
+ return server
44
+ return _wire
45
+
46
+
47
+ class Swamp:
48
+ """A recorded `swamp`. Answers `--json` the way the real binary does, errors included."""
49
+
50
+ def __init__(self, *, vaults=(), keys=(), sources=(), fail=()):
51
+ self.vaults, self.keys, self.sources = list(vaults), list(keys), list(sources)
52
+ self.fail, self.calls, self.stdin, self.cwds = set(fail), [], [], []
53
+
54
+ def __call__(self, argv, input=None, capture_output=True, text=True, timeout=None,
55
+ cwd=None):
56
+ self.cwds.append(cwd)
57
+ verb = " ".join(a for a in argv[1:] if not a.startswith("--"))
58
+ self.calls.append(verb)
59
+ if input is not None:
60
+ self.stdin.append(input)
61
+ for bad in self.fail:
62
+ if verb.startswith(bad):
63
+ return subprocess.CompletedProcess(argv, 1, "", '{"error": "nope"}')
64
+ payload = {}
65
+ # THE SHAPES ARE COPIED FROM THE REAL BINARY, not invented. The first version of this
66
+ # double answered `vaults` and `keys`; `swamp 20260830` answers `results` and
67
+ # `secretKeys`. Both readers failed silently against it and the double agreed with
68
+ # them, because the double and the reader were written from one guess.
69
+ if verb.startswith("vault list-keys"):
70
+ payload = {"vaultName": "secrets", "vaultType": "local_encryption",
71
+ "secretKeys": list(self.keys), "count": len(self.keys)}
72
+ elif verb.startswith("vault list"):
73
+ payload = {"query": "", "results": [
74
+ {"id": "v1", "name": v, "type": "local_encryption"} for v in self.vaults]}
75
+ elif verb.startswith("extension source list"):
76
+ payload = {"sources": [{"path": s, "expandedPaths": [s], "status": "valid"}
77
+ for s in self.sources]}
78
+ return subprocess.CompletedProcess(argv, 0, json.dumps(payload), "")
79
+
80
+
81
+ @pytest.fixture()
82
+ def wired(monkeypatch):
83
+ def _wire(fake):
84
+ monkeypatch.setattr(swamp_mod, "find", lambda: "/usr/local/bin/swamp")
85
+ monkeypatch.setattr(swamp_mod.subprocess, "run", fake)
86
+ return fake
87
+ return _wire
88
+
89
+
90
+ def _tree(tmp_path):
91
+ repo, adapter = tmp_path / "repo", tmp_path / "graphban-swamp"
92
+ repo.mkdir()
93
+ adapter.mkdir()
94
+ (repo / ".swamp.yaml").write_text("{}\n")
95
+ return repo, adapter
96
+
97
+
98
+ # ---- the one that is silent when it breaks -------------------------------------------------
99
+
100
+ def test_the_gate_key_never_reaches_an_mcp_config(tmp_path, wired, ctx, monkeypatch):
101
+ """THE INVARIANT. `gban setup` writes the agent key into ~/.claude.json; a gate key there
102
+ would let the building agent attest its own completion, with nothing to notice."""
103
+ repo, adapter = _tree(tmp_path)
104
+ wired(Swamp(vaults=["secrets"], sources=[str(adapter)]))
105
+ monkeypatch.setattr(setup_mod, "write_entries",
106
+ lambda *a, **k: pytest.fail("swamp setup wrote an MCP config"))
107
+ home = tmp_path / ".claude.json"
108
+ monkeypatch.setattr(setup_mod, "claude_home", lambda: home)
109
+
110
+ lines, code = swamp_mod.setup(ctx(Server()), URL, "core", repo, adapter)
111
+
112
+ assert code == 0, lines
113
+ assert not home.exists(), "swamp setup created a harness config"
114
+ assert not (repo / ".mcp.json").exists()
115
+
116
+
117
+ def test_the_secret_travels_on_stdin_not_argv(tmp_path, wired, ctx):
118
+ """`ps` is world-readable, and Swamp's own help says the same: piping via stdin is
119
+ recommended for scripts and CI to avoid exposing secrets in the argument list."""
120
+ repo, adapter = _tree(tmp_path)
121
+ fake = wired(Swamp(vaults=["secrets"], sources=[str(adapter)]))
122
+
123
+ swamp_mod.setup(ctx(Server()), URL, "core", repo, adapter)
124
+
125
+ assert "gb_sk_gate1" in fake.stdin
126
+ for call in fake.calls:
127
+ assert "gb_sk_" not in call, f"a credential appeared in argv: {call}"
128
+
129
+
130
+ def test_the_key_is_minted_with_write_as_well_as_gate(tmp_path, wired, ctx):
131
+ """`gate` alone mints fine and 403s on the first real attestation, because `attest_ci.py`
132
+ attests through `update_item` — months later, in CI."""
133
+ repo, adapter = _tree(tmp_path)
134
+ wired(Swamp(vaults=["secrets"], sources=[str(adapter)]))
135
+ server = Server()
136
+
137
+ swamp_mod.setup(ctx(server), URL, "core", repo, adapter)
138
+
139
+ body = server.minted[0]
140
+ assert set(body["scopes"]) == {"read", "write", "gate"}
141
+ assert body["project_id"] == "core", "an unpinned gate key attests everywhere"
142
+ assert body["expires_in_days"] is None
143
+
144
+
145
+ def test_a_key_that_came_back_without_write_fails_here_not_in_ci(tmp_path, wired, ctx):
146
+ repo, adapter = _tree(tmp_path)
147
+ wired(Swamp(vaults=["secrets"], sources=[str(adapter)]))
148
+
149
+ lines, code = swamp_mod.setup(ctx(Server(scopes=("read", "gate"))), URL, "core", repo, adapter)
150
+
151
+ assert code == 1
152
+ assert any("403 on the first real attestation" in l["detail"] for l in lines)
153
+
154
+
155
+ # ---- refusing to do a person's job ----------------------------------------------------------
156
+
157
+ def test_a_missing_swamp_is_reported_never_installed(tmp_path, monkeypatch, ctx):
158
+ """The documented install pipes a remote script into a shell. That is not something a
159
+ tool should do on somebody's behalf, however convenient."""
160
+ repo, adapter = _tree(tmp_path)
161
+ monkeypatch.setattr(swamp_mod, "find", lambda: "")
162
+ monkeypatch.setattr(swamp_mod.subprocess, "run",
163
+ lambda *a, **k: pytest.fail("ran a command with no swamp installed"))
164
+
165
+ lines, code = swamp_mod.setup(ctx(Server()), URL, "core", repo, adapter)
166
+
167
+ assert code == 0, "a missing swamp is not a failed setup"
168
+ assert any("curl" in l["detail"] and "install.sh" in l["detail"] for l in lines)
169
+
170
+
171
+ def test_an_existing_secret_is_left_alone(tmp_path, wired, ctx):
172
+ """Overwriting would strand a live credential on the server, and the one there may be
173
+ deliberate."""
174
+ repo, adapter = _tree(tmp_path)
175
+ wired(Swamp(vaults=["secrets"], keys=["graphban-api-key"], sources=[str(adapter)]))
176
+ server = Server()
177
+
178
+ lines, code = swamp_mod.setup(ctx(server), URL, "core", repo, adapter)
179
+
180
+ assert code == 0
181
+ assert server.minted == [], "minted a second gate key over a working one"
182
+ assert any("left alone" in l["detail"] for l in lines)
183
+
184
+
185
+ def test_an_initialised_repo_is_never_reinitialised(tmp_path, wired, ctx):
186
+ """`docs/swamp.md`: do not `repo init --force` a tree that already has a vault."""
187
+ repo, adapter = _tree(tmp_path)
188
+ fake = wired(Swamp(vaults=["secrets"], sources=[str(adapter)]))
189
+
190
+ swamp_mod.setup(ctx(Server()), URL, "core", repo, adapter)
191
+
192
+ assert not any(c.startswith("repo init") for c in fake.calls)
193
+ assert not any("--force" in c for c in fake.calls)
194
+
195
+
196
+ def test_a_nested_adapter_is_flagged(tmp_path, wired, ctx):
197
+ """The runbook asks for a sibling. A copy inside the tree is one `git add` from being
198
+ committed into a repository whose licence forbids carrying Swamp source."""
199
+ repo, _ = _tree(tmp_path)
200
+ inside = repo / "graphban-swamp"
201
+ inside.mkdir()
202
+ wired(Swamp(vaults=["secrets"], sources=[str(inside)]))
203
+
204
+ lines, _ = swamp_mod.setup(ctx(Server()), URL, "core", repo, inside)
205
+
206
+ assert any("INSIDE this checkout" in l["detail"] for l in lines)
207
+
208
+
209
+ def test_a_missing_adapter_refuses_before_minting(tmp_path, wired, ctx):
210
+ repo, _ = _tree(tmp_path)
211
+ wired(Swamp(vaults=["secrets"]))
212
+ server = Server()
213
+
214
+ lines, code = swamp_mod.setup(ctx(server), URL, "core", repo, tmp_path / "nope")
215
+
216
+ assert code == 1
217
+ assert server.minted == [], "minted a key for a wiring it could not finish"
218
+
219
+
220
+ def test_a_key_minted_and_not_stored_says_to_revoke_it(tmp_path, wired, ctx):
221
+ """The one genuinely bad state: live on the server, present in nothing. It must not be
222
+ reported as a generic failure."""
223
+ repo, adapter = _tree(tmp_path)
224
+ wired(Swamp(vaults=["secrets"], sources=[str(adapter)], fail=["vault put"]))
225
+
226
+ lines, code = swamp_mod.setup(ctx(Server()), URL, "core", repo, adapter)
227
+
228
+ assert code == 1
229
+ said = " ".join(l["detail"] for l in lines)
230
+ assert "revoke it" in said and "Settings" in said
231
+
232
+
233
+ def test_a_refused_mint_stores_nothing(tmp_path, wired, ctx):
234
+ repo, adapter = _tree(tmp_path)
235
+ fake = wired(Swamp(vaults=["secrets"], sources=[str(adapter)]))
236
+
237
+ lines, code = swamp_mod.setup(ctx(Server(refuse=Refused(403, "no", ""))), URL, "core",
238
+ repo, adapter)
239
+
240
+ assert code == 1
241
+ assert not any(c.startswith("vault put") for c in fake.calls)
242
+
243
+
244
+ def test_every_swamp_call_is_scoped_to_the_repository(tmp_path, wired, ctx):
245
+ """By `cwd`, because `--repo-dir` does not exist. Swamp's own error text recommends that
246
+ flag and `swamp 20260830` rejects it on every verb — found by running the real binary,
247
+ which is the only place a tool's advice about its own flags can be checked.
248
+
249
+ Scoping matters either way: a caller standing somewhere else must not wire a different
250
+ checkout, silently."""
251
+ repo, adapter = _tree(tmp_path)
252
+ fake = wired(Swamp(vaults=["secrets"], sources=[str(adapter)]))
253
+
254
+ swamp_mod.setup(ctx(Server()), URL, "core", repo, adapter)
255
+
256
+ assert fake.cwds, "no swamp call was made"
257
+ assert all(c == str(repo) for c in fake.cwds), fake.cwds
258
+ assert not any("--repo-dir" in c for c in fake.calls)
259
+
260
+
261
+ def test_a_pretty_printed_error_is_read_as_one(tmp_path, wired, ctx):
262
+ """Swamp formats its JSON across lines. Reading line by line found nothing and fell back
263
+ to the last line of stderr — which for a formatted object is `}`, an error report that
264
+ looks like a real message."""
265
+ repo, adapter = _tree(tmp_path)
266
+
267
+ class Formatted(Swamp):
268
+ def __call__(self, argv, **kw):
269
+ done = super().__call__(argv, **kw)
270
+ if "put" in argv:
271
+ return subprocess.CompletedProcess(
272
+ argv, 1, "", '{\n "error": "vault is sealed"\n}\n')
273
+ return done
274
+
275
+ wired(Formatted(vaults=["secrets"], sources=[str(adapter)]))
276
+ lines, code = swamp_mod.setup(ctx(Server()), URL, "core", repo, adapter)
277
+
278
+ assert code == 1
279
+ said = " ".join(l["detail"] for l in lines)
280
+ assert "vault is sealed" in said, said
281
+
282
+
283
+ def test_every_route_swamp_setup_calls_is_documented(tmp_path, wired, monkeypatch):
284
+ """The check `test_acts.py` makes for the verbs it drives, made here for the one it
285
+ cannot: `swamp` needs a checkout and a binary of its own."""
286
+ repo, adapter = _tree(tmp_path)
287
+ wired(Swamp(vaults=["secrets"], sources=[str(adapter)]))
288
+ reference = (Path(__file__).resolve().parents[2] / "docs" / "api-reference.md"
289
+ ).read_text(encoding="utf-8")
290
+ seen = []
291
+
292
+ class Watching(Server):
293
+ def call(self, method, path, body=None):
294
+ seen.append(path)
295
+ return super().call(method, path, body)
296
+
297
+ def context(self, method, path, body=None):
298
+ seen.append(path)
299
+ return {"result": {"content": [{"text": json.dumps({"scopes": list(swamp_mod.GATE_SCOPES)})}]}}
300
+
301
+ monkeypatch.setattr(swamp_mod.Client, "call", context)
302
+ swamp_mod.setup(Watching(), URL, "core", repo, adapter)
303
+
304
+ assert seen, "swamp setup made no HTTP call"
305
+ for path in seen:
306
+ assert f"`{path}`" in reference, f"{path} is not in docs/api-reference.md"
307
+
308
+
309
+ def test_the_readers_match_what_swamp_actually_returns(tmp_path, wired, ctx):
310
+ """A shape pinned against the recorded output of `swamp 20260830`.
311
+
312
+ This exists because the alternative already happened: the readers looked for `vaults` and
313
+ `keys`, the real binary answers `results` and `secretKeys`, and the double was written
314
+ from the same guess as the readers — so the suite was green while `secret_present` could
315
+ only ever return False. That failure mints another live gate credential on every run.
316
+ """
317
+ repo, adapter = _tree(tmp_path)
318
+ wired(Swamp(vaults=["secrets", "other"], keys=["graphban-api-key"], sources=[str(adapter)]))
319
+
320
+ assert swamp_mod.vaults(repo) == ["secrets", "other"]
321
+ assert swamp_mod.secret_present(repo) is True
322
+ assert swamp_mod.sources(repo) == [str(adapter)]
File without changes
File without changes