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.
- {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/PKG-INFO +1 -1
- {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/client_assets/hooks/pretooluse_pathguard.py +55 -2
- {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/client_assets/hooks/userpromptsubmit_workitem.py +117 -13
- {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/main.py +39 -0
- {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/pyproject.toml +1 -1
- {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/.gitignore +0 -0
- {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/README.md +0 -0
- {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/__init__.py +0 -0
- {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/asset_manifest.py +0 -0
- {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/claude_env.py +0 -0
- {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/client_assets/commands/workitem.md +0 -0
- {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/client_assets/hooks/sessionstart_register.py +0 -0
- {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/client_assets/managed-settings.json.tmpl +0 -0
- {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/client_assets/statusline/agentgov_statusline.sh +0 -0
- {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/commands/__init__.py +0 -0
- {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/commands/doctor.py +0 -0
- {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/commands/gateway.py +0 -0
- {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/commands/install.py +0 -0
- {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/commands/login.py +0 -0
- {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/commands/register_device.py +0 -0
- {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/commands/status.py +0 -0
- {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/commands/uninstall.py +0 -0
- {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/commands/workitem.py +0 -0
- {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/commands/wrap.py +0 -0
- {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/gateway_process.py +0 -0
- {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/loopback.py +0 -0
- {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/port_resolver.py +0 -0
- {agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/hatch_build.py +0 -0
{agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/client_assets/hooks/pretooluse_pathguard.py
RENAMED
|
@@ -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
|
-
|
|
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,
|
|
482
|
-
|
|
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
|
-
|
|
1473
|
-
|
|
1474
|
-
and
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentgov_cli-0.1.16 → agentgov_cli-0.1.18}/agentgov_cli/client_assets/managed-settings.json.tmpl
RENAMED
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|