universal-dev-security 1.0.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.
Files changed (37) hide show
  1. universal_dev_security/__init__.py +3 -0
  2. universal_dev_security/__main__.py +4 -0
  3. universal_dev_security/cli.py +376 -0
  4. universal_dev_security/cli_extra.py +244 -0
  5. universal_dev_security/config.py +171 -0
  6. universal_dev_security/detector.py +201 -0
  7. universal_dev_security/engine.py +203 -0
  8. universal_dev_security/findings.py +199 -0
  9. universal_dev_security/fixes.py +138 -0
  10. universal_dev_security/gitguard.py +269 -0
  11. universal_dev_security/history.py +149 -0
  12. universal_dev_security/initializer.py +112 -0
  13. universal_dev_security/integrations/__init__.py +46 -0
  14. universal_dev_security/integrations/agents.py +45 -0
  15. universal_dev_security/integrations/claude_code.py +192 -0
  16. universal_dev_security/integrations/github.py +149 -0
  17. universal_dev_security/integrations/vscode.py +44 -0
  18. universal_dev_security/policy.py +384 -0
  19. universal_dev_security/projectfiles.py +80 -0
  20. universal_dev_security/recovery.py +168 -0
  21. universal_dev_security/redaction.py +132 -0
  22. universal_dev_security/reports.py +466 -0
  23. universal_dev_security/scanner.py +36 -0
  24. universal_dev_security/scanners/__init__.py +36 -0
  25. universal_dev_security/scanners/base.py +269 -0
  26. universal_dev_security/scanners/gitleaks.py +132 -0
  27. universal_dev_security/scanners/osv.py +156 -0
  28. universal_dev_security/scanners/semgrep.py +130 -0
  29. universal_dev_security/scanners/trivy.py +139 -0
  30. universal_dev_security/testing.py +100 -0
  31. universal_dev_security/updates.py +84 -0
  32. universal_dev_security-1.0.0.dist-info/METADATA +128 -0
  33. universal_dev_security-1.0.0.dist-info/RECORD +37 -0
  34. universal_dev_security-1.0.0.dist-info/WHEEL +5 -0
  35. universal_dev_security-1.0.0.dist-info/entry_points.txt +2 -0
  36. universal_dev_security-1.0.0.dist-info/licenses/LICENSE +163 -0
  37. universal_dev_security-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3 @@
