graphban-cli 0.4.0__tar.gz → 0.5.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.
Files changed (23) hide show
  1. {graphban_cli-0.4.0 → graphban_cli-0.5.0}/.gitignore +18 -0
  2. {graphban_cli-0.4.0 → graphban_cli-0.5.0}/PKG-INFO +15 -3
  3. {graphban_cli-0.4.0 → graphban_cli-0.5.0}/README.md +14 -2
  4. {graphban_cli-0.4.0 → graphban_cli-0.5.0}/pyproject.toml +1 -1
  5. {graphban_cli-0.4.0 → graphban_cli-0.5.0}/src/gban/cli.py +5 -3
  6. graphban_cli-0.5.0/src/gban/config.py +212 -0
  7. {graphban_cli-0.4.0 → graphban_cli-0.5.0}/src/gban/doctor.py +70 -7
  8. graphban_cli-0.5.0/src/gban/gitignore.py +176 -0
  9. {graphban_cli-0.4.0 → graphban_cli-0.5.0}/src/gban/setup.py +90 -21
  10. {graphban_cli-0.4.0 → graphban_cli-0.5.0}/src/gban/skills/graphban-delegation/SKILL.md +7 -1
  11. graphban_cli-0.5.0/src/gban/skills/watch-wave/SKILL.md +66 -0
  12. {graphban_cli-0.4.0 → graphban_cli-0.5.0}/src/gban/swamp.py +5 -0
  13. {graphban_cli-0.4.0 → graphban_cli-0.5.0}/tests/test_acts.py +4 -1
  14. {graphban_cli-0.4.0 → graphban_cli-0.5.0}/tests/test_doctor.py +258 -23
  15. {graphban_cli-0.4.0 → graphban_cli-0.5.0}/tests/test_session.py +1 -2
  16. {graphban_cli-0.4.0 → graphban_cli-0.5.0}/tests/test_setup.py +169 -1
  17. {graphban_cli-0.4.0 → graphban_cli-0.5.0}/tests/test_swamp.py +26 -1
  18. graphban_cli-0.4.0/src/gban/config.py +0 -102
  19. {graphban_cli-0.4.0 → graphban_cli-0.5.0}/LICENSE +0 -0
  20. {graphban_cli-0.4.0 → graphban_cli-0.5.0}/src/gban/__init__.py +0 -0
  21. {graphban_cli-0.4.0 → graphban_cli-0.5.0}/src/gban/client.py +0 -0
  22. {graphban_cli-0.4.0 → graphban_cli-0.5.0}/tests/test_login_projects.py +0 -0
  23. {graphban_cli-0.4.0 → graphban_cli-0.5.0}/tests/test_packaging.py +0 -0
@@ -57,6 +57,24 @@ dist-release/
57
57
  # Project MCP config — holds a real API key
58
58
  .mcp.json
59
59
 
60
+ # gban --scope project writes the same live key into Grok's project file, and
61
+ # gbfleet's grok adapter writes the child's seat there. Tracked, spawn refuses
62
+ # (fleet/tests/test_repo_keeps_its_own_config.py); unignored, it is one `git add`
63
+ # from a committed credential. `.grok/mcp.json` is the pre-GRPH-575 name.
64
+ .grok/config.toml
65
+ .grok/mcp.json
66
+
67
+ # gbfleet: enrolment code (`.gbfleet-instruction`), fallback seat, doctor/spawn
68
+ # probes, in-tree worktree pool. Salvage already excludes the seat paths from a
69
+ # WIP commit; git status in the parent checkout still offers them to `git add`.
70
+ .gbfleet-*
71
+
72
+ # Swamp vault ciphertext, and a nested copy of the adapter. docs/swamp.md: commit
73
+ # `.swamp.yaml`; do not commit these. The adapter is a sibling on purpose — a
74
+ # nested clone is one add from a tree whose licence forbids carrying Swamp source.
75
+ .swamp/
76
+ graphban-swamp/
77
+
60
78
  # Cursor claim manifest — per-session runtime state (see .cursor/hooks/README.md)
61
79
  .cursor/graphban-claim.json
62
80
  .cursor/agentledger-claim.json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: graphban-cli
