gitpro 0.3.3__tar.gz → 0.3.4__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.
- {gitpro-0.3.3 → gitpro-0.3.4}/PKG-INFO +10 -1
- {gitpro-0.3.3 → gitpro-0.3.4}/README.md +9 -0
- {gitpro-0.3.3 → gitpro-0.3.4}/src/gitpro/__init__.py +1 -1
- {gitpro-0.3.3 → gitpro-0.3.4}/src/gitpro/agents.py +24 -2
- {gitpro-0.3.3 → gitpro-0.3.4}/src/gitpro/args.py +38 -0
- {gitpro-0.3.3 → gitpro-0.3.4}/src/gitpro/cli.py +5 -0
- {gitpro-0.3.3 → gitpro-0.3.4}/src/gitpro/guard.py +36 -8
- gitpro-0.3.4/src/gitpro/incident.py +133 -0
- {gitpro-0.3.3 → gitpro-0.3.4}/src/gitpro/mcp.py +30 -0
- gitpro-0.3.4/src/gitpro/report.py +804 -0
- {gitpro-0.3.3 → gitpro-0.3.4}/.gitignore +0 -0
- {gitpro-0.3.3 → gitpro-0.3.4}/LICENSE +0 -0
- {gitpro-0.3.3 → gitpro-0.3.4}/pyproject.toml +0 -0
- {gitpro-0.3.3 → gitpro-0.3.4}/src/gitpro/__main__.py +0 -0
- {gitpro-0.3.3 → gitpro-0.3.4}/src/gitpro/approval_policy.py +0 -0
- {gitpro-0.3.3 → gitpro-0.3.4}/src/gitpro/audit.py +0 -0
- {gitpro-0.3.3 → gitpro-0.3.4}/src/gitpro/change_contract.py +0 -0
- {gitpro-0.3.3 → gitpro-0.3.4}/src/gitpro/changes.py +0 -0
- {gitpro-0.3.3 → gitpro-0.3.4}/src/gitpro/clone.py +0 -0
- {gitpro-0.3.3 → gitpro-0.3.4}/src/gitpro/completion.py +0 -0
- {gitpro-0.3.3 → gitpro-0.3.4}/src/gitpro/config.py +0 -0
- {gitpro-0.3.3 → gitpro-0.3.4}/src/gitpro/devcontainer.py +0 -0
- {gitpro-0.3.3 → gitpro-0.3.4}/src/gitpro/discover.py +0 -0
- {gitpro-0.3.3 → gitpro-0.3.4}/src/gitpro/doctor.py +0 -0
- {gitpro-0.3.3 → gitpro-0.3.4}/src/gitpro/envcmd.py +0 -0
- {gitpro-0.3.3 → gitpro-0.3.4}/src/gitpro/example.toml +0 -0
- {gitpro-0.3.3 → gitpro-0.3.4}/src/gitpro/files.py +0 -0
- {gitpro-0.3.3 → gitpro-0.3.4}/src/gitpro/forge.py +0 -0
- {gitpro-0.3.3 → gitpro-0.3.4}/src/gitpro/health.py +0 -0
- {gitpro-0.3.3 → gitpro-0.3.4}/src/gitpro/hot.py +0 -0
- {gitpro-0.3.3 → gitpro-0.3.4}/src/gitpro/jsonfile.py +0 -0
- {gitpro-0.3.3 → gitpro-0.3.4}/src/gitpro/jsonout.py +0 -0
- {gitpro-0.3.3 → gitpro-0.3.4}/src/gitpro/keys.py +0 -0
- {gitpro-0.3.3 → gitpro-0.3.4}/src/gitpro/lint.py +0 -0
- {gitpro-0.3.3 → gitpro-0.3.4}/src/gitpro/lost.py +0 -0
- {gitpro-0.3.3 → gitpro-0.3.4}/src/gitpro/operation_contract.py +0 -0
- {gitpro-0.3.3 → gitpro-0.3.4}/src/gitpro/operation_io.py +0 -0
- {gitpro-0.3.3 → gitpro-0.3.4}/src/gitpro/operation_reconcile.py +0 -0
- {gitpro-0.3.3 → gitpro-0.3.4}/src/gitpro/operations.py +0 -0
- {gitpro-0.3.3 → gitpro-0.3.4}/src/gitpro/pin.py +0 -0
- {gitpro-0.3.3 → gitpro-0.3.4}/src/gitpro/plan.py +0 -0
- {gitpro-0.3.3 → gitpro-0.3.4}/src/gitpro/policy.py +0 -0
- {gitpro-0.3.3 → gitpro-0.3.4}/src/gitpro/prompt.py +0 -0
- {gitpro-0.3.3 → gitpro-0.3.4}/src/gitpro/render.py +0 -0
- {gitpro-0.3.3 → gitpro-0.3.4}/src/gitpro/resolve.py +0 -0
- {gitpro-0.3.3 → gitpro-0.3.4}/src/gitpro/scan.py +0 -0
- {gitpro-0.3.3 → gitpro-0.3.4}/src/gitpro/scanroots.py +0 -0
- {gitpro-0.3.3 → gitpro-0.3.4}/src/gitpro/setup.py +0 -0
- {gitpro-0.3.3 → gitpro-0.3.4}/src/gitpro/trailers.py +0 -0
- {gitpro-0.3.3 → gitpro-0.3.4}/src/gitpro/tui.py +0 -0
- {gitpro-0.3.3 → gitpro-0.3.4}/src/gitpro/undo.py +0 -0
- {gitpro-0.3.3 → gitpro-0.3.4}/src/gitpro/view.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: gitpro
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.4
|
|
4
4
|
Summary: Per-remote git identities (name, email, SSH key, commit signing) from one TOML file
|
|
5
5
|
Project-URL: Homepage, https://gitpro.dev
|
|
6
6
|
Project-URL: Source, https://github.com/Yantrakar/gitpro
|
|
@@ -106,6 +106,15 @@ optional operator-scoped preauthorization, and recoverable apply. MCP execution
|
|
|
106
106
|
The detailed TOML, YAML and command examples live in the guide, not on the
|
|
107
107
|
getting-started path.
|
|
108
108
|
|
|
109
|
+
## Problems and questions
|
|
110
|
+
|
|
111
|
+
Run `gitpro report` to draft an issue with the versions and a redacted
|
|
112
|
+
`gitpro why`; it shows you the draft and opens nothing until you say yes.
|
|
113
|
+
Or open an issue at [github.com/Yantrakar/gitpro/issues](https://github.com/Yantrakar/gitpro/issues).
|
|
114
|
+
Report a security problem privately with `gitpro report --security`, as the
|
|
115
|
+
[security policy](https://github.com/Yantrakar/gitpro/security/policy) describes,
|
|
116
|
+
not in a public issue.
|
|
117
|
+
|
|
109
118
|
## License
|
|
110
119
|
|
|
111
120
|
[MIT](LICENSE)
|
|
@@ -80,6 +80,15 @@ optional operator-scoped preauthorization, and recoverable apply. MCP execution
|
|
|
80
80
|
The detailed TOML, YAML and command examples live in the guide, not on the
|
|
81
81
|
getting-started path.
|
|
82
82
|
|
|
83
|
+
## Problems and questions
|
|
84
|
+
|
|
85
|
+
Run `gitpro report` to draft an issue with the versions and a redacted
|
|
86
|
+
`gitpro why`; it shows you the draft and opens nothing until you say yes.
|
|
87
|
+
Or open an issue at [github.com/Yantrakar/gitpro/issues](https://github.com/Yantrakar/gitpro/issues).
|
|
88
|
+
Report a security problem privately with `gitpro report --security`, as the
|
|
89
|
+
[security policy](https://github.com/Yantrakar/gitpro/security/policy) describes,
|
|
90
|
+
not in a public issue.
|
|
91
|
+
|
|
83
92
|
## License
|
|
84
93
|
|
|
85
94
|
[MIT](LICENSE)
|
|
@@ -1113,6 +1113,9 @@ class Decision:
|
|
|
1113
1113
|
cause: str
|
|
1114
1114
|
fix: str
|
|
1115
1115
|
repo: str
|
|
1116
|
+
why: str = "" # the denial record's reason code (not `reason`: that is the text below)
|
|
1117
|
+
codes: Tuple[str, ...] = () # problem codes of the repo's report
|
|
1118
|
+
action: str = "" # the git subcommand or program
|
|
1116
1119
|
|
|
1117
1120
|
@property
|
|
1118
1121
|
def reason(self) -> str:
|
|
@@ -1312,6 +1315,9 @@ def _judge_one(c: Call, rep, cfg, own: set, pushed_to: Optional[str] = None) ->
|
|
|
1312
1315
|
top = str(rep.repo)
|
|
1313
1316
|
what = _what(c)
|
|
1314
1317
|
commits = c.prog == "git" and c.sub != "push"
|
|
1318
|
+
codes = tuple(p.code for p in rep.problems)
|
|
1319
|
+
action = c.sub if c.prog == "git" else c.prog
|
|
1320
|
+
why = ("no_identity" if exp.email is None else "committer" if c_bad else "author" if a_bad else "problem")
|
|
1315
1321
|
if exp.email is None:
|
|
1316
1322
|
from .resolve import redact_url
|
|
1317
1323
|
url = redact_url(next(iter(rep.effective.remotes.values()), None)) or top
|
|
@@ -1329,9 +1335,10 @@ def _judge_one(c: Call, rep, cfg, own: set, pushed_to: Optional[str] = None) ->
|
|
|
1329
1335
|
if c_bad and c_src == "effective" and pushed_to and not triggers:
|
|
1330
1336
|
# the repo's other problems were computed for this remote's identity; they would mislead here
|
|
1331
1337
|
return Decision(headline, f"this repo's commits use {committer or '(no email)'} and {pushed_to} belongs "
|
|
1332
|
-
f"to {exp.id or '[default]'}", f"gitpro why {shlex.quote(top)}", top
|
|
1338
|
+
f"to {exp.id or '[default]'}", f"gitpro why {shlex.quote(top)}", top,
|
|
1339
|
+
"pushed_to", codes, action)
|
|
1333
1340
|
cause, fix = _cause(c, c_bad, c_src, committer, a_bad, a_src, author, rep, cfg, environ)
|
|
1334
|
-
return Decision(headline, cause, fix, top)
|
|
1341
|
+
return Decision(headline, cause, fix, top, why, codes, action)
|
|
1335
1342
|
|
|
1336
1343
|
|
|
1337
1344
|
def _command_text(c: Call, limit: int = 200) -> str:
|
|
@@ -1447,6 +1454,7 @@ def _check(args: argparse.Namespace, text: str) -> int:
|
|
|
1447
1454
|
sys.stdout.flush()
|
|
1448
1455
|
print(msg, file=sys.stderr)
|
|
1449
1456
|
return 0
|
|
1457
|
+
_record_denial(args.agent, d)
|
|
1450
1458
|
out, rc = adapter.deny(d)
|
|
1451
1459
|
if out:
|
|
1452
1460
|
sys.stdout.write(out + "\n")
|
|
@@ -1455,6 +1463,11 @@ def _check(args: argparse.Namespace, text: str) -> int:
|
|
|
1455
1463
|
return rc
|
|
1456
1464
|
|
|
1457
1465
|
|
|
1466
|
+
def _record_denial(agent: str, d: Decision) -> None:
|
|
1467
|
+
from . import incident # lazy: stdlib only, but most checks never deny
|
|
1468
|
+
incident.record_denial("agent-check", agent, d.action, d.why, d.codes)
|
|
1469
|
+
|
|
1470
|
+
|
|
1458
1471
|
def _fail_open(func: Callable[[], int]) -> int:
|
|
1459
1472
|
try:
|
|
1460
1473
|
return func()
|
|
@@ -1463,9 +1476,18 @@ def _fail_open(func: Callable[[], int]) -> int:
|
|
|
1463
1476
|
except _ArgError as e:
|
|
1464
1477
|
return _fail_open_msg(str(e))
|
|
1465
1478
|
except Exception as e: # fail open: never stop the agent
|
|
1479
|
+
_record_crash(e)
|
|
1466
1480
|
return _fail_open_msg(str(e) or type(e).__name__)
|
|
1467
1481
|
|
|
1468
1482
|
|
|
1483
|
+
def _record_crash(e: Exception) -> None:
|
|
1484
|
+
try:
|
|
1485
|
+
from . import incident
|
|
1486
|
+
incident.record_crash("agent-check", e)
|
|
1487
|
+
except Exception: # noqa: BLE001
|
|
1488
|
+
pass
|
|
1489
|
+
|
|
1490
|
+
|
|
1469
1491
|
def _fail_open_msg(msg: str) -> int:
|
|
1470
1492
|
try:
|
|
1471
1493
|
_warn(msg)
|
|
@@ -11,6 +11,8 @@ so both routes accept the same arguments without importing cli.
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
13
|
import argparse
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
14
16
|
import shlex
|
|
15
17
|
import subprocess
|
|
16
18
|
import sys
|
|
@@ -64,6 +66,40 @@ def fail(msg: str, args: object) -> int:
|
|
|
64
66
|
return 2
|
|
65
67
|
|
|
66
68
|
|
|
69
|
+
_CMD_RE = re.compile(r"^[a-z][a-z0-9-]{0,30}$")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _command_path(args: object) -> str:
|
|
73
|
+
"""The command words of parsed args ("forge similar"), or "gitpro"."""
|
|
74
|
+
words = [getattr(args, "command", None)]
|
|
75
|
+
for key, val in sorted(vars(args).items()) if hasattr(args, "__dict__") else []:
|
|
76
|
+
if key != "command" and key.endswith(("_command", "_action")):
|
|
77
|
+
words.append(val)
|
|
78
|
+
return " ".join(w for w in words if isinstance(w, str) and _CMD_RE.match(w)) or "gitpro"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _internal_error(args: object, exc: Exception) -> int:
|
|
82
|
+
"""An unexpected exception: write the crash record, print two lines (the traceback only with
|
|
83
|
+
GITPRO_DEBUG=1), exit 2."""
|
|
84
|
+
from . import incident
|
|
85
|
+
command = _command_path(args)
|
|
86
|
+
incident.record_crash(command, exc)
|
|
87
|
+
name = type(exc).__qualname__
|
|
88
|
+
if not re.match(r"^[A-Za-z_][A-Za-z0-9_.]{0,80}$", name):
|
|
89
|
+
name = "Exception"
|
|
90
|
+
if os.environ.get("GITPRO_DEBUG") == "1":
|
|
91
|
+
import traceback
|
|
92
|
+
traceback.print_exception(type(exc), exc, exc.__traceback__)
|
|
93
|
+
if getattr(args, "json", False):
|
|
94
|
+
from . import jsonout
|
|
95
|
+
jsonout.error(getattr(args, "command", None) or "gitpro", f"internal error ({name})")
|
|
96
|
+
else:
|
|
97
|
+
print(f"✗ internal error ({name}): this is a gitpro bug", file=sys.stderr)
|
|
98
|
+
print("→ draft a report with: gitpro report --crash", file=sys.stderr)
|
|
99
|
+
return 2
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
|
|
67
103
|
def guarded(func: Callable[[argparse.Namespace], int], args: argparse.Namespace) -> int:
|
|
68
104
|
"""func(args) with gitpro's error handling: ConfigError, CalledProcessError, OSError and UnicodeError
|
|
69
105
|
become exit 2 through fail(); Ctrl-C / EOF exit 130."""
|
|
@@ -80,3 +116,5 @@ def guarded(func: Callable[[argparse.Namespace], int], args: argparse.Namespace)
|
|
|
80
116
|
except (KeyboardInterrupt, EOFError):
|
|
81
117
|
print(file=sys.stderr)
|
|
82
118
|
return 130
|
|
119
|
+
except Exception as e: # noqa: BLE001 - a bug in gitpro: record it, say so, exit 2
|
|
120
|
+
return _internal_error(args, e)
|
|
@@ -14,6 +14,10 @@ first run on a new machine:
|
|
|
14
14
|
gitpro keys # public keys to register with each account
|
|
15
15
|
gitpro scan # every repo and the identity git uses there
|
|
16
16
|
|
|
17
|
+
problems and questions:
|
|
18
|
+
gitpro report --print # a redacted draft of a bug report; nothing is sent unless you say so
|
|
19
|
+
https://github.com/Yantrakar/gitpro/issues
|
|
20
|
+
|
|
17
21
|
-- for developers (not part of --help) --
|
|
18
22
|
CALLER_UMASK is the umask gitpro was started with. It is None until main() runs; main() saves it and
|
|
19
23
|
then tightens the process umask to 077, so every file gitpro writes is private. A command that creates
|
|
@@ -58,6 +62,7 @@ EXTENSIONS = [
|
|
|
58
62
|
"gitpro.operations", # versioned agent preflight and scoped execution
|
|
59
63
|
"gitpro.policy", # 14: policy, team, check --policy
|
|
60
64
|
"gitpro.prompt", # 16: prompt
|
|
65
|
+
"gitpro.report", # 19: report
|
|
61
66
|
"gitpro.resolve", # 01: why
|
|
62
67
|
"gitpro.scanroots", # 02: scan -c/--roots
|
|
63
68
|
"gitpro.setup", # guided configuration
|
|
@@ -189,6 +189,7 @@ def _check(name: str, argv: List[str], stdin: Optional[str], repo: Optional[Path
|
|
|
189
189
|
except KeyboardInterrupt:
|
|
190
190
|
raise
|
|
191
191
|
except Exception as e: # noqa: BLE001
|
|
192
|
+
_record_crash(f"guard {name}", e)
|
|
192
193
|
_warn(name, f"⚠ gitpro {spec}: {e}; not blocking")
|
|
193
194
|
continue
|
|
194
195
|
if rc == BLOCK:
|
|
@@ -227,8 +228,13 @@ def _where(exp) -> str:
|
|
|
227
228
|
return url or "this repo"
|
|
228
229
|
|
|
229
230
|
|
|
230
|
-
|
|
231
|
+
_RECORD = True # False while `guard test` simulates a hook: nothing was denied
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _mismatch(ctx: Context, head: str, lines: List[str], skip: str, reason: str = "problem") -> int:
|
|
231
235
|
warn = ctx.cfg.guard.on_mismatch != "block"
|
|
236
|
+
if not warn and _RECORD:
|
|
237
|
+
_record_denial(ctx, reason)
|
|
232
238
|
ctx.say(("⚠" if warn else "✗") + f" gitpro: {head}")
|
|
233
239
|
for line in lines:
|
|
234
240
|
ctx.say(" " + line)
|
|
@@ -238,6 +244,23 @@ def _mismatch(ctx: Context, head: str, lines: List[str], skip: str) -> int:
|
|
|
238
244
|
return BLOCK
|
|
239
245
|
|
|
240
246
|
|
|
247
|
+
def _record_crash(command: str, e: Exception) -> None:
|
|
248
|
+
try:
|
|
249
|
+
from . import incident
|
|
250
|
+
incident.record_crash(command, e)
|
|
251
|
+
except Exception: # noqa: BLE001
|
|
252
|
+
pass
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _record_denial(ctx: Context, reason: str) -> None:
|
|
256
|
+
try:
|
|
257
|
+
from . import incident
|
|
258
|
+
codes = tuple(p.code for p in getattr(ctx.report, "problems", ()) or ())
|
|
259
|
+
incident.record_denial("guard", None, ctx.hook, reason, codes)
|
|
260
|
+
except Exception: # noqa: BLE001 - recording never changes the hook's answer
|
|
261
|
+
pass
|
|
262
|
+
|
|
263
|
+
|
|
241
264
|
def _id_of(cfg, email: str) -> str:
|
|
242
265
|
for i in cfg.identities:
|
|
243
266
|
if _same(i.email, email):
|
|
@@ -269,14 +292,14 @@ def identity_commit(ctx: Context) -> int:
|
|
|
269
292
|
lines = [f"cause: git resolves user.email to {ctx.committer_email} here",
|
|
270
293
|
f"fix: {PROG} why {shlex.quote(str(ctx.repo))}"]
|
|
271
294
|
return _mismatch(ctx, f"this commit would be committed as {ctx.committer_email} but {_where(exp)} "
|
|
272
|
-
f"belongs to {_belongs(exp)}", lines, "commit")
|
|
295
|
+
f"belongs to {_belongs(exp)}", lines, "commit", "committer")
|
|
273
296
|
a = ctx.author_email
|
|
274
297
|
if a and bad_author:
|
|
275
298
|
lines = [f"cause: author is your {_id_of(ctx.cfg, a)} email",
|
|
276
299
|
f"fix: unset GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL, or commit with "
|
|
277
300
|
f"--author={shlex.quote(f'{exp.name} <{exp.email}>')}"]
|
|
278
301
|
return _mismatch(ctx, f"this commit would be authored as {a} but {_where(exp)} belongs to "
|
|
279
|
-
f"{_belongs(exp)}", lines, "commit")
|
|
302
|
+
f"{_belongs(exp)}", lines, "commit", "author")
|
|
280
303
|
return 0
|
|
281
304
|
|
|
282
305
|
|
|
@@ -325,7 +348,7 @@ def identity_push(ctx: Context) -> int:
|
|
|
325
348
|
"then git commit --amend --no-edit on each)")
|
|
326
349
|
head = (f"{n} commit{'s' * (n != 1)} to {_where(exp)} {'are' if n != 1 else 'is'} committed as {emails}; "
|
|
327
350
|
f"this remote belongs to {exp.id or '[default]'} <{exp.email}>")
|
|
328
|
-
return _mismatch(ctx, head, lines, "push")
|
|
351
|
+
return _mismatch(ctx, head, lines, "push", "push")
|
|
329
352
|
|
|
330
353
|
|
|
331
354
|
def _audit_available() -> bool:
|
|
@@ -598,10 +621,15 @@ def _check_strict(repo: Path) -> int:
|
|
|
598
621
|
ctx = Context("pre-commit", top, (), "", cfg, report, _ident_email(top, "COMMITTER", os.environ),
|
|
599
622
|
_ident_email(top, "AUTHOR", os.environ), _say)
|
|
600
623
|
refused = False
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
624
|
+
global _RECORD
|
|
625
|
+
_RECORD = False
|
|
626
|
+
try:
|
|
627
|
+
for spec in HOOKS["pre-commit"]:
|
|
628
|
+
fn = _entry(spec)
|
|
629
|
+
if fn is not None and fn(ctx) == BLOCK:
|
|
630
|
+
refused = True
|
|
631
|
+
finally:
|
|
632
|
+
_RECORD = True
|
|
605
633
|
return BLOCK if refused and cfg.guard.on_mismatch == "block" else 0
|
|
606
634
|
|
|
607
635
|
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Incident records for `gitpro report` (spec 19, section 8): the last crash and the last denials.
|
|
2
|
+
|
|
3
|
+
Stdlib only, so the hot hooks can import it. Best effort: a record that cannot be written is dropped
|
|
4
|
+
silently, because a failing hook must behave exactly as it did before. A record holds fixed fields
|
|
5
|
+
only: never an exception message, a local variable, an argument or a path outside the gitpro package.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
15
|
+
|
|
16
|
+
from . import __version__
|
|
17
|
+
|
|
18
|
+
PROG = "gitpro"
|
|
19
|
+
CRASH = "last-crash.json"
|
|
20
|
+
DENIALS = "denials.jsonl"
|
|
21
|
+
KEEP = 20 # denials kept
|
|
22
|
+
MAX_FRAMES = 30
|
|
23
|
+
REASONS = ("committer", "author", "no_identity", "pushed_to", "problem", "push")
|
|
24
|
+
SOURCES = ("guard", "agent-check")
|
|
25
|
+
_TYPE_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_.]{0,80}$")
|
|
26
|
+
_FUNC_RE = re.compile(r"^[A-Za-z_<][A-Za-z0-9_<>]{0,80}$")
|
|
27
|
+
_COMMAND_RE = re.compile(r"^[a-z][a-z0-9 _.-]{0,60}$")
|
|
28
|
+
_WORD_RE = re.compile(r"^[A-Za-z0-9_.-]{1,40}$")
|
|
29
|
+
_CODE_RE = re.compile(r"^[a-z_]{1,32}$")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def state_dir() -> Path:
|
|
33
|
+
"""The same directory as undo.state_dir(): $XDG_STATE_HOME/gitpro or ~/.local/state/gitpro, 0700."""
|
|
34
|
+
base = os.environ.get("XDG_STATE_HOME") or str(Path.home() / ".local/state")
|
|
35
|
+
path = Path(base) / PROG
|
|
36
|
+
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
37
|
+
return path
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _now() -> str:
|
|
41
|
+
return time.strftime("%Y-%m-%dT%H:%MZ", time.gmtime())
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _write(path: Path, text: str) -> None:
|
|
45
|
+
"""Atomically replace path with text, mode 0600."""
|
|
46
|
+
tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
47
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
48
|
+
try:
|
|
49
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
50
|
+
f.write(text)
|
|
51
|
+
os.replace(tmp, path)
|
|
52
|
+
except BaseException:
|
|
53
|
+
try:
|
|
54
|
+
tmp.unlink()
|
|
55
|
+
except OSError:
|
|
56
|
+
pass
|
|
57
|
+
raise
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _frames(tb: Any) -> "tuple[List[list], int]":
|
|
61
|
+
"""(frames inside the gitpro package as [gitpro/<relpath>, line, func], count of the others)."""
|
|
62
|
+
import traceback
|
|
63
|
+
pkg = Path(__file__).resolve().parent
|
|
64
|
+
inside: List[list] = []
|
|
65
|
+
outside = 0
|
|
66
|
+
for frame, line in traceback.walk_tb(tb):
|
|
67
|
+
try:
|
|
68
|
+
rel = Path(frame.f_code.co_filename).resolve().relative_to(pkg)
|
|
69
|
+
except (ValueError, OSError):
|
|
70
|
+
outside += 1
|
|
71
|
+
continue
|
|
72
|
+
name = frame.f_code.co_name
|
|
73
|
+
inside.append([f"{PROG}/{rel.as_posix()}", int(line), name if _FUNC_RE.match(name) else "?"])
|
|
74
|
+
return inside[-MAX_FRAMES:], outside
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def record_crash(command: str, exc: BaseException) -> None:
|
|
78
|
+
"""Write last-crash.json for an unexpected exception raised while running `command`."""
|
|
79
|
+
try:
|
|
80
|
+
frames, outside = _frames(exc.__traceback__)
|
|
81
|
+
name = type(exc).__qualname__
|
|
82
|
+
rec = {"time": _now(), "version": __version__,
|
|
83
|
+
"command": command if _COMMAND_RE.match(command or "") else "unknown",
|
|
84
|
+
"type": name if _TYPE_RE.match(name) else "Exception",
|
|
85
|
+
"frames": frames, "outside": outside}
|
|
86
|
+
_write(state_dir() / CRASH, json.dumps(rec) + "\n")
|
|
87
|
+
except Exception: # noqa: BLE001 - recording must never change what the caller does
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def record_denial(source: str, agent: Optional[str], action: str, reason: str,
|
|
92
|
+
codes: Sequence[str] = ()) -> None:
|
|
93
|
+
"""Append one denial to denials.jsonl, keeping the last KEEP."""
|
|
94
|
+
try:
|
|
95
|
+
if source not in SOURCES:
|
|
96
|
+
return
|
|
97
|
+
rec = {"time": _now(), "version": __version__, "source": source,
|
|
98
|
+
"agent": agent if agent and _WORD_RE.match(agent) else None,
|
|
99
|
+
"action": action if _WORD_RE.match(action or "") else "other",
|
|
100
|
+
"reason": reason if reason in REASONS else "problem",
|
|
101
|
+
"codes": [c for c in codes if isinstance(c, str) and _CODE_RE.match(c)]}
|
|
102
|
+
path = state_dir() / DENIALS
|
|
103
|
+
old = path.read_text(encoding="utf-8").splitlines() if path.is_file() else []
|
|
104
|
+
lines = [ln for ln in old if ln.strip()][-(KEEP - 1):] + [json.dumps(rec)]
|
|
105
|
+
_write(path, "\n".join(lines) + "\n")
|
|
106
|
+
except Exception: # noqa: BLE001
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def read_crash() -> Optional[Dict[str, Any]]:
|
|
111
|
+
"""The parsed last-crash.json, or None. Fields are not validated here; the reader must."""
|
|
112
|
+
try:
|
|
113
|
+
data = json.loads((state_dir() / CRASH).read_text(encoding="utf-8"))
|
|
114
|
+
except (OSError, ValueError):
|
|
115
|
+
return None
|
|
116
|
+
return data if isinstance(data, dict) else None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def read_denials() -> List[Dict[str, Any]]:
|
|
120
|
+
"""The parsed records of denials.jsonl, oldest first (unparsable lines skipped)."""
|
|
121
|
+
out: List[Dict[str, Any]] = []
|
|
122
|
+
try:
|
|
123
|
+
text = (state_dir() / DENIALS).read_text(encoding="utf-8")
|
|
124
|
+
except OSError:
|
|
125
|
+
return out
|
|
126
|
+
for line in text.splitlines():
|
|
127
|
+
try:
|
|
128
|
+
rec = json.loads(line)
|
|
129
|
+
except ValueError:
|
|
130
|
+
continue
|
|
131
|
+
if isinstance(rec, dict):
|
|
132
|
+
out.append(rec)
|
|
133
|
+
return out
|
|
@@ -409,6 +409,23 @@ def tool_identities(args: dict, ctx: Ctx) -> dict:
|
|
|
409
409
|
"clone_dir": i.clone_dir, "sign": config.signs(i, d)} for i in cfg.identities]}
|
|
410
410
|
|
|
411
411
|
|
|
412
|
+
def tool_draft_report(args: dict, ctx: Ctx) -> dict:
|
|
413
|
+
"""A redacted bug report and its prefilled issue url; nothing is sent, opened or recorded."""
|
|
414
|
+
from . import report
|
|
415
|
+
kind = args.get("kind", "bug")
|
|
416
|
+
repo = _repo(args["path"]) if "path" in args else None
|
|
417
|
+
try:
|
|
418
|
+
d = report.build_draft(kind, message=args.get("message"), repo=repo, config=ctx.cfg_path)
|
|
419
|
+
except report.ReportError as e:
|
|
420
|
+
raise ToolError(str(e)) from None
|
|
421
|
+
if d.hits:
|
|
422
|
+
kinds = ", ".join(dict.fromkeys(h["kind"] for h in d.hits))
|
|
423
|
+
where = ", ".join(dict.fromkeys(h["section"] for h in d.hits))
|
|
424
|
+
raise ToolError(f"no draft: it would contain {kinds} in {where}; nothing was shown or sent")
|
|
425
|
+
return {"ok": True, "command": "report", "title": d.title, "body": d.body, "url": d.url,
|
|
426
|
+
"security_url": report.ADVISORY_URL, "truncated": d.truncated}
|
|
427
|
+
|
|
428
|
+
|
|
412
429
|
# ---------------------------------------------------------------- the registry
|
|
413
430
|
|
|
414
431
|
def _schema(props: dict, required: Tuple[str, ...] = ()) -> dict:
|
|
@@ -487,6 +504,14 @@ TOOLS: Dict[str, Tool] = {t.name: t for t in (
|
|
|
487
504
|
tool_audit, "gitpro.audit"),
|
|
488
505
|
Tool("identities", "The configured identities (name, email, host, owners, forge accounts, signing); no key "
|
|
489
506
|
"contents, no extra git settings.", _schema({}), tool_identities),
|
|
507
|
+
Tool("draft_report", "Draft a redacted gitpro bug report (versions, a redacted why, the last crash or "
|
|
508
|
+
"denial) and the prefilled GitHub issue URL. It never sends or opens anything. Show the user the draft "
|
|
509
|
+
"and the URL and let them decide; for a security problem, give them security_url instead of an issue.",
|
|
510
|
+
_schema({"path": PATH, "kind": {"type": "string", "enum": ["bug", "crash", "denial"],
|
|
511
|
+
"description": "what to report (default: bug)"},
|
|
512
|
+
"message": {"type": "string", "minLength": 1, "maxLength": 2000,
|
|
513
|
+
"description": "what happened (redacted before it is used)"}}),
|
|
514
|
+
tool_draft_report),
|
|
490
515
|
Tool("preflight", "Versioned operation decision and state token for commit, push or GitHub PR creation. "
|
|
491
516
|
"Read-only; online=true explicitly permits account/destination reads. Ready is not authorization.",
|
|
492
517
|
_schema({"request": REQUEST_SCHEMA, "online": {"type": "boolean"}}, ("request",)), tool_preflight),
|
|
@@ -695,6 +720,11 @@ class Server:
|
|
|
695
720
|
tb = traceback.format_exc()
|
|
696
721
|
sys.stderr.write(tb)
|
|
697
722
|
self.note("!", tb)
|
|
723
|
+
try:
|
|
724
|
+
from . import incident
|
|
725
|
+
incident.record_crash(f"mcp {tool.name}", e)
|
|
726
|
+
except Exception: # noqa: BLE001 - recording never changes the answer
|
|
727
|
+
pass
|
|
698
728
|
return self.failed(f"internal error: {type(e).__name__}")
|
|
699
729
|
|
|
700
730
|
def result(self, obj: dict) -> dict:
|