agent-write-gate 0.1.0__py3-none-any.whl

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.
agentgate/cli.py ADDED
@@ -0,0 +1,550 @@
1
+ """cli.py -- agentgate command-line interface.
2
+
3
+ Subcommands:
4
+ agentgate hook --stdin Primary: agent hook entrypoint (Pre/PostToolUse)
5
+ agentgate scan PATH... Same checks over files (CI / manual); tty|json|sarif
6
+ agentgate checks List checks + enabled state + missing deps
7
+ agentgate --version
8
+
9
+ Exit codes:
10
+ hook: 0 = allow; 2 = block (deny in Pre / feedback in Post) or error
11
+ scan: 0 = no blocking findings; 1 = blocking findings; 2 = error
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import sys
19
+ from pathlib import Path
20
+ from typing import List, Optional
21
+
22
+ from .config import load_config
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # UTF-8 stdio fix (Windows cp932)
26
+ # ---------------------------------------------------------------------------
27
+
28
+ def _enable_utf8_io() -> None:
29
+ """Force UTF-8 on stdio so Unicode output is not mangled on Windows."""
30
+ out = getattr(sys.stdout, "reconfigure", None)
31
+ if out is not None:
32
+ try:
33
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
34
+ except Exception:
35
+ pass
36
+ err = getattr(sys.stderr, "reconfigure", None)
37
+ if err is not None:
38
+ try:
39
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
40
+ except Exception:
41
+ pass
42
+ inp = getattr(sys.stdin, "reconfigure", None)
43
+ if inp is not None:
44
+ try:
45
+ sys.stdin.reconfigure(encoding="utf-8")
46
+ except Exception:
47
+ pass
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Skip dirs / binary detection (mirrors mojihen)
52
+ # ---------------------------------------------------------------------------
53
+
54
+ _SKIP_DIRS = {
55
+ ".git", "node_modules", ".venv", "__pycache__", ".mypy_cache",
56
+ ".pytest_cache", "dist", "build", ".tox",
57
+ }
58
+
59
+
60
+ def _is_binary(path: Path) -> bool:
61
+ try:
62
+ with open(path, "rb") as fh:
63
+ chunk = fh.read(8192)
64
+ return b"\x00" in chunk
65
+ except OSError:
66
+ return True
67
+
68
+
69
+ def _read_source(path: Path) -> Optional[str]:
70
+ if _is_binary(path):
71
+ return None
72
+ try:
73
+ return path.read_text(encoding="utf-8", errors="replace")
74
+ except OSError:
75
+ return None
76
+
77
+
78
+ def collect_files(paths: List[Path]) -> List[Path]:
79
+ result: List[Path] = []
80
+ for p in paths:
81
+ if p.is_file():
82
+ result.append(p)
83
+ elif p.is_dir():
84
+ for root, dirs, files in os.walk(p):
85
+ dirs[:] = [
86
+ d for d in dirs
87
+ if d not in _SKIP_DIRS and not d.startswith(".")
88
+ ]
89
+ for fname in files:
90
+ result.append(Path(root) / fname)
91
+ return result
92
+
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # hook subcommand
96
+ # ---------------------------------------------------------------------------
97
+
98
+ def cmd_hook(args: List[str]) -> int:
99
+ """agentgate hook --stdin
100
+
101
+ Reads a JSON hook payload from stdin, runs enabled checks, applies policy.
102
+
103
+ Exit codes:
104
+ 0 = allow
105
+ 2 = block (deny in PreToolUse; feedback in PostToolUse)
106
+ """
107
+ if "--stdin" not in args:
108
+ print("agentgate hook: expected --stdin flag", file=sys.stderr)
109
+ return 2
110
+
111
+ # Parse optional --config
112
+ config_path: Optional[Path] = None
113
+ for i, arg in enumerate(args):
114
+ if arg in ("--config", "-c") and i + 1 < len(args):
115
+ config_path = Path(args[i + 1])
116
+ elif arg.startswith("--config="):
117
+ config_path = Path(arg.split("=", 1)[1])
118
+
119
+ # Load config (with startup dep check)
120
+ cfg = load_config(explicit_path=config_path)
121
+
122
+ # Startup check: if cjk is enabled, ensure mojihen is importable
123
+ if cfg.cjk.enabled:
124
+ try:
125
+ import mojihen # type: ignore # noqa: F401
126
+ except ImportError:
127
+ # Surface as clean error, not traceback
128
+ print(
129
+ "agentgate: ERROR -- cjk check enabled but mojihen is not installed.\n"
130
+ " Install it with: pip install agent-write-gate[cjk]\n"
131
+ " Or disable it: set [checks] cjk = false in agentgate.toml",
132
+ file=sys.stderr,
133
+ )
134
+ return 2
135
+
136
+ try:
137
+ payload_raw = sys.stdin.read()
138
+ except Exception as exc:
139
+ print(f"agentgate hook: failed to read stdin: {exc}", file=sys.stderr)
140
+ return 2
141
+
142
+ if not payload_raw or not payload_raw.strip():
143
+ return 0
144
+
145
+ # Try JSON parse; non-JSON -> fail open
146
+ try:
147
+ json.loads(payload_raw)
148
+ except (json.JSONDecodeError, ValueError):
149
+ return 0
150
+
151
+ from .adapter import from_stdin_json
152
+ from .registry import get_enabled
153
+ from .policy import apply_suppression, decide_block, filter_actionable
154
+ from .report import block_report, warn_report
155
+
156
+ event = from_stdin_json(payload_raw)
157
+
158
+ if not event.content:
159
+ return 0
160
+
161
+ # Run enabled checks
162
+ all_issues = []
163
+ enabled = get_enabled(cfg)
164
+ for name, check_fn in enabled:
165
+ try:
166
+ found = check_fn(event, cfg)
167
+ all_issues.extend(found)
168
+ except RuntimeError as exc:
169
+ # e.g. mojihen missing despite startup check passing (race or monkeypatch)
170
+ print(f"agentgate: ERROR in check '{name}': {exc}", file=sys.stderr)
171
+ return 2
172
+
173
+ # Apply suppression
174
+ surviving = apply_suppression(all_issues, event.content, cfg)
175
+
176
+ # Filter to actionable (not 'ignore')
177
+ actionable = filter_actionable(surviving, cfg)
178
+
179
+ if decide_block(actionable, cfg):
180
+ report = block_report(actionable, file_path=event.file_path)
181
+ print(report, file=sys.stderr)
182
+ return 2
183
+
184
+ # Non-blocking warn-level issues: surface to stderr but do not block (exit 0).
185
+ if actionable:
186
+ print(warn_report(actionable, file_path=event.file_path), file=sys.stderr)
187
+
188
+ return 0
189
+
190
+
191
+ # ---------------------------------------------------------------------------
192
+ # scan subcommand
193
+ # ---------------------------------------------------------------------------
194
+
195
+ def cmd_scan(args: List[str]) -> int:
196
+ """agentgate scan PATH...
197
+
198
+ Walk paths, run checks on each file, output in chosen format.
199
+
200
+ Exit codes:
201
+ 0 = no blocking findings
202
+ 1 = blocking findings found
203
+ 2 = error
204
+ """
205
+ fmt = "tty"
206
+ config_path: Optional[Path] = None
207
+ paths: List[str] = []
208
+
209
+ i = 0
210
+ while i < len(args):
211
+ arg = args[i]
212
+ if arg in ("--format", "-f") and i + 1 < len(args):
213
+ fmt = args[i + 1]
214
+ i += 2
215
+ elif arg.startswith("--format="):
216
+ fmt = arg.split("=", 1)[1]
217
+ i += 1
218
+ elif arg in ("--config", "-c") and i + 1 < len(args):
219
+ config_path = Path(args[i + 1])
220
+ i += 2
221
+ elif arg.startswith("--config="):
222
+ config_path = Path(arg.split("=", 1)[1])
223
+ i += 1
224
+ elif arg.startswith("-"):
225
+ print(f"agentgate scan: unknown flag {arg!r}", file=sys.stderr)
226
+ return 2
227
+ else:
228
+ paths.append(arg)
229
+ i += 1
230
+
231
+ if not paths:
232
+ print("agentgate scan: no paths specified", file=sys.stderr)
233
+ return 2
234
+
235
+ if fmt not in ("tty", "json", "sarif"):
236
+ print(f"agentgate scan: --format must be tty|json|sarif, got {fmt!r}", file=sys.stderr)
237
+ return 2
238
+
239
+ cfg = load_config(explicit_path=config_path)
240
+
241
+ # Startup dep check
242
+ if cfg.cjk.enabled:
243
+ try:
244
+ import mojihen # type: ignore # noqa: F401
245
+ except ImportError:
246
+ print(
247
+ "agentgate: ERROR -- cjk check enabled but mojihen is not installed.\n"
248
+ " Install it with: pip install agent-write-gate[cjk]\n"
249
+ " Or disable it: set [checks] cjk = false in agentgate.toml",
250
+ file=sys.stderr,
251
+ )
252
+ return 2
253
+
254
+ from .adapter import from_stdin_json
255
+ from .model import WriteEvent, Issue
256
+ from .registry import get_enabled
257
+ from .policy import apply_suppression, decide_block, filter_actionable
258
+ from .report import format_tty, format_json, format_sarif
259
+ from .checks.unicode_safety import run as unicode_run
260
+
261
+ file_list = collect_files([Path(p) for p in paths])
262
+ if not file_list:
263
+ print(f"agentgate scan: no files found in {paths}", file=sys.stderr)
264
+ return 2
265
+
266
+ all_file_issues: List[tuple] = [] # (file_path_str, issue)
267
+ any_blocking = False
268
+
269
+ enabled = get_enabled(cfg)
270
+
271
+ for fp in file_list:
272
+ source = _read_source(fp)
273
+ if source is None:
274
+ continue
275
+
276
+ # Build a synthetic WriteEvent for this file
277
+ event = WriteEvent(
278
+ agent="generic",
279
+ phase="unknown",
280
+ tool="unknown",
281
+ file_path=str(fp),
282
+ content=source,
283
+ )
284
+
285
+ file_issues = []
286
+ for name, check_fn in enabled:
287
+ try:
288
+ found = check_fn(event, cfg)
289
+ file_issues.extend(found)
290
+ except RuntimeError as exc:
291
+ print(f"agentgate: ERROR in check '{name}' on {fp}: {exc}", file=sys.stderr)
292
+ return 2
293
+
294
+ # Apply suppression
295
+ surviving = apply_suppression(file_issues, source, cfg)
296
+ actionable = filter_actionable(surviving, cfg)
297
+
298
+ for issue in actionable:
299
+ all_file_issues.append((str(fp), issue))
300
+ if decide_block([issue], cfg):
301
+ any_blocking = True
302
+
303
+ # Render output
304
+ all_issues_flat = [issue for _, issue in all_file_issues]
305
+
306
+ if fmt == "json":
307
+ # Build combined JSON with file paths per issue
308
+ import json as _json
309
+ payload = {
310
+ "version": "1",
311
+ "tool": "agentgate",
312
+ "issues": [
313
+ {
314
+ "check": issue.check,
315
+ "rule_id": issue.rule_id,
316
+ "severity": issue.severity,
317
+ "file": fp_str,
318
+ "line": issue.line,
319
+ "col": issue.col,
320
+ "message": issue.message,
321
+ "excerpt": issue.excerpt,
322
+ "suggestion": issue.suggestion,
323
+ }
324
+ for fp_str, issue in all_file_issues
325
+ ],
326
+ }
327
+ _safe_print(_json.dumps(payload, ensure_ascii=False, indent=2))
328
+ elif fmt == "sarif":
329
+ # Build SARIF with file paths
330
+ _print_sarif(all_file_issues)
331
+ else:
332
+ # TTY: group by file
333
+ _print_tty(all_file_issues)
334
+
335
+ return 1 if any_blocking else 0
336
+
337
+
338
+ def _print_tty(file_issues: List[tuple]) -> None:
339
+ lines: List[str] = []
340
+ for fp_str, issue in file_issues:
341
+ lines.append(
342
+ f"{fp_str}:{issue.line}:{issue.col} "
343
+ f"{issue.check}/{issue.rule_id} {issue.severity.upper()} "
344
+ f"'{issue.excerpt}'"
345
+ + (f" -> {issue.suggestion}" if issue.suggestion else "")
346
+ )
347
+ lines.append(f" {issue.message}")
348
+
349
+ n = len(file_issues)
350
+ if n == 0:
351
+ lines.append("agentgate: no issues")
352
+ else:
353
+ plural = "issue" if n == 1 else "issues"
354
+ lines.append(f"\nagentgate: {n} {plural}")
355
+ _safe_print("\n".join(lines))
356
+
357
+
358
+ def _print_sarif(file_issues: List[tuple]) -> None:
359
+ import json as _json
360
+
361
+ rule_ids_seen = sorted({issue.rule_id for _, issue in file_issues})
362
+
363
+ _RULE_DESCRIPTIONS = {
364
+ "AG-BIDI": "Bidi control character that can visually reorder source code (Trojan-Source).",
365
+ "AG-INVIS": "Invisible character inside identifier or string.",
366
+ "AG-HOMO": "Homoglyph: Cyrillic or Greek character that looks like ASCII.",
367
+ "MH001": "Known LLM CJK corruption.",
368
+ "MH002": "Mixed-script token.",
369
+ "MH003": "Isolated CJK in ASCII context.",
370
+ }
371
+ _RULE_NAMES = {
372
+ "AG-BIDI": "bidi-control",
373
+ "AG-INVIS": "invisible-char",
374
+ "AG-HOMO": "homoglyph",
375
+ "MH001": "known-cjk-corruption",
376
+ "MH002": "mixed-script-token",
377
+ "MH003": "isolated-cjk",
378
+ }
379
+ _SARIF_LEVEL = {"high": "error", "medium": "warning", "low": "note"}
380
+
381
+ rules = [
382
+ {
383
+ "id": rid,
384
+ "name": _RULE_NAMES.get(rid, rid),
385
+ "shortDescription": {"text": _RULE_DESCRIPTIONS.get(rid, rid)},
386
+ "defaultConfiguration": {"level": "error"},
387
+ }
388
+ for rid in rule_ids_seen
389
+ ]
390
+
391
+ results = [
392
+ {
393
+ "ruleId": issue.rule_id,
394
+ "level": _SARIF_LEVEL.get(issue.severity, "warning"),
395
+ "message": {"text": issue.message},
396
+ "locations": [
397
+ {
398
+ "physicalLocation": {
399
+ "artifactLocation": {"uri": fp_str},
400
+ "region": {
401
+ "startLine": issue.line,
402
+ "startColumn": issue.col,
403
+ },
404
+ }
405
+ }
406
+ ],
407
+ }
408
+ for fp_str, issue in file_issues
409
+ ]
410
+
411
+ sarif = {
412
+ "$schema": "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json",
413
+ "version": "2.1.0",
414
+ "runs": [
415
+ {
416
+ "tool": {
417
+ "driver": {
418
+ "name": "agentgate",
419
+ "version": "0.1.0",
420
+ "informationUri": "https://github.com/hryoma1217/agentgate",
421
+ "rules": rules,
422
+ }
423
+ },
424
+ "results": results,
425
+ }
426
+ ],
427
+ }
428
+ _safe_print(_json.dumps(sarif, ensure_ascii=False, indent=2))
429
+
430
+
431
+ # ---------------------------------------------------------------------------
432
+ # checks subcommand
433
+ # ---------------------------------------------------------------------------
434
+
435
+ def cmd_checks(args: List[str]) -> int:
436
+ """agentgate checks -- list registered checks, enabled state, and dep status."""
437
+ from .registry import get_all
438
+
439
+ config_path: Optional[Path] = None
440
+ for i, arg in enumerate(args):
441
+ if arg in ("--config", "-c") and i + 1 < len(args):
442
+ config_path = Path(args[i + 1])
443
+ elif arg.startswith("--config="):
444
+ config_path = Path(arg.split("=", 1)[1])
445
+
446
+ cfg = load_config(explicit_path=config_path)
447
+ registry = get_all()
448
+
449
+ print("agentgate checks:")
450
+ print()
451
+
452
+ # unicode check
453
+ unicode_enabled = cfg.unicode.enabled
454
+ unicode_status = "enabled" if unicode_enabled else "disabled"
455
+ print(f" unicode {unicode_status} (stdlib, no extra deps)")
456
+
457
+ # cjk check
458
+ cjk_enabled = cfg.cjk.enabled
459
+ cjk_status = "enabled" if cjk_enabled else "disabled"
460
+ mojihen_ok = False
461
+ try:
462
+ import mojihen # type: ignore # noqa: F401
463
+ mojihen_ok = True
464
+ except ImportError:
465
+ pass
466
+
467
+ if cjk_enabled and not mojihen_ok:
468
+ print(f" cjk {cjk_status} [MISSING: mojihen -- run: pip install agent-write-gate[cjk]]")
469
+ elif cjk_enabled and mojihen_ok:
470
+ print(f" cjk {cjk_status} (mojihen installed)")
471
+ else:
472
+ mojihen_note = "(mojihen installed)" if mojihen_ok else "(mojihen not installed)"
473
+ print(f" cjk {cjk_status} {mojihen_note}")
474
+
475
+ # Third-party checks
476
+ for name in registry:
477
+ if name not in ("unicode", "cjk"):
478
+ print(f" {name:<10} enabled (third-party)")
479
+
480
+ print()
481
+ return 0
482
+
483
+
484
+ # ---------------------------------------------------------------------------
485
+ # Main entry point
486
+ # ---------------------------------------------------------------------------
487
+
488
+ def _safe_print(text: str) -> None:
489
+ try:
490
+ print(text)
491
+ except UnicodeEncodeError:
492
+ enc = sys.stdout.encoding or "utf-8"
493
+ print(text.encode(enc, errors="replace").decode(enc, errors="replace"))
494
+
495
+
496
+ def _print_help() -> None:
497
+ msg = (
498
+ "agentgate -- agent-hook safety gate for AI-written code\n\n"
499
+ "Usage:\n"
500
+ " agentgate hook --stdin Hook entrypoint (pipe JSON from agent)\n"
501
+ " agentgate scan PATH... [--format tty|json|sarif] Scan files\n"
502
+ " agentgate checks List checks and their status\n"
503
+ " agentgate --version\n\n"
504
+ "Options:\n"
505
+ " --config PATH Explicit config file (agentgate.toml or pyproject.toml)\n"
506
+ " -h, --help Show this help\n\n"
507
+ "Exit codes (hook): 0 = allow; 2 = block or error\n"
508
+ "Exit codes (scan): 0 = no blocking issues; 1 = blocking issues; 2 = error\n"
509
+ )
510
+ try:
511
+ print(msg, file=sys.stderr)
512
+ except UnicodeEncodeError:
513
+ print(msg.encode("ascii", errors="replace").decode("ascii"), file=sys.stderr)
514
+
515
+
516
+ def main(argv: Optional[List[str]] = None) -> int:
517
+ _enable_utf8_io()
518
+ if argv is None:
519
+ argv = sys.argv[1:]
520
+
521
+ if not argv or argv[0] in ("-h", "--help"):
522
+ _print_help()
523
+ return 0 if argv else 2
524
+
525
+ if argv[0] == "--version":
526
+ from . import __version__
527
+ print(f"agentgate {__version__}")
528
+ return 0
529
+
530
+ from .config import ConfigError
531
+ try:
532
+ if argv[0] == "hook":
533
+ return cmd_hook(argv[1:])
534
+
535
+ if argv[0] == "scan":
536
+ return cmd_scan(argv[1:])
537
+
538
+ if argv[0] == "checks":
539
+ return cmd_checks(argv[1:])
540
+ except ConfigError as exc:
541
+ print(f"agentgate: config error -- {exc}", file=sys.stderr)
542
+ return 2
543
+
544
+ print(f"agentgate: unknown subcommand {argv[0]!r}", file=sys.stderr)
545
+ _print_help()
546
+ return 2
547
+
548
+
549
+ if __name__ == "__main__":
550
+ sys.exit(main())