3
- Version: 0.4.0
3
+ Version: 0.5.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
@@ -128,11 +128,17 @@ mint a credential for it while you are standing in another repository, and a key
128
128
  project is not a mistake anybody notices quickly.
129
129
 
130
130
  `setup` mints a project-scoped credential, writes the `graphban` and `gbfleet` MCP entries
131
- into every parent harness that would actually read them, installs the supervisor if it is
132
- missing, drops the delegation skill into `.claude/skills/`, and then **verifies** rather than
131
+ into every parent harness that would actually read them, stores that key next to the login
132
+ session (a `gb_sk_…`, never the refresh token), installs the supervisor if it is missing,
133
+ drops the delegation skill into `.claude/skills/`, and then **verifies** rather than
133
134
  asserting: it asks the new credential what it can actually see. Restart the session that will
134
135
  call `delegate`/`spawn` afterwards — MCP servers are read at startup.
135
136
 
137
+ `gban fleet` and `gban doctor`'s local half then use that minted key. They do not feed the
138
+ login session to the supervisor. The order is `$GBFLEET_API_KEY` if you set it, then
139
+ `$GRAPHBAN_API_KEY`, then the key `gban setup` stored — and, for a machine that already ran
140
+ setup, the one already in the harness dest.
141
+
136
142
  Three properties worth knowing, each of which is a bug this command exists to not have:
137
143
 
138
144
  - **The credential does not expire.** `gban keys mint` produces a *wave* key, which lasts a
@@ -158,6 +164,12 @@ Three properties worth knowing, each of which is a bug this command exists to no
158
164
  - **It refuses to write a key into a file git tracks.** `--scope project` on a tracked
159
165
  `.mcp.json` or `.grok/config.toml` is refused rather than warned about, because a warning
160
166
  attached to committing a credential still commits it.
167
+ - **It gitignores the files it and `gbfleet` write into the checkout.** `.mcp.json`,
168
+ `.cursor/mcp.json`, `.grok/config.toml`, `.gbfleet-*`, `.swamp/`, `graphban-swamp/`.
169
+ A warning that said "make sure it is gitignored" still wrote the key; `gban setup`
170
+ now adds the lines, and `gban doctor` FAILs if a credential file would still be
171
+ committed. It asks git (`check-ignore`), so an equivalent pattern already in the
172
+ file is left alone.
161
173
 
162
174
  ## Wiring a checkout to Swamp
163
175
 
@@ -113,11 +113,17 @@ mint a credential for it while you are standing in another repository, and a key
113
113
  project is not a mistake anybody notices quickly.
114
114
 
115
115
  `setup` mints a project-scoped credential, writes the `graphban` and `gbfleet` MCP entries
116
- into every parent harness that would actually read them, installs the supervisor if it is
117
- missing, drops the delegation skill into `.claude/skills/`, and then **verifies** rather than
116
+ into every parent harness that would actually read them, stores that key next to the login
117
+ session (a `gb_sk_…`, never the refresh token), installs the supervisor if it is missing,
118
+ drops the delegation skill into `.claude/skills/`, and then **verifies** rather than
118
119
  asserting: it asks the new credential what it can actually see. Restart the session that will
119
120
  call `delegate`/`spawn` afterwards — MCP servers are read at startup.
120
121
 
122
+ `gban fleet` and `gban doctor`'s local half then use that minted key. They do not feed the
123
+ login session to the supervisor. The order is `$GBFLEET_API_KEY` if you set it, then
124
+ `$GRAPHBAN_API_KEY`, then the key `gban setup` stored — and, for a machine that already ran
125
+ setup, the one already in the harness dest.
126
+
121
127
  Three properties worth knowing, each of which is a bug this command exists to not have:
122
128
 
123
129
  - **The credential does not expire.** `gban keys mint` produces a *wave* key, which lasts a
@@ -143,6 +149,12 @@ Three properties worth knowing, each of which is a bug this command exists to no
143
149
  - **It refuses to write a key into a file git tracks.** `--scope project` on a tracked
144
150
  `.mcp.json` or `.grok/config.toml` is refused rather than warned about, because a warning
145
151
  attached to committing a credential still commits it.