1
+ """Universal Dev Security (UDS) — one security system for every project."""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1,4 @@
1
+ """Allow ``python -m universal_dev_security``."""
2
+ from universal_dev_security.cli import main
3
+
4
+ raise SystemExit(main())
@@ -0,0 +1,376 @@
1
+ """Command-line interface for Universal Dev Security."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ import platform
8
+ import shutil
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Any, Callable
12
+
13
+ from universal_dev_security import __version__
14
+ from universal_dev_security.config import FAIL_ON_LEVELS, ConfigError, ProjectConfig, load_config
15
+ from universal_dev_security.detector import detect_project
16
+ from universal_dev_security.initializer import initialize_project
17
+
18
+ EXIT_OK, EXIT_FINDINGS, EXIT_ERROR = 0, 1, 2
19
+
20
+
21
+ def _out(data: Any) -> None:
22
+ print(json.dumps(data, indent=2, ensure_ascii=False))
23
+
24
+
25
+ def _err(message: str) -> None:
26
+ print(f"uds: {message}", file=sys.stderr)
27
+
28
+
29
+ def _project(path: str) -> Path:
30
+ target = Path(path).expanduser().resolve()
31
+ if not target.is_dir():
32
+ raise ValueError(f"Not an existing directory: {target}")
33
+ return target
34
+
35
+
36
+ def _config(root: Path) -> ProjectConfig:
37
+ return load_config(root)
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Commands
42
+ # ---------------------------------------------------------------------------
43
+
44
+ def cmd_doctor(args: argparse.Namespace) -> int:
45
+ from universal_dev_security.scanners import available_scanners, get_scanner
46
+
47
+ root = _project(args.path)
48
+ scanners = [get_scanner(name).info(root) for name in available_scanners()]
49
+ try:
50
+ config = load_config(root)
51
+ config_state = {"exists": config.exists, "warnings": config.warnings}
52
+ except ConfigError as exc:
53
+ config_state = {"exists": True, "error": str(exc)}
54
+ data = {
55
+ "uds_version": __version__,
56
+ "python": sys.version.split()[0],
57
+ "platform": f"{platform.system()} {platform.release()}",
58
+ "git": shutil.which("git") is not None,
59
+ "config": config_state,
60
+ "scanners": scanners,
61
+ "network_used": False,
62
+ "global_settings_modified": False,
63
+ }
64
+ if args.json:
65
+ _out(data)
66
+ else:
67
+ print(f"UDS {__version__} · Python {data['python']} · {data['platform']}")
68
+ print(f"Git available: {'yes' if data['git'] else 'no'}")
69
+ print(f"Project config: {'found' if config_state.get('exists') else 'not initialized'}")
70
+ for warning in config_state.get("warnings", []):
71
+ print(f" ! {warning}")
72
+ if config_state.get("error"):
73
+ print(f" ! {config_state['error']}")
74
+ print("Scanners:")
75
+ for s in scanners:
76
+ if not s["installed"]:
77
+ state = "not installed" + (f" ({s['note']})" if s["note"] else "")
78
+ elif not s["compatible"]:
79
+ state = f"installed {s['version'] or 'unknown version'} — UNSUPPORTED (need {s['minimum_version']}+)"
80
+ else:
81
+ state = f"OK {s['version']}"
82
+ print(f" {s['display_name']:<12} {state}")
83
+ if not s["installed"]:
84
+ hint = s["install_hints"].get({"Windows": "windows", "Darwin": "macos"}.get(platform.system(), "linux"))
85
+ print(f" {'':<12} install: {hint} ({s['docs_url']})")
86
+ print("UDS runtime: OK (no network access used, no global settings modified)")
87
+ return EXIT_OK
88
+
89
+
90
+ def cmd_detect(args: argparse.Namespace) -> int:
91
+ _out(detect_project(_project(args.path)))
92
+ return EXIT_OK
93
+
94
+
95
+ def cmd_init(args: argparse.Namespace) -> int:
96
+ result = initialize_project(Path(args.path))
97
+ _out(result)
98
+ return EXIT_OK if result["status"] in {"initialized", "already_initialized"} else EXIT_ERROR
99
+
100
+
101
+ def cmd_status(args: argparse.Namespace) -> int:
102
+ from universal_dev_security.history import list_scans, load_scan
103
+
104
+ target = _project(args.path)
105
+ config = target / ".uds" / "config.toml"
106
+ if not config.is_file():
107
+ print(f"UDS not initialized: {target}")
108
+ print("Run `uds init` to create the local project configuration.")
109
+ return EXIT_FINDINGS
110
+ print(f"UDS initialized: {target}")
111
+ print(f"Configuration: {config}")
112
+ scans = list_scans(target)
113
+ if scans:
114
+ latest = load_scan(target, "latest")
115
+ print(f"Latest scan: {latest['scan_id']} · status {latest['overall_status']} · "
116
+ f"{latest['summary']['finding_count']} finding(s) · scanners run: "
117
+ f"{', '.join(latest['coverage']['executed']) or 'none'}")
118
+ else:
119
+ print("Latest scan: none (run `uds scan`)")
120
+ return EXIT_OK
121
+
122
+
123
+ def _print_scan_summary(scan: dict[str, Any], paths: dict[str, str], comparison: dict[str, Any] | None) -> None:
124
+ summary = scan["summary"]
125
+ print(f"UDS scan {scan['scan_id']} · {scan['target']}")
126
+ print(f"Overall status: {scan['overall_status'].upper()}")
127
+ for s in scan["scanners"]:
128
+ print(f" {s['tool']:<12} {s['status']:<15} {s['finding_count'] if s['executed'] else '-':>5} {s['message']}")
129
+ sev = summary["by_severity"]
130
+ print("Findings: " + ", ".join(f"{k} {v}" for k, v in sev.items()) + f" (total {summary['finding_count']})")
131
+ if comparison:
132
+ print(f"Since previous scan: {len(comparison['new'])} new, {len(comparison['resolved'])} resolved, "
133
+ f"{len(comparison['not_rescanned'])} not re-scanned")
134
+ for warning in comparison.get("warnings", []):
135
+ print(f" ! {warning}")
136
+ for kind, path in paths.items():
137
+ print(f"Report ({kind}): {path}")
138
+
139
+
140
+ def cmd_scan(args: argparse.Namespace) -> int:
141
+ from universal_dev_security.engine import exit_code_for, run_scan
142
+ from universal_dev_security.history import compare_scans, history_summaries, list_scans, load_scan, save_scan
143
+ from universal_dev_security.reports import build_report, write_reports
144
+
145
+ root = _project(args.path)
146
+ config = _config(root)
147
+ for warning in config.warnings:
148
+ _err(f"config warning: {warning}")
149
+ progress: Callable[[str], None] | None = None if (args.json or args.quiet) else (lambda m: print(m, file=sys.stderr))
150
+ scan = run_scan(root, config, tools=args.tool or None,
151
+ allow_network=True if args.allow_network else None, progress=progress)
152
+
153
+ comparison = None
154
+ if not args.no_history:
155
+ previous = list_scans(root)
156
+ if previous:
157
+ try:
158
+ comparison = compare_scans(load_scan(root, previous[-1]), scan)
159
+ except (OSError, ValueError):
160
+ comparison = None
161
+ save_scan(root, scan, config.history_limit)
162
+ history = [] if args.no_history else history_summaries(root)
163
+ report = build_report(scan, comparison, history)
164
+ formats = tuple(f.strip() for f in args.format.split(",") if f.strip())
165
+ paths = write_reports(report, root, Path(args.report_dir) if args.report_dir else None, formats)
166
+ fail_on = args.fail_on or config.fail_on
167
+ code = exit_code_for(scan, fail_on, args.require_complete)
168
+ if args.json:
169
+ _out({"scan_id": scan["scan_id"], "overall_status": scan["overall_status"], "exit_code": code,
170
+ "summary": scan["summary"], "scanners": scan["scanners"], "reports": paths,
171
+ "comparison": {k: (len(v) if isinstance(v, list) else v) for k, v in comparison.items()} if comparison else None})
172
+ elif not args.quiet:
173
+ _print_scan_summary(scan, paths, comparison)
174
+ if code == EXIT_FINDINGS:
175
+ print(f"Result: findings at or above fail-on threshold '{fail_on}'.")
176
+ elif code == EXIT_ERROR:
177
+ print("Result: scan incomplete or failed — results must not be treated as a pass.")
178
+ return code
179
+
180
+
181
+ def cmd_report(args: argparse.Namespace) -> int:
182
+ from universal_dev_security.history import compare_scans, history_summaries, list_scans, load_scan, resolve_scan_id
183
+ from universal_dev_security.reports import build_report, write_reports
184
+
185
+ root = _project(args.path)
186
+ scan_id = resolve_scan_id(root, args.scan)
187
+ scan = load_scan(root, scan_id)
188
+ scans = list_scans(root)
189
+ index = scans.index(scan_id)
190
+ comparison = compare_scans(load_scan(root, scans[index - 1]), scan) if index > 0 else None
191
+ history = [h for h in history_summaries(root, 1000) if str(h["scan_id"]) <= scan_id][-20:]
192
+ formats = tuple(f.strip() for f in args.format.split(",") if f.strip())
193
+ paths = write_reports(build_report(scan, comparison, history), root,
194
+ Path(args.report_dir) if args.report_dir else None, formats)
195
+ _out(paths)
196
+ return EXIT_OK
197
+
198
+
199
+ def cmd_dashboard(args: argparse.Namespace) -> int:
200
+ root = _project(args.path)
201
+ html = (Path(args.report_dir).expanduser().resolve() if args.report_dir else root / ".uds" / "reports") / "security-report.html"
202
+ if not html.is_file():
203
+ _err("No dashboard found. Run `uds scan` first.")
204
+ return EXIT_ERROR
205
+ print(html)
206
+ if args.open:
207
+ import webbrowser
208
+ webbrowser.open(html.as_uri())
209
+ return EXIT_OK
210
+
211
+
212
+ def cmd_history(args: argparse.Namespace) -> int:
213
+ from universal_dev_security.history import history_summaries
214
+
215
+ entries = history_summaries(_project(args.path), args.limit)
216
+ if args.json:
217
+ _out(entries)
218
+ return EXIT_OK
219
+ if not entries:
220
+ print("No scan history. Run `uds scan`.")
221
+ return EXIT_OK
222
+ for e in entries:
223
+ sev = e["by_severity"]
224
+ print(f"{e['scan_id']} {e['overall_status']:<12} total {e['finding_count']:>4} "
225
+ + " ".join(f"{k[:4]}:{v}" for k, v in sev.items()) + f" [{', '.join(e['executed'])}]")
226
+ return EXIT_OK
227
+
228
+
229
+ def cmd_compare(args: argparse.Namespace) -> int:
230
+ from universal_dev_security.history import compare_scans, load_scan
231
+
232
+ root = _project(args.path)
233
+ result = compare_scans(load_scan(root, args.base), load_scan(root, args.head))
234
+ if args.json:
235
+ _out(result)
236
+ return EXIT_OK
237
+ print(f"Base {result['base_scan_id']} → head {result['current_scan_id']} (tools: {', '.join(result['compared_tools']) or 'none'})")
238
+ print(f"New: {len(result['new'])} Resolved: {len(result['resolved'])} Unchanged: {result['unchanged_count']} "
239
+ f"Not re-scanned: {len(result['not_rescanned'])}")
240
+ for label in ("new", "resolved"):
241
+ for f in result[label][:50]:
242
+ print(f" [{label}] {f['severity']:<12} {f['tool']:<11} {f['rule_id']} {f.get('file') or ''}")
243
+ return EXIT_OK
244
+
245
+
246
+ def cmd_policy(args: argparse.Namespace) -> int:
247
+ from universal_dev_security.policy import GLOBAL_COMMAND_RULES, Action, ActionKind, PolicyEngine
248
+
249
+ root = _project(args.path)
250
+ config = _config(root)
251
+ if args.policy_command == "show":
252
+ _out({
253
+ "precedence": ["mandatory global policy", "technology profile", "project configuration"],
254
+ "global_command_rules": [{"rule_id": r.rule_id, "decision": r.decision.value, "risk": r.risk.value,
255
+ "reason": r.reason} for r in GLOBAL_COMMAND_RULES],
256
+ "project": config.to_dict(),
257
+ })
258
+ return EXIT_OK
259
+ engine = PolicyEngine(config)
260
+ if args.command_text is not None:
261
+ action = Action(ActionKind.SHELL, command=args.command_text, project_root=str(root))
262
+ else:
263
+ content = Path(args.content_file).read_text(encoding="utf-8", errors="replace") if args.content_file else ""
264
+ action = Action(ActionKind.WRITE_FILE, path=args.write, content=content, project_root=str(root))
265
+ decision = engine.evaluate(action)
266
+ _out(decision.to_dict())
267
+ return {"allow": EXIT_OK, "ask": EXIT_FINDINGS, "deny": EXIT_ERROR}[decision.decision.value]
268
+
269
+
270
+ def cmd_scanners(args: argparse.Namespace) -> int:
271
+ from universal_dev_security.scanners import available_scanners, get_scanner
272
+
273
+ _out([get_scanner(name).info(Path.cwd()) for name in available_scanners()])
274
+ return EXIT_OK
275
+
276
+
277
+ # ---------------------------------------------------------------------------
278
+ # Parser
279
+ # ---------------------------------------------------------------------------
280
+
281
+ def build_parser() -> argparse.ArgumentParser:
282
+ parser = argparse.ArgumentParser(prog="uds", description="Universal Dev Security — One Security System. Every Project.")
283
+ parser.add_argument("--version", action="version", version=f"UDS {__version__}")
284
+ sub = parser.add_subparsers(dest="command", required=True)
285
+
286
+ def with_path(p: argparse.ArgumentParser) -> argparse.ArgumentParser:
287
+ p.add_argument("path", nargs="?", default=".", help="Project directory (default: current directory)")
288
+ return p
289
+
290
+ p = with_path(sub.add_parser("doctor", help="Check the UDS runtime, configuration and scanners"))
291
+ p.add_argument("--json", action="store_true")
292
+ p.set_defaults(func=cmd_doctor)
293
+ with_path(sub.add_parser("detect", help="Detect project technologies and scanning profile")).set_defaults(func=cmd_detect)
294
+ with_path(sub.add_parser("init", help="Initialize UDS in a project safely (never overwrites)")).set_defaults(func=cmd_init)
295
+ with_path(sub.add_parser("status", help="Show UDS project status")).set_defaults(func=cmd_status)
296
+
297
+ p = with_path(sub.add_parser("scan", help="Run applicable security scanners and write reports"))
298
+ p.add_argument("--tool", action="append", choices=["gitleaks", "semgrep", "osv-scanner", "trivy"],
299
+ help="Run only this scanner (repeatable). Default: scanners enabled in config.")
300
+ p.add_argument("--report-dir", help="Directory for reports (default: PATH/.uds/reports)")
301
+ p.add_argument("--format", default="json,html", help="Comma-separated: json,html,sarif (default: json,html)")
302
+ p.add_argument("--allow-network", action="store_true",
303
+ help="Allow scanners that send package metadata to external services (OSV-Scanner)")
304
+ p.add_argument("--fail-on", choices=FAIL_ON_LEVELS, help="Exit 1 when findings reach this severity")
305
+ p.add_argument("--require-complete", action="store_true",
306
+ help="Exit 2 when an applicable scanner did not run (not installed or skipped)")
307
+ p.add_argument("--no-history", action="store_true", help="Do not record this scan in .uds/history")
308
+ p.add_argument("--json", action="store_true", help="Print a machine-readable summary")
309
+ p.add_argument("--quiet", action="store_true")
310
+ p.set_defaults(func=cmd_scan)
311
+
312
+ p = with_path(sub.add_parser("report", help="Regenerate reports from scan history"))
313
+ p.add_argument("--scan", default="latest", help="Scan id, 'latest' or 'previous'")
314
+ p.add_argument("--report-dir")
315
+ p.add_argument("--format", default="json,html")
316
+ p.set_defaults(func=cmd_report)
317
+
318
+ p = with_path(sub.add_parser("dashboard", help="Show (or open) the latest HTML dashboard"))
319
+ p.add_argument("--report-dir")
320
+ p.add_argument("--open", action="store_true", help="Open the dashboard in the default browser")
321
+ p.set_defaults(func=cmd_dashboard)
322
+
323
+ p = with_path(sub.add_parser("history", help="List previous scans"))
324
+ p.add_argument("--limit", type=int, default=20)
325
+ p.add_argument("--json", action="store_true")
326
+ p.set_defaults(func=cmd_history)
327
+
328
+ p = with_path(sub.add_parser("compare", help="Compare two scans from history"))
329
+ p.add_argument("--base", default="previous")
330
+ p.add_argument("--head", default="latest")
331
+ p.add_argument("--json", action="store_true")
332
+ p.set_defaults(func=cmd_compare)
333
+
334
+ p = sub.add_parser("policy", help="Show or test the effective security policy")
335
+ psub = p.add_subparsers(dest="policy_command", required=True)
336
+ with_path(psub.add_parser("show", help="Print global rules and project policy")).set_defaults(func=cmd_policy)
337
+ pc = with_path(psub.add_parser("check", help="Evaluate a command or file write against the policy"))
338
+ group = pc.add_mutually_exclusive_group(required=True)
339
+ group.add_argument("--command", dest="command_text", help="Shell command to evaluate")
340
+ group.add_argument("--write", help="Relative path of a file about to be written")
341
+ pc.add_argument("--content-file", help="File whose content would be written (with --write)")
342
+ pc.set_defaults(func=cmd_policy)
343
+
344
+ sub.add_parser("scanners", help="List supported scanners, versions and install sources").set_defaults(func=cmd_scanners)
345
+
346
+ from universal_dev_security import cli_extra
347
+ cli_extra.register(sub, with_path)
348
+ return parser
349
+
350
+
351
+ def main(argv: list[str] | None = None) -> int:
352
+ if os.name == "nt":
353
+ for stream in (sys.stdout, sys.stderr):
354
+ try:
355
+ stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
356
+ except (AttributeError, ValueError):
357
+ pass
358
+ args = build_parser().parse_args(argv)
359
+ try:
360
+ return int(args.func(args))
361
+ except ConfigError as exc:
362
+ _err(str(exc))
363
+ return EXIT_ERROR
364
+ except (ValueError, FileNotFoundError, NotADirectoryError) as exc:
365
+ _err(str(exc))
366
+ return EXIT_ERROR
367
+ except PermissionError as exc:
368
+ _err(f"Permission denied: {exc.filename or ''}")
369
+ return EXIT_ERROR
370
+ except KeyboardInterrupt:
371
+ _err("Interrupted.")
372
+ return 130
373
+
374
+
375
+ if __name__ == "__main__":
376
+ raise SystemExit(main())
@@ -0,0 +1,244 @@
1
+ """CLI commands for integrations, Git protection, recovery, fixes, testing and updates."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Any, Callable
9
+
10
+ EXIT_OK, EXIT_FINDINGS, EXIT_ERROR = 0, 1, 2
11
+
12
+
13
+ def _out(data: Any) -> None:
14
+ print(json.dumps(data, indent=2, ensure_ascii=False))
15
+
16
+
17
+ def _project(path: str) -> Path:
18
+ target = Path(path).expanduser().resolve()
19
+ if not target.is_dir():
20
+ raise ValueError(f"Not an existing directory: {target}")
21
+ return target
22
+
23
+
24
+ def _print_plan(result: dict[str, Any], apply: bool) -> None:
25
+ if not result["changes"]:
26
+ print("No changes needed.")
27
+ for change in result["changes"]:
28
+ print(f"{change['action'].upper()}: {change['path']}")
29
+ if not apply and change.get("diff"):
30
+ print(change["diff"])
31
+ if apply and result.get("checkpoint"):
32
+ print(f"Applied. Recovery checkpoint: {result['checkpoint']} (undo with `uds rollback {result['checkpoint']}`)")
33
+ elif not apply and result["changes"]:
34
+ print("Dry run only. Re-run with --apply to make these changes.")
35
+ for note in result.get("notes", []):
36
+ print(f"note: {note}")
37
+ for conflict in result.get("conflicts", []):
38
+ print(f"conflict: {conflict['path']}: {conflict['message']}")
39
+
40
+
41
+ # -- hooks runtime -----------------------------------------------------------
42
+
43
+ def cmd_hook(args: argparse.Namespace) -> int:
44
+ if args.hook_name == "claude-code":
45
+ from universal_dev_security.integrations.claude_code import run_hook
46
+
47
+ code, output = run_hook(sys.stdin.read())
48
+ if output:
49
+ print(output)
50
+ return code
51
+ from universal_dev_security import gitguard
52
+
53
+ try:
54
+ if args.hook_name == "git-pre-commit":
55
+ code, message = gitguard.pre_commit(Path.cwd())
56
+ else:
57
+ remote = args.hook_args[0] if args.hook_args else "origin"
58
+ code, message = gitguard.pre_push(Path.cwd(), remote, sys.stdin.read())
59
+ except Exception as exc: # noqa: BLE001 - fail closed
60
+ print(f"UDS: security check failed ({type(exc).__name__}); blocking to be safe.", file=sys.stderr)
61
+ return EXIT_FINDINGS
62
+ print(message, file=sys.stderr)
63
+ return code
64
+
65
+
66
+ def cmd_guard(args: argparse.Namespace) -> int:
67
+ from universal_dev_security.integrations.agents import guard
68
+
69
+ code, payload = guard(sys.stdin.read(), str(Path(args.project).expanduser().resolve()))
70
+ _out(payload)
71
+ return code
72
+
73
+
74
+ # -- installation commands ------------------------------------------------------
75
+
76
+ def cmd_hooks(args: argparse.Namespace) -> int:
77
+ from universal_dev_security import gitguard
78
+
79
+ root = _project(args.path)
80
+ try:
81
+ if args.hooks_command == "status":
82
+ _out(gitguard.hooks_status(root))
83
+ return EXIT_OK
84
+ python = sys.executable if args.python_path else None
85
+ result = gitguard.install_hooks(root, apply=args.apply, uninstall=args.hooks_command == "uninstall",
86
+ python=python)
87
+ except gitguard.GitError as exc:
88
+ print(f"uds: {exc}", file=sys.stderr)
89
+ return EXIT_ERROR
90
+ _print_plan(result, args.apply)
91
+ return EXIT_FINDINGS if result.get("conflicts") else EXIT_OK
92
+
93
+
94
+ def cmd_integrate(args: argparse.Namespace) -> int:
95
+ root = _project(args.path)
96
+ if args.target == "claude-code":
97
+ from universal_dev_security.integrations import claude_code
98
+
99
+ result = claude_code.install(root, apply=args.apply, remove=args.remove, local=args.local,
100
+ use_python_path=args.python_path)
101
+ elif args.target == "github":
102
+ from universal_dev_security.integrations import github
103
+
104
+ result = github.install(root, apply=args.apply, remove=args.remove, branch=args.branch,
105
+ fail_on=args.fail_on, uds_spec=args.uds_spec)
106
+ else:
107
+ from universal_dev_security.integrations import vscode
108
+
109
+ result = vscode.install(root, apply=args.apply, remove=args.remove)
110
+ if args.json:
111
+ _out(result)
112
+ else:
113
+ _print_plan(result, args.apply)
114
+ return EXIT_OK
115
+
116
+
117
+ # -- recovery ---------------------------------------------------------------------
118
+
119
+ def cmd_checkpoint(args: argparse.Namespace) -> int:
120
+ from universal_dev_security.recovery import RecoveryManager
121
+
122
+ manager = RecoveryManager(_project(args.path))
123
+ if args.checkpoint_command == "list":
124
+ _out(manager.list())
125
+ return EXIT_OK
126
+ checkpoint = manager.create(args.label or "manual checkpoint", args.files)
127
+ manager.seal(checkpoint)
128
+ _out({"checkpoint": checkpoint, "files": args.files})
129
+ return EXIT_OK
130
+
131
+
132
+ def cmd_rollback(args: argparse.Namespace) -> int:
133
+ from universal_dev_security.recovery import RecoveryManager
134
+
135
+ result = RecoveryManager(_project(args.path)).restore(args.checkpoint, force=args.force)
136
+ _out(result)
137
+ return EXIT_OK if result["status"] == "restored" else EXIT_FINDINGS
138
+
139
+
140
+ # -- fixes and tests ------------------------------------------------------------
141
+
142
+ def cmd_fix(args: argparse.Namespace) -> int:
143
+ from universal_dev_security.config import load_config
144
+ from universal_dev_security.fixes import apply_fixes, manual_recommendations, plan_fixes
145
+ from universal_dev_security.history import list_scans, load_scan
146
+
147
+ root = _project(args.path)
148
+ config = load_config(root)
149
+ latest = load_scan(root, "latest") if list_scans(root) else None
150
+ if not args.apply:
151
+ _out({"automatic_fixes": plan_fixes(root), "manual_recommendations": manual_recommendations(latest),
152
+ "note": "Dry run. Use --apply for low-risk fixes; add --include-medium for medium-risk fixes."})
153
+ return EXIT_OK
154
+ result = apply_fixes(root, config, include_medium=args.include_medium, run_test_suite=not args.skip_tests,
155
+ approved=args.approve)
156
+ _out(result)
157
+ return EXIT_OK if result["status"] in ("applied", "nothing_to_do") else EXIT_FINDINGS
158
+
159
+
160
+ def cmd_test(args: argparse.Namespace) -> int:
161
+ from universal_dev_security.config import load_config
162
+ from universal_dev_security.testing import run_tests
163
+
164
+ root = _project(args.path)
165
+ result = run_tests(root, load_config(root))
166
+ _out(result)
167
+ return {"passed": EXIT_OK, "not_configured": EXIT_FINDINGS}.get(result["status"], EXIT_ERROR)
168
+
169
+
170
+ # -- updates ------------------------------------------------------------------------
171
+
172
+ def cmd_update(args: argparse.Namespace) -> int:
173
+ from universal_dev_security.updates import check_uds_update, scanner_status
174
+
175
+ if args.update_command == "scanners":
176
+ _out(scanner_status())
177
+ return EXIT_OK
178
+ result = check_uds_update(args.source)
179
+ _out(result)
180
+ return EXIT_ERROR if result["status"] == "error" else EXIT_OK
181
+
182
+
183
+ def register(sub: Any, with_path: Callable[[argparse.ArgumentParser], argparse.ArgumentParser]) -> None:
184
+ p = sub.add_parser("hook", help="Hook entry points (called by Git and Claude Code)")
185
+ p.add_argument("hook_name", choices=["claude-code", "git-pre-commit", "git-pre-push"])
186
+ p.add_argument("hook_args", nargs="*")
187
+ p.set_defaults(func=cmd_hook)
188
+
189
+ p = sub.add_parser("guard", help="Policy decision for AI agents: JSON action on stdin, decision on stdout")
190
+ p.add_argument("--project", default=".", help="Project root (default: current directory)")
191
+ p.set_defaults(func=cmd_guard)
192
+
193
+ p = sub.add_parser("hooks", help="Install, remove or inspect UDS Git hooks")
194
+ hsub = p.add_subparsers(dest="hooks_command", required=True)
195
+ for name in ("install", "uninstall"):
196
+ hp = with_path(hsub.add_parser(name))
197
+ hp.add_argument("--apply", action="store_true", help="Make the change (default is a dry run)")
198
+ hp.add_argument("--python-path", action="store_true",
199
+ help="Embed this Python interpreter in the hook instead of relying on `uds` on PATH")
200
+ hp.set_defaults(func=cmd_hooks)
201
+ with_path(hsub.add_parser("status")).set_defaults(func=cmd_hooks, apply=False, python_path=False)
202
+
203
+ p = sub.add_parser("integrate", help="Set up Claude Code, GitHub Actions or VS Code integration")
204
+ p.add_argument("target", choices=["claude-code", "github", "vscode"])
205
+ with_path(p)
206
+ p.add_argument("--apply", action="store_true", help="Make the change (default is a dry run with a diff)")
207
+ p.add_argument("--remove", action="store_true", help="Remove UDS-owned configuration only")
208
+ p.add_argument("--local", action="store_true", help="Claude Code: write .claude/settings.local.json")
209
+ p.add_argument("--python-path", action="store_true", help="Claude Code: embed this interpreter path in the hook")
210
+ p.add_argument("--branch", default="main", help="GitHub: default branch for push scans")
211
+ p.add_argument("--fail-on", default="high", help="GitHub: severity threshold that fails the check")
212
+ p.add_argument("--uds-spec", default="universal-dev-security", help="GitHub: pip install spec for UDS")
213
+ p.add_argument("--json", action="store_true")
214
+ p.set_defaults(func=cmd_integrate)
215
+
216
+ p = sub.add_parser("checkpoint", help="Create or list recovery checkpoints")
217
+ csub = p.add_subparsers(dest="checkpoint_command", required=True)
218
+ cp = with_path(csub.add_parser("create"))
219
+ cp.add_argument("--label")
220
+ cp.add_argument("--file", dest="files", action="append", required=True, help="Project-relative file (repeatable)")
221
+ cp.set_defaults(func=cmd_checkpoint)
222
+ with_path(csub.add_parser("list")).set_defaults(func=cmd_checkpoint)
223
+
224
+ p = sub.add_parser("rollback", help="Restore a recovery checkpoint (keeps later user edits unless --force)")
225
+ p.add_argument("checkpoint")
226
+ with_path(p)
227
+ p.add_argument("--force", action="store_true", help="Also overwrite files changed after the checkpoint")
228
+ p.set_defaults(func=cmd_rollback)
229
+
230
+ p = with_path(sub.add_parser("fix", help="Plan or apply safe, reversible automatic fixes"))
231
+ p.add_argument("--apply", action="store_true")
232
+ p.add_argument("--include-medium", action="store_true")
233
+ p.add_argument("--skip-tests", action="store_true", help="Do not run tests before/after fixing")
234
+ p.add_argument("--approve", action="store_true", help="Explicit approval when automatic fixes are disabled by config")
235
+ p.set_defaults(func=cmd_fix)
236
+
237
+ with_path(sub.add_parser("test", help="Detect and run the project's test suite")).set_defaults(func=cmd_test)
238
+
239
+ p = sub.add_parser("update", help="Check for UDS updates or show scanner versions (never installs)")
240
+ usub = p.add_subparsers(dest="update_command", required=True)
241
+ up = usub.add_parser("check")
242
+ up.add_argument("--source", help="'pypi' or 'github:OWNER/REPO' (network request only when given)")
243
+ up.set_defaults(func=cmd_update)
244
+ usub.add_parser("scanners").set_defaults(func=cmd_update)