agentgov-cli 0.1.16__tar.gz → 0.1.18__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 (28) hide show
  1. {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/PKG-INFO +1 -1
  2. {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/client_assets/hooks/pretooluse_pathguard.py +55 -2
  3. {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/client_assets/hooks/userpromptsubmit_workitem.py +117 -13
  4. {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/main.py +39 -0
  5. {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/pyproject.toml +1 -1
  6. {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/.gitignore +0 -0
  7. {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/README.md +0 -0
  8. {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/__init__.py +0 -0
  9. {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/asset_manifest.py +0 -0
  10. {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/claude_env.py +0 -0
  11. {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/client_assets/commands/workitem.md +0 -0
  12. {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/client_assets/hooks/sessionstart_register.py +0 -0
  13. {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/client_assets/managed-settings.json.tmpl +0 -0
  14. {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/client_assets/statusline/agentgov_statusline.sh +0 -0
  15. {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/commands/__init__.py +0 -0
  16. {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/commands/doctor.py +0 -0
  17. {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/commands/gateway.py +0 -0
  18. {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/commands/install.py +0 -0
  19. {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/commands/login.py +0 -0
  20. {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/commands/register_device.py +0 -0
  21. {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/commands/status.py +0 -0
  22. {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/commands/uninstall.py +0 -0
  23. {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/commands/workitem.py +0 -0
  24. {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/commands/wrap.py +0 -0
  25. {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/gateway_process.py +0 -0
  26. {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/loopback.py +0 -0
  27. {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/port_resolver.py +0 -0
  28. {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/hatch_build.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentgov-cli
3
- Version: 0.1.16
3
+ Version: 0.1.18
4
4
  Summary: AgentGov CLI — wrap Claude Code, bind work items, check gateway health.
5
5
  Author: AgentGov Contributors
6
6
  License: Apache-2.0
@@ -38,12 +38,65 @@ from collections.abc import Iterator
38
38
  from datetime import datetime, timezone
39
39
  from pathlib import Path
40
40
 
41
+ def _sane_repo_root(value: str) -> str:
42
+ """Accept an AGENTGOV_REPO_ROOT only if it could really be a repository.
43
+
44
+ THE HOLE THIS CLOSES. The env var was trusted absolutely, so
45
+ `AGENTGOV_REPO_ROOT=/` made every path on the machine "inside the authorized
46
+ root" and the tool plane allowed everything — including /etc/shadow — while
47
+ every other check still reported the machine governed. One exported variable,
48
+ no error, no log line, governance gone. That inverts the prime directive in
49
+ CLAUDE.md §1 exactly as a deleted hook did (§13 gap #31): bypassing produced
50
+ UNGOVERNED access rather than none.
51
+
52
+ THE RULE: a root may not be your home directory, nor anything containing it.
53
+
54
+ That is deliberately narrow. It rejects every value that actually disables
55
+ the guard — `/`, `/home`, `/Users`, `$HOME` itself — because authorising any
56
+ of those puts ~/.ssh, ~/.claude and every other repository inside the
57
+ "authorized" tree at once. It does NOT require the root to be a git work
58
+ tree, which was the first attempt and was wrong: the hook is also used
59
+ against scratch directories and test fixtures that are legitimately not
60
+ repositories, and a check that strict turns a governance control into a
61
+ reason to stop using it.
62
+
63
+ An unusable value is DISCARDED, not fatal. Returning "" falls through to the
64
+ normal resolution — CLAUDE_PROJECT_DIR, then cwd — which still fails closed
65
+ when nothing is found. Refusing outright would let anyone disable a
66
+ developer's editor by exporting one bad variable, trading a permissive bug
67
+ for a denial-of-service one.
68
+ """
69
+ root = (value or "").strip()
70
+ if not root:
71
+ return ""
72
+ try:
73
+ p = Path(root).expanduser().resolve()
74
+ except (OSError, RuntimeError):
75
+ return ""
76
+ if not p.is_dir():
77
+ return ""
78
+ # True only at a filesystem root, on POSIX and Windows alike, without
79
+ # hardcoding "/" or a drive-letter pattern.
80
+ if p.parent == p:
81
+ return ""
82
+ try:
83
+ home = Path.home().resolve()
84
+ except (OSError, RuntimeError):
85
+ return str(p)
86
+ # `p == home` or `home` beneath `p`: both authorise the whole home
87
+ # directory, and with it every credential and every other checkout.
88
+ if p == home or p in home.parents:
89
+ return ""
90
+ return str(p)
91
+
92
+
41
93
  # The authorized repository root. Resolved in main() from the hook payload,
42
94
  # because this hook now runs in ANY Claude Code session — including the
43
95
  # VS Code extension, where no `agentgov wrap` process exists to export
44
96
  # AGENTGOV_REPO_ROOT. The env var is still honoured first so wrapper-launched
45
- # sessions and the test-suite behave exactly as before.
46
- REPO_ROOT = os.environ.get("AGENTGOV_REPO_ROOT", "")
97
+ # sessions and the test-suite behave exactly as before — but only after
98
+ # `_sane_repo_root` has confirmed it names something that could be a repository.
99
+ REPO_ROOT = _sane_repo_root(os.environ.get("AGENTGOV_REPO_ROOT", ""))
47
100
 
48
101
  # The directory Claude is working in, from the payload's `cwd`. Used as the
49
102
  # base for relative paths. Previously this was the HOOK process's own cwd,
@@ -209,6 +209,78 @@ def parse_marked_ref(prompt: str) -> tuple[str, str]:
209
209
  return ref, remainder
210
210
 
211
211
 
212
+ # Public, no sign-in required. Deliberately NOT the dashboard's /setup page: a
213
+ # developer who cannot bind often cannot sign in either — their account may not
214
+ # exist yet, or the blocker IS the account — so troubleshooting behind a login is
215
+ # unreachable exactly when it is needed. Overridable for self-hosted deployments.
216
+ HELP_URL = (os.environ.get("AGENTGOV_HELP_URL") or "https://www.aicodekeyshare.com/help").rstrip("/")
217
+
218
+ # What each failure code means to a PERSON, and the single next action that
219
+ # resolves it. Written as an instruction, not a diagnosis: "ask your admin to
220
+ # grant this repository" is actionable, "REPO_NOT_REGISTERED" is not.
221
+ _BIND_FAILURE_HELP: dict[str, tuple[str, str]] = {
222
+ "repo_not_registered": (
223
+ "This repository has not been granted to you.",
224
+ "Ask an admin to add it on the Grants page. Until then Claude Code cannot "
225
+ "run here — this is the repo lock working, not a fault.",
226
+ ),
227
+ "grant_expired": (
228
+ "Your access to this repository has expired.",
229
+ "Ask an admin to renew the grant on the Grants page.",
230
+ ),
231
+ "user_inactive": (
232
+ "Your account is not active.",
233
+ "Ask an admin to reactivate it on the Team page.",
234
+ ),
235
+ "session_not_registered": (
236
+ "The control tower has no record of this editor session.",
237
+ "Usually a dropped connection. Send the prompt once more — the hook "
238
+ "re-registers automatically. If it repeats, the tower is unreachable from "
239
+ "this machine.",
240
+ ),
241
+ "work_item_invalid": (
242
+ "That work item could not be validated with your tracker.",
243
+ "Check the id, or use a short label instead — any text in brackets works.",
244
+ ),
245
+ }
246
+
247
+
248
+ def bind_failure_message(ref: str, repo: str, code: str) -> str:
249
+ """Explain why a work item that WAS supplied could not be bound.
250
+
251
+ The message this replaces rendered the generic marker guidance, which asks
252
+ the reader to supply a work item — the thing they had just supplied. It named
253
+ no cause, so there was nothing to act on, and offered no exit, so retyping the
254
+ same marker was the only move. A customer reported it as "a settings checking
255
+ screen which is not taking to right settings to fix", which is an accurate
256
+ description of a loop.
257
+
258
+ Every branch here ends with something the developer can DO, and a link to a
259
+ page they can open without signing in.
260
+ """
261
+ what, action = _BIND_FAILURE_HELP.get(
262
+ code.lower(),
263
+ (
264
+ "The work item could not be bound.",
265
+ "Send the prompt once more. If it repeats, run `agentgov doctor` — it "
266
+ "checks each step in order and names the first one that is failing.",
267
+ ),
268
+ )
269
+ anchor = code.lower().replace("_", "-") if code.lower() in _BIND_FAILURE_HELP else "binding"
270
+ return (
271
+ "\U0001f4cc AgentGov: could not bind to '" + ref + "'.\n"
272
+ " Repository: " + repo + "\n"
273
+ "\n"
274
+ " " + what + "\n"
275
+ " " + action + "\n"
276
+ "\n"
277
+ " Full troubleshooting (no sign-in needed):\n"
278
+ " " + HELP_URL + "#" + anchor + "\n"
279
+ "\n"
280
+ " Your prompt was not sent and no tokens were used.\n"
281
+ )
282
+
283
+
212
284
  def marker_guidance(repo: str = "", reason: str = "") -> str:
213
285
  """The one message that teaches the marker form.
214
286
 
@@ -439,7 +511,7 @@ def _bind_attempt(session_id: str, ref: str) -> tuple[int, dict]:
439
511
  return 0, {}
440
512
 
441
513
 
442
- def _bind_root(session_id: str, ref: str, cwd: str) -> bool:
514
+ def _bind_root(session_id: str, ref: str, cwd: str) -> tuple[bool, str]:
443
515
  """Bind the session to `ref` as its ROOT work item.
444
516
 
445
517
  A bind with NO parent is the developer naming their work, so the gateway
@@ -459,10 +531,18 @@ def _bind_root(session_id: str, ref: str, cwd: str) -> bool:
459
531
  whatever state it was already in: still bound to the previous item, or still
460
532
  unbound and therefore answered with the guidance message. Neither loses the
461
533
  prompt.
534
+
535
+ RETURNS (ok, reason). The reason used to be computed here and thrown away on
536
+ every failure path, so a developer who DID name a work item was answered with
537
+ "start your prompt with a work item in square brackets" — the one thing they
538
+ had just done. That message is a dead end: it asks for what was already
539
+ supplied, names no cause, and offers no way out, so the only remaining move is
540
+ to type the same thing again. Carrying the reason out is what lets the caller
541
+ say which of the several possible failures actually happened.
462
542
  """
463
543
  status, payload = _bind_attempt(session_id, ref)
464
544
  if 200 <= status < 300:
465
- return True
545
+ return True, ""
466
546
 
467
547
  code = ""
468
548
  err = payload.get("error")
@@ -477,11 +557,19 @@ def _bind_root(session_id: str, ref: str, cwd: str) -> bool:
477
557
  # key needs. Only retry when that actually succeeded — a second bind
478
558
  # against a still-missing session would fail identically.
479
559
  if not _register(session_id, cwd):
480
- return False
481
- status, _ = _bind_attempt(session_id, ref)
482
- return 200 <= status < 300
560
+ return False, "session_not_registered"
561
+ status, retry_payload = _bind_attempt(session_id, ref)
562
+ if 200 <= status < 300:
563
+ return True, ""
564
+ retry_err = retry_payload.get("error")
565
+ retry_code = ""
566
+ if isinstance(retry_err, dict):
567
+ retry_code = str(retry_err.get("code") or "")
568
+ elif isinstance(retry_err, str):
569
+ retry_code = retry_err
570
+ return False, retry_code or f"http_{status}"
483
571
 
484
- return False
572
+ return False, code or f"http_{status}"
485
573
 
486
574
 
487
575
  def _auto_child(
@@ -1451,6 +1539,11 @@ def main() -> None:
1451
1539
  # below sees the session the developer just named rather than the one they
1452
1540
  # happened to be on. This is what removes the second round trip: naming the
1453
1541
  # work and doing the work are one message.
1542
+ # Why a bind that was attempted did not land. Empty means either no
1543
+ # marker was given or the bind succeeded — the final gate tells those two
1544
+ # apart by whether `ref` is set.
1545
+ bind_failure = ""
1546
+
1454
1547
  if ref:
1455
1548
  # Bind would 409 on a session with no upstream row — `work_item_bindings`
1456
1549
  # has a foreign key onto it. Registering first turns a gateway restart
@@ -1468,13 +1561,18 @@ def main() -> None:
1468
1561
  # drift counters, so re-binding the same ref on every prompt would zero
1469
1562
  # `prompts_since_bind` every turn and no drift signal could accumulate.
1470
1563
  current_root = str(data.get("root_work_item_ref") or data.get("work_item_ref") or "")
1471
- if (
1472
- repaired
1473
- and current_root.strip().casefold() != ref.casefold()
1474
- and _bind_root(session_id, ref, cwd)
1475
- and (refreshed := _binding(session_id)) is not None
1476
- ):
1477
- data = refreshed
1564
+ if not repaired:
1565
+ # The session could not be registered upstream, so a bind cannot
1566
+ # land. Named rather than folded into the generic guidance, because
1567
+ # "we could not reach the control tower" and "you did not name a work
1568
+ # item" call for completely different actions from the developer.
1569
+ bind_failure = "session_not_registered"
1570
+ elif current_root.strip().casefold() != ref.casefold():
1571
+ ok, bind_failure = _bind_root(session_id, ref, cwd)
1572
+ if ok and (refreshed := _binding(session_id)) is not None:
1573
+ data = refreshed
1574
+ # An unchanged marker is not a failure — the session is already bound to
1575
+ # it, and re-binding would reset the drift counters for nothing.
1478
1576
 
1479
1577
  if data.get("bound"):
1480
1578
  # The ROOT is the item the developer named with `/workitem`. Once
@@ -1568,6 +1666,12 @@ def main() -> None:
1568
1666
  # No gateway downstream: this hook is the only gate, so it blocks and says
1569
1667
  # so on every channel at once.
1570
1668
  repo = data.get("repo") or "this repository"
1669
+ if ref and bind_failure:
1670
+ # THE FIX. Reaching here with a marker in hand used to render the generic
1671
+ # "start your prompt with a work item" text — telling a developer to do
1672
+ # the thing they had just done, with no cause and no exit. Repeating it
1673
+ # was the only move left, which is exactly the loop a customer reported.
1674
+ _block(bind_failure_message(ref, repo, bind_failure))
1571
1675
  _block(
1572
1676
  marker_guidance(repo=repo)
1573
1677
  + "\n Your prompt was not sent and no tokens were used.\n"
@@ -21,6 +21,45 @@ app = typer.Typer(
21
21
  help="AgentGov CLI. See CLAUDE.md §6.3 + §13/§14.",
22
22
  no_args_is_help=True,
23
23
  )
24
+
25
+
26
+ def _print_version(value: bool) -> None:
27
+ """`agentgov --version`.
28
+
29
+ The Setup page has told every developer to run this since it was written, and
30
+ until now it errored with "No such option: --version" — so the FIRST command
31
+ in the documented install path failed, for every client, on every machine.
32
+
33
+ It matters beyond tidiness. Half the diagnoses in this product turn on which
34
+ version is installed: `doctor`'s hook-version check compares assets against
35
+ `cli_version()`, and a stale CLI is the usual reason hooks are out of date.
36
+ Asking someone to report their version and handing them a command that does
37
+ not exist is the worst possible first impression of a governance tool.
38
+
39
+ Reads the installed distribution metadata rather than a hardcoded string, so
40
+ it cannot drift from what pip actually installed — the same source `doctor`
41
+ already compares against, which is what makes the two agree.
42
+ """
43
+ if not value:
44
+ return
45
+ from .asset_manifest import cli_version
46
+
47
+ typer.echo(f"agentgov {cli_version()}")
48
+ raise typer.Exit()
49
+
50
+
51
+ @app.callback()
52
+ def _root(
53
+ _version: bool = typer.Option(
54
+ False,
55
+ "--version",
56
+ "-V",
57
+ callback=_print_version,
58
+ is_eager=True,
59
+ help="Show the installed CLI version and exit.",
60
+ ),
61
+ ) -> None:
62
+ """Root options. Commands are registered below."""
24
63
  app.command("login", help="Attach this machine to a user in the control tower (§13.5).")(login.run)
25
64
  app.command(
26
65
  "register-device",
@@ -2,7 +2,7 @@
2
2
  # PyPI: `agentgov` was rejected as too similar to existing `agent-gov`.
3
3
  # Package name is agentgov-cli; the console command it installs is still `agentgov`.
4
4
  name = "agentgov-cli"
5
- version = "0.1.16"
5
+ version = "0.1.18"
6
6
  description = "AgentGov CLI — wrap Claude Code, bind work items, check gateway health."
7
7
  readme = "README.md"
8
8
  license = { text = "Apache-2.0" }
File without changes
File without changes