152
+ - **It gitignores the files it and `gbfleet` write into the checkout.** `.mcp.json`,
153
+ `.cursor/mcp.json`, `.grok/config.toml`, `.gbfleet-*`, `.swamp/`, `graphban-swamp/`.
154
+ A warning that said "make sure it is gitignored" still wrote the key; `gban setup`
155
+ now adds the lines, and `gban doctor` FAILs if a credential file would still be
156
+ committed. It asks git (`check-ignore`), so an equivalent pattern already in the
157
+ file is left alone.
146
158
 
147
159
  ## Wiring a checkout to Swamp
148
160
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "graphban-cli"
3
- version = "0.4.0"
3
+ version = "0.5.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"
@@ -278,7 +278,7 @@ def cmd_whoami(args) -> int:
278
278
  def cmd_doctor(args) -> int:
279
279
  url = config.resolve(args.server, config.URL_ENV, "url")
280
280
  project = config.resolve(args.project, config.PROJECT_ENV, "project")
281
- api_key = os.environ.get(config.API_KEY_ENV, "")
281
+ api_key = doctor_mod.resolve_supervisor_key(project)
282
282
  lines, code = doctor_mod.run(url, project, api_key)
283
283
  _out({"lines": lines, "ok": code == 0}, doctor_mod.render(lines), args.as_json)
284
284
  return code
@@ -411,9 +411,11 @@ def cmd_fleet(args) -> int:
411
411
  # message is the one its own tests pin.
412
412
  #
413
413
  # The ENVIRONMENT goes through the same function `doctor` uses (GRPH-782). It used not
414
- # to, so the doctor certified a local half this command could not reproduce.
414
+ # to, so the doctor certified a local half this command could not reproduce. The key
415
+ # is resolved the same way too: env, then the setup-minted agent key, never the login
416
+ # session — a doctor PASS that does not predict `gban fleet` is the original lie.
415
417
  return subprocess.run(argv, env=doctor_mod.child_environment(
416
- os.environ.get(config.API_KEY_ENV, ""))).returncode
418
+ doctor_mod.resolve_supervisor_key(project))).returncode
417
419
 
418
420
 
419
421
  def _project(args, act: str) -> str:
