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.
- {graphban_cli-0.2.0 → graphban_cli-0.3.0}/PKG-INFO +17 -1
- {graphban_cli-0.2.0 → graphban_cli-0.3.0}/README.md +16 -0
- {graphban_cli-0.2.0 → graphban_cli-0.3.0}/pyproject.toml +1 -1
- {graphban_cli-0.2.0 → graphban_cli-0.3.0}/src/gban/cli.py +48 -2
- graphban_cli-0.3.0/src/gban/swamp.py +278 -0
- {graphban_cli-0.2.0 → graphban_cli-0.3.0}/tests/test_acts.py +1 -1
- graphban_cli-0.3.0/tests/test_swamp.py +322 -0
- {graphban_cli-0.2.0 → graphban_cli-0.3.0}/.gitignore +0 -0
- {graphban_cli-0.2.0 → graphban_cli-0.3.0}/LICENSE +0 -0
- {graphban_cli-0.2.0 → graphban_cli-0.3.0}/src/gban/__init__.py +0 -0
- {graphban_cli-0.2.0 → graphban_cli-0.3.0}/src/gban/client.py +0 -0
- {graphban_cli-0.2.0 → graphban_cli-0.3.0}/src/gban/config.py +0 -0
- {graphban_cli-0.2.0 → graphban_cli-0.3.0}/src/gban/doctor.py +0 -0
- {graphban_cli-0.2.0 → graphban_cli-0.3.0}/src/gban/setup.py +0 -0
- {graphban_cli-0.2.0 → graphban_cli-0.3.0}/src/gban/skills/graphban-delegation/SKILL.md +0 -0
- {graphban_cli-0.2.0 → graphban_cli-0.3.0}/tests/test_doctor.py +0 -0
- {graphban_cli-0.2.0 → graphban_cli-0.3.0}/tests/test_login_projects.py +0 -0
- {graphban_cli-0.2.0 → graphban_cli-0.3.0}/tests/test_packaging.py +0 -0
- {graphban_cli-0.2.0 → graphban_cli-0.3.0}/tests/test_session.py +0 -0
- {graphban_cli-0.2.0 → graphban_cli-0.3.0}/tests/test_setup.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: graphban-cli
|
|
3
|
-
Version: 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**
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|