@@ -0,0 +1,212 @@
1
+ """Where `gban` keeps its two facts, and why they are two files (PRD-40 D3, D10).
2
+
3
+ `~/.graphban/` is shared with `graphban`, the operator's database-side tool, and the sharing
4
+ stops at the directory. `gban` reads `gban.json` (`url`, `project`) and `session.json` (a refresh
5
+ token) and **never opens `config.json`**, which is `graphban`'s and may hold a database link.
6
+
7
+ A third file, `supervisor.json`, holds the project-scoped **agent** keys `gban setup` minted
8
+ (`gb_sk_…`). It is not the login session. `gban fleet` / `gban doctor` hand that key to
9
+ `gbfleet`; feeding the refresh token instead is how a working session produces "session
10
+ expired" on the supervisor (GRPH-782).
11
+
12
+ The grill made that stricter than the draft. Reading `config.json` and ignoring the keys it did
13
+ not recognise would have been true and insufficient: the risk is not misreading a database
14
+ password, it is that password living in a file which now has a second consumer and a second
15
+ reason to be copied onto another machine. `graphban` runs in a container against a database;
16
+ `gban` runs on a laptop against HTTP; the credential that must not cross that line lives in its
17
+ own file, so copying a config never carries it.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ import stat
24
+ import subprocess
25
+ from pathlib import Path
26
+
27
+ #: The directory both tools use. Shared deliberately — a person has one Graphban.
28
+ HOME_ENV = "GRAPHBAN_HOME"
29
+
30
+ #: `gban`'s own settings. NOT `config.json`, which belongs to `graphban` (D10).
31
+ SETTINGS_FILE = "gban.json"
32
+
33
+ #: The refresh token, alone in its own file so that copying settings never carries it.
34
+ SESSION_FILE = "session.json"
35
+
36
+ #: Project-scoped agent keys `gban setup` minted. Next to the session, never inside it —
37
+ #: a refresh token and a `gb_sk_…` are different credentials, and mixing them is the
38
+ #: remaining hole GRPH-782 was reopened for.
39
+ SUPERVISOR_KEYS_FILE = "supervisor.json"
40
+
41
+ #: `graphban`'s file. Named here only so the test that asserts `gban` never opens it has
42
+ #: something to name, and so a reader knows the omission is deliberate.
43
+ NOT_OURS = "config.json"
44
+
45
+ #: What a minted agent key looks like. `gbfleet` wants this; a user refresh token is not it.
46
+ AGENT_KEY_PREFIX = "gb_sk_"
47
+
48
+ URL_ENV = "GRAPHBAN_URL"
49
+ PROJECT_ENV = "GRAPHBAN_PROJECT"
50
+ API_KEY_ENV = "GRAPHBAN_API_KEY"
51
+
52
+ #: Owner read/write and nothing else. A credential at rest gets the same mode the seat files
53
+ #: in `gbfleet` get, for the same reason.
54
+ PRIVATE = stat.S_IRUSR | stat.S_IWUSR
55
+
56
+
57
+ def home() -> Path:
58
+ return Path(os.environ.get(HOME_ENV) or (Path.home() / ".graphban"))
59
+
60
+
61
+ def _read(path: Path) -> dict:
62
+ try:
63
+ loaded = json.loads(path.read_text(encoding="utf-8"))
64
+ except (OSError, ValueError):
65
+ # A missing file and an unreadable one are the same to a caller that has a default,
66
+ # and neither is worth a traceback in front of somebody trying to log in.
67
+ return {}
68
+ return loaded if isinstance(loaded, dict) else {}
69
+
70
+
71
+ def _write(path: Path, payload: dict) -> Path:
72
+ path.parent.mkdir(parents=True, exist_ok=True)
73
+ # Created private BEFORE anything is written to it. Writing first and chmod-ing after
74
+ # leaves a window where the token is world-readable, which is the whole failure.
75
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, PRIVATE)
76
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
77
+ json.dump(payload, fh, indent=1, sort_keys=True)
78
+ fh.write("\n")
79
+ _make_private(path)
80
+ return path
81
+
82
+
83
+ def _make_private(path: Path) -> None:
84
+ """Owner-only, including on Windows where `chmod 0600` is a no-op (GRPH-855)."""
85
+ os.chmod(path, PRIVATE)
86
+ if os.name != "nt":
87
+ return
88
+ user = os.environ.get("USERNAME") or ""
89
+ if not user:
90
+ return
91
+ subprocess.run(
92
+ ["icacls", str(path), "/inheritance:r", "/grant:r", f"{user}:(F)"],
93
+ capture_output=True, text=True, timeout=30,
94
+ )
95
+ listing = subprocess.run(
96
+ ["icacls", str(path)], capture_output=True, text=True, timeout=30,
97
+ )
98
+ if listing.returncode != 0:
99
+ return
100
+ me = user.casefold()
101
+ prefix = str(path)
102
+ for line in listing.stdout.splitlines():
103
+ if ":(" not in line:
104
+ continue
105
+ principal = line.split(":(")[0].strip()
106
+ if principal.lower().startswith(prefix.lower()):
107
+ principal = principal[len(prefix):].strip()
108
+ if not principal:
109
+ continue
110
+ if principal.rsplit("\\", 1)[-1].casefold() == me:
111
+ continue
112
+ subprocess.run(
113
+ ["icacls", str(path), "/remove", principal],
114
+ capture_output=True, text=True, timeout=30,
115
+ )
116
+
117
+
118
+ def is_private(path: Path) -> bool:
119
+ """Whether `path` is owner-only. POSIX is the mode bits; Windows is the ACL.
120
+
121
+ Asserting `st_mode == 0o600` goes red on NTFS for a reason that has nothing to do
122
+ with whether anyone else can read the file (GRPH-855).
123
+ """
124
+ path = Path(path)
125
+ if os.name != "nt":
126
+ try:
127
+ return stat.S_IMODE(path.stat().st_mode) == PRIVATE
128
+ except OSError:
129
+ return False
130
+ user = (os.environ.get("USERNAME") or "").casefold()
131
+ listing = subprocess.run(
132
+ ["icacls", str(path)], capture_output=True, text=True, timeout=30,
133
+ )
134
+ if listing.returncode != 0:
135
+ return False
136
+ prefix = str(path)
137
+ saw_owner = False
138
+ for line in listing.stdout.splitlines():
139
+ if ":(" not in line:
140
+ continue
141
+ principal = line.split(":(")[0].strip()
142
+ if principal.lower().startswith(prefix.lower()):
143
+ principal = principal[len(prefix):].strip()
144
+ if not principal:
145
+ continue
146
+ account = principal.rsplit("\\", 1)[-1].casefold()
147
+ if account == user:
148
+ saw_owner = True
149
+ continue
150
+ return False
151
+ return saw_owner
152
+
153
+
154
+ def settings() -> dict:
155
+ return _read(home() / SETTINGS_FILE)
156
+
157
+
158
+ def save_settings(**values: str) -> Path:
159
+ merged = {k: v for k, v in {**settings(), **values}.items() if v}
160
+ return _write(home() / SETTINGS_FILE, merged)
161
+
162
+
163
+ def session() -> dict:
164
+ return _read(home() / SESSION_FILE)
165
+
166
+
167
+ def save_session(refresh_token: str, *, user: str = "") -> Path:
168
+ return _write(home() / SESSION_FILE,
169
+ {"refresh_token": refresh_token, "user": user})
170
+
171
+
172
+ def clear_session() -> bool:
173
+ path = home() / SESSION_FILE
174
+ try:
175
+ path.unlink()
176
+ return True
177
+ except FileNotFoundError:
178
+ return False
179
+
180
+
181
+ def _agent_key(value: str) -> str:
182
+ value = (value or "").strip()
183
+ return value if value.startswith(AGENT_KEY_PREFIX) else ""
184
+
185
+
186
+ def save_supervisor_key(project: str, key: str) -> Path | None:
187
+ """Store the project-scoped agent key `gban setup` minted. Refuses a refresh token."""
188
+ key = _agent_key(key)
189
+ if not project or not key:
190
+ return None
191
+ blob = _read(home() / SUPERVISOR_KEYS_FILE)
192
+ keys = blob.get("keys") if isinstance(blob.get("keys"), dict) else {}
193
+ keys[project] = key
194
+ return _write(home() / SUPERVISOR_KEYS_FILE, {"keys": keys})
195
+
196
+
197
+ def stored_supervisor_key(project: str) -> str:
198
+ """The agent key setup stored for this project, or empty. Never reads `session.json`."""
199
+ if not project:
200
+ return ""
201
+ blob = _read(home() / SUPERVISOR_KEYS_FILE)
202
+ keys = blob.get("keys") if isinstance(blob.get("keys"), dict) else {}
203
+ return _agent_key(str(keys.get(project) or ""))
204
+
205
+
206
+ def resolve(flag: str | None, env: str, key: str) -> str:
207
+ """D10's precedence, in one place: flag, then environment, then the settings file.
208
+
209
+ One function rather than three lookups at each call site, because a precedence that is
210
+ re-implemented per option is one that will eventually differ per option.
211
+ """
212
+ return (flag or os.environ.get(env) or settings().get(key) or "").strip()
@@ -16,7 +16,7 @@ import shutil
16
16
  import subprocess
17
17
  from pathlib import Path
18
18
 
19
- from gban import config
19
+ from gban import config, gitignore
20
20
  from gban.client import Client, NoSession, Refused, Unreachable, authenticated
21
21
 
22
22
  PASS, FAIL, UNKNOWN = "PASS", "FAIL", "UNKNOWN"
@@ -200,6 +200,31 @@ def install_supervisor(timeout: float = 600.0) -> tuple[bool, str]:
200
200
  return True, ""
201
201
 
202
202
 
203
+ def _sibling_binary(name: str) -> str:
204
+ """An executable beside this interpreter, trying the platform suffix that actually
205
+ exists. On Windows a `pip install` of both packages drops `gbfleet.exe` into
206
+ `Scripts\\`; on Unix it drops `gbfleet` into `bin/`. Checking the bare name on Windows
207
+ is the same hole the Unix sibling check used to have — the file is there and the lookup
208
+ says it is not.
209
+
210
+ `os.access(X_OK)` is true of almost every file on Windows, so a leftover extensionless
211
+ `gbfleet` (chmod 0644 even) would win over `gbfleet.exe` if we asked for the bare name
212
+ first (GRPH-855).
213
+ """
214
+ import sys
215
+
216
+ parent = Path(sys.executable).parent
217
+ bare, exe = parent / name, parent / f"{name}.exe"
218
+ if os.name == "nt":
219
+ # X_OK is true of almost every file here, so a leftover extensionless
220
+ # `gbfleet` would win over `gbfleet.exe` (GRPH-855).
221
+ return str(exe) if exe.is_file() else ""
222
+ for candidate in (bare, exe):
223
+ if candidate.is_file() and os.access(candidate, os.X_OK):
224
+ return str(candidate)
225
+ return ""
226
+
227
+
203
228
  def find_supervisor() -> str:
204
229
  """`gbfleet`, from beside this interpreter first and only then from PATH.
205
230
 
@@ -213,13 +238,10 @@ def find_supervisor() -> str:
213
238
  PATH still wins for a supervisor the operator installed separately and put there on
214
239
  purpose — a sibling is a fallback for the case PATH cannot see, not an override of it.
215
240
  """
216
- import sys
217
-
218
241
  found = shutil.which("gbfleet")
219
242
  if found:
220
243
  return found
221
- beside = Path(sys.executable).parent / "gbfleet"
222
- return str(beside) if beside.exists() and os.access(beside, os.X_OK) else ""
244
+ return _sibling_binary("gbfleet")
223
245
 
224
246
 
225
247
  #: What `gbfleet` reads. `gban` names it once, here, and both paths that launch a supervisor
@@ -227,6 +249,45 @@ def find_supervisor() -> str:
227
249
  SUPERVISOR_KEY_ENV = "GBFLEET_API_KEY"
228
250
 
229
251
 
252
+ def resolve_supervisor_key(project: str, repo: Path | None = None) -> str:
253
+ """The agent key we hand `child_environment` as `api_key`.
254
+
255
+ Order (GRPH-782 remaining hole): `$GRAPHBAN_API_KEY`, then the project-scoped key
256
+ `gban setup` stored next to `session.json`, then the key setup already wrote into a
257
+ harness dest for this repository. `$GBFLEET_API_KEY` the caller set is preserved
258
+ inside `child_environment`, so it is not resolved here.
259
+
260
+ Never the login session. A refresh token is a different kind of credential.
261
+ """
262
+ env_key = (os.environ.get(config.API_KEY_ENV) or "").strip()
263
+ if env_key:
264
+ return env_key
265
+ stored = config.stored_supervisor_key(project)
266
+ if stored:
267
+ return stored
268
+ return _harness_supervisor_key(repo)
269
+
270
+
271
+ def _harness_supervisor_key(repo: Path | None) -> str:
272
+ """The key `gban setup` already wrote into a parent-harness dest.
273
+
274
+ Lazy-imports setup: that module imports this one, and a top-level cycle would
275
+ make `gban doctor` fail to import on a machine that has never run setup.
276
+ """
277
+ from gban import setup as setup_mod
278
+
279
+ repo = (repo or Path.cwd()).resolve()
280
+ seen: list[str] = []
281
+ for scope in ("user", "project"):
282
+ dests, _ = setup_mod.destinations(
283
+ repo, scope, setup_mod.claude_home(), setup_mod.grok_home())
284
+ for dest in dests:
285
+ key = (setup_mod.dest_key(dest, repo) or "").strip()
286
+ if key.startswith(config.AGENT_KEY_PREFIX) and key not in seen:
287
+ seen.append(key)
288
+ return seen[0] if seen else ""
289
+
290
+
230
291
  def child_environment(api_key: str) -> dict:
231
292
  """The environment a `gbfleet` child is launched with, for `doctor` AND for `fleet`.
232
293
 
@@ -251,8 +312,10 @@ def child_environment(api_key: str) -> dict:
251
312
  return env
252
313
 
253
314
 
254
- def run(url: str, project: str, api_key: str = "") -> tuple[list[dict], int]:
255
- lines = ledger(url, project) + local(url, project, api_key)
315
+ def run(url: str, project: str, api_key: str = "",
316
+ repo: Path | None = None) -> tuple[list[dict], int]:
317
+ lines = (ledger(url, project) + local(url, project, api_key)
318
+ + gitignore.check(repo or Path.cwd()))
256
319
  worst = max((SEVERITY[l["status"]] for l in lines), default=0)
257
320
  # FAIL exits 1, UNKNOWN exits 0: "could not check" is not "broken", and a doctor that
258
321
  # failed a script because a laptop lacked `gbfleet` would stop being run.
@@ -0,0 +1,176 @@
1
+ """Keep gban/gbfleet credentials out of `git add`.
2
+
3
+ The files this tool writes into a checkout — `.mcp.json`, Grok's project config, a
4
+ Cursor seat, a gbfleet enrolment instruction, a Swamp vault — are live keys. A warning
5
+ attached to writing them still writes them, and `git add .` does not read warnings.
6
+ So setup *writes the ignore lines*, and doctor *fails* if a file that exists would
7
+ still be committed. Asking git (`check-ignore`) rather than grepping `.gitignore`
8
+ is the load-bearing bit: an equivalent pattern (`.cursor/`, a user exclude file)
9
+ already does the job, and a string match would either duplicate it or miss it.
10
+
11
+ A path git already tracks is a different refusal. gitignore does not untrack, and
12
+ pretending it does is how a key that is already in the index keeps shipping.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import subprocess
17
+ from pathlib import Path
18
+
19
+ # Same three states as `gban.doctor`, inlined so this module cannot import doctor
20
+ # (doctor calls `check`, and a cycle here would make `gban doctor` fail to import).
21
+ PASS, FAIL, UNKNOWN = "PASS", "FAIL", "UNKNOWN"
22
+
23
+
24
+ def _line(side: str, status: str, name: str, detail: str = "") -> dict:
25
+ return {"side": side, "status": status, "name": name, "detail": detail, "report": ""}
26
+
27
+
28
+ #: What we write. Concrete files gban and gbfleet actually emit, plus the two swamp
29
+ #: paths `docs/swamp.md` names. `.gbfleet-*` covers instruction, seat, probes and an
30
+ #: in-tree worktree pool without enumerating each one as it appears.
31
+ PATTERNS = (
32
+ ".mcp.json",
33
+ ".cursor/mcp.json",
34
+ ".grok/config.toml",
35
+ ".grok/mcp.json",
36
+ ".gbfleet-*",
37
+ ".swamp/",
38
+ "graphban-swamp/",
39
+ )
40
+
41
+ #: What we ASK git about. A glob is not a path; these are the files that would be
42
+ #: committed. Directory patterns keep the trailing slash: `check-ignore .swamp`
43
+ #: does not honour `.swamp/`, but `check-ignore .swamp/` does. `.gbfleet-instruction`
44
+ #: is the representative of `.gbfleet-*` because it carries a live enrolment code.
45
+ PROBES = (
46
+ ".mcp.json",
47
+ ".cursor/mcp.json",
48
+ ".grok/config.toml",
49
+ ".grok/mcp.json",
50
+ ".gbfleet-instruction",
51
+ ".swamp/",
52
+ "graphban-swamp/",
53
+ )
54
+
55
+ HEADER = "# Graphban — live keys and seats. Maintained by `gban setup`."
56
+
57
+
58
+ def is_repo(path: Path) -> bool:
59
+ """`.git` is a directory in a clone and a file in a worktree."""
60
+ return (path / ".git").exists()
61
+
62
+
63
+ def _git(repo: Path, *args: str) -> subprocess.CompletedProcess:
64
+ return subprocess.run(["git", "-C", str(repo), *args],
65
+ capture_output=True, text=True, timeout=10)
66
+
67
+
68
+ def usable(repo: Path) -> bool:
69
+ """A `.git` marker is not a repository git will answer. Tests (and `--auto`)
70
+ plant an empty `.git` directory as the marker; writing a gitignore that git
71
+ then cannot honour would FAIL every one of them."""
72
+ if not is_repo(repo):
73
+ return False
74
+ try:
75
+ done = _git(repo, "rev-parse", "--is-inside-work-tree")
76
+ except (OSError, subprocess.SubprocessError):
77
+ return False
78
+ return done.returncode == 0 and done.stdout.strip() == "true"
79
+
80
+
81
+ def tracked(repo: Path, rel: str) -> bool:
82
+ try:
83
+ done = _git(repo, "ls-files", "--error-unmatch", "--", rel)
84
+ except (OSError, subprocess.SubprocessError):
85
+ return False
86
+ return done.returncode == 0
87
+
88
+
89
+ def ignored(repo: Path, rel: str) -> bool:
90
+ """Would `git add` skip this path? Tracked files are not ignored, even when a
91
+ pattern matches — that is git's rule, and it is why a tracked credential is a
92
+ FAIL rather than a write-to-gitignore."""
93
+ try:
94
+ done = _git(repo, "check-ignore", "-q", "--", rel)
95
+ except (OSError, subprocess.SubprocessError):
96
+ return False
97
+ return done.returncode == 0
98
+
99
+
100
+ def _probe_for(pattern: str) -> str:
101
+ if pattern == ".gbfleet-*":
102
+ return ".gbfleet-instruction"
103
+ return pattern
104
+
105
+
106
+ def missing(repo: Path) -> list[str]:
107
+ """Patterns that would not actually ignore their probe path."""
108
+ return [p for p in PATTERNS if not ignored(repo, _probe_for(p))]
109
+
110
+
111
+ def leaking(repo: Path) -> list[str]:
112
+ """Probes that exist on disk or are tracked, and would still be committed."""
113
+ out = []
114
+ for rel in PROBES:
115
+ disk = rel.rstrip("/")
116
+ if tracked(repo, disk) or (repo / disk).exists():
117
+ if not ignored(repo, rel):
118
+ out.append(disk)
119
+ return out
120
+
121
+
122
+ def ensure(repo: Path) -> list[dict]:
123
+ """Append any missing patterns to `.gitignore`. Idempotent.
124
+
125
+ Not a git repository: nothing to write (the caller already reports that). A
126
+ pattern git already honours — however it is spelled — is left alone.
127
+ """
128
+ if not usable(repo):
129
+ return []
130
+ needed = missing(repo)
131
+ if not needed:
132
+ return [_line("config", PASS, "gitignore",
133
+ "credential paths are gitignored")]
134
+ path = repo / ".gitignore"
135
+ try:
136
+ existing = path.read_text(encoding="utf-8") if path.exists() else ""
137
+ block = "\n".join([HEADER, *needed]) + "\n"
138
+ text = block if not existing else existing.rstrip("\n") + "\n\n" + block
139
+ path.write_text(text, encoding="utf-8")
140
+ except OSError as exc:
141
+ return [_line("config", FAIL, "gitignore",
142
+ f"could not write {path}: {exc}. Refusing to leave a key "
143
+ "unignored — add the lines by hand, or use --scope user")]
144
+ still = missing(repo)
145
+ if still:
146
+ return [_line("config", FAIL, "gitignore",
147
+ f"wrote {path} but git still would not ignore "
148
+ f"{', '.join(still)}")]
149
+ return [_line("config", PASS, "gitignore",
150
+ f"added to {path}: {', '.join(needed)}")]
151
+
152
+
153
+ def check(repo: Path) -> list[dict]:
154
+ """Doctor: fail if a credential file would be committed, not if the patterns
155
+ are merely absent. Absence of the files is not a clean result either — that
156
+ prints UNKNOWN, because 'nobody has written a key here yet' is not 'safe'.
157
+ """
158
+ if not usable(repo):
159
+ return []
160
+ live = leaking(repo)
161
+ if live:
162
+ tracked_ones = [p for p in live if tracked(repo, p)]
163
+ if tracked_ones:
164
+ return [_line("local", FAIL, "gitignore",
165
+ f"git tracks {', '.join(tracked_ones)} — gitignore will "
166
+ "not stop a commit. `git rm --cached` them, or use "
167
+ "--scope user")]
168
+ return [_line("local", FAIL, "gitignore",
169
+ f"{', '.join(live)} is not gitignored and would be committed. "
170
+ "`gban setup` adds the lines")]
171
+ uncovered = [p for p in PROBES if not ignored(repo, p)]
172
+ if uncovered:
173
+ return [_line("local", UNKNOWN, "gitignore",
174
+ "credential paths are not gitignored yet; `gban setup` adds "
175
+ "the lines so a later spawn cannot commit them")]
176
+ return [_line("local", PASS, "gitignore", "credential paths are gitignored")]