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,149 @@
1
+ """Local scan history and scan-to-scan comparison."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from universal_dev_security.findings import SEVERITY_ORDER
10
+
11
+ SCAN_ID_RE = re.compile(r"^\d{8}T\d{6}(?:\d{6})?Z-[0-9a-f]{6}$")
12
+ UDS_GITIGNORE = "# Generated by UDS.\nreports/\nhistory/\nbackups/\ncache/\n"
13
+
14
+
15
+ def ensure_uds_dir(root: Path) -> Path:
16
+ """Create ``.uds/`` with a protective .gitignore (never overwriting one)."""
17
+ uds = root / ".uds"
18
+ uds.mkdir(exist_ok=True)
19
+ ignore = uds / ".gitignore"
20
+ if not ignore.exists():
21
+ try:
22
+ with ignore.open("x", encoding="utf-8", newline="\n") as handle:
23
+ handle.write(UDS_GITIGNORE)
24
+ except FileExistsError:
25
+ pass
26
+ return uds
27
+
28
+
29
+ def history_dir(root: Path) -> Path:
30
+ return root.expanduser().resolve() / ".uds" / "history"
31
+
32
+
33
+ def save_scan(root: Path, scan: dict[str, Any], limit: int = 50) -> Path:
34
+ scan_id = str(scan.get("scan_id", ""))
35
+ if not SCAN_ID_RE.match(scan_id):
36
+ raise ValueError("Scan has an invalid scan_id")
37
+ ensure_uds_dir(root.expanduser().resolve())
38
+ directory = history_dir(root)
39
+ directory.mkdir(parents=True, exist_ok=True)
40
+ path = directory / f"{scan_id}.json"
41
+ path.write_text(json.dumps(scan, indent=2, ensure_ascii=False) + "\n", encoding="utf-8", newline="\n")
42
+ prune(root, limit)
43
+ return path
44
+
45
+
46
+ def list_scans(root: Path) -> list[str]:
47
+ directory = history_dir(root)
48
+ if not directory.is_dir():
49
+ return []
50
+ return sorted(p.stem for p in directory.glob("*.json") if SCAN_ID_RE.match(p.stem))
51
+
52
+
53
+ def prune(root: Path, limit: int) -> list[str]:
54
+ scans = list_scans(root)
55
+ removed = scans[:-limit] if limit > 0 and len(scans) > limit else []
56
+ for scan_id in removed:
57
+ (history_dir(root) / f"{scan_id}.json").unlink(missing_ok=True)
58
+ return removed
59
+
60
+
61
+ def resolve_scan_id(root: Path, reference: str) -> str:
62
+ scans = list_scans(root)
63
+ if not scans:
64
+ raise ValueError("No scan history found. Run `uds scan` first.")
65
+ if reference == "latest":
66
+ return scans[-1]
67
+ if reference == "previous":
68
+ if len(scans) < 2:
69
+ raise ValueError("Only one scan in history; nothing to compare with.")
70
+ return scans[-2]
71
+ if not SCAN_ID_RE.match(reference): # also prevents path traversal
72
+ raise ValueError(f"Invalid scan id: {reference!r}")
73
+ if reference not in scans:
74
+ raise ValueError(f"Scan not found in history: {reference}")
75
+ return reference
76
+
77
+
78
+ def load_scan(root: Path, reference: str) -> dict[str, Any]:
79
+ scan_id = resolve_scan_id(root, reference)
80
+ data = json.loads((history_dir(root) / f"{scan_id}.json").read_text(encoding="utf-8"))
81
+ if not isinstance(data, dict):
82
+ raise ValueError("History entry is not a scan object")
83
+ return data
84
+
85
+
86
+ def summarize(scan: dict[str, Any]) -> dict[str, Any]:
87
+ summary = scan.get("summary", {})
88
+ return {
89
+ "scan_id": scan.get("scan_id"),
90
+ "started_at": scan.get("started_at"),
91
+ "overall_status": scan.get("overall_status"),
92
+ "finding_count": summary.get("finding_count", 0),
93
+ "by_severity": summary.get("by_severity", {}),
94
+ "executed": scan.get("coverage", {}).get("executed", []),
95
+ }
96
+
97
+
98
+ def history_summaries(root: Path, limit: int = 20) -> list[dict[str, Any]]:
99
+ result = []
100
+ for scan_id in list_scans(root)[-limit:]:
101
+ try:
102
+ result.append(summarize(load_scan(root, scan_id)))
103
+ except (OSError, ValueError):
104
+ continue
105
+ return result
106
+
107
+
108
+ def compare_scans(base: dict[str, Any], current: dict[str, Any]) -> dict[str, Any]:
109
+ """Compare findings by fingerprint, only across scanners executed in both scans."""
110
+ base_tools = set(base.get("coverage", {}).get("executed", []))
111
+ current_tools = set(current.get("coverage", {}).get("executed", []))
112
+ common = base_tools & current_tools
113
+ base_findings = {f["fingerprint"]: f for f in base.get("findings", []) if f.get("tool") in common}
114
+ current_findings = {f["fingerprint"]: f for f in current.get("findings", []) if f.get("tool") in common}
115
+ not_rescanned = [f for f in base.get("findings", []) if f.get("tool") not in current_tools]
116
+ newly_covered = [f for f in current.get("findings", []) if f.get("tool") not in base_tools]
117
+ new = [current_findings[k] for k in current_findings if k not in base_findings]
118
+ resolved = [base_findings[k] for k in base_findings if k not in current_findings]
119
+ unchanged = [current_findings[k] for k in current_findings if k in base_findings]
120
+
121
+ def by_severity(items: list[dict[str, Any]]) -> dict[str, int]:
122
+ counts = {s.value: 0 for s in SEVERITY_ORDER}
123
+ for item in items:
124
+ counts[item.get("severity", "unclassified")] = counts.get(item.get("severity", "unclassified"), 0) + 1
125
+ return counts
126
+
127
+ base_scope = base.get("options", {}).get("scope_fingerprint")
128
+ current_scope = current.get("options", {}).get("scope_fingerprint")
129
+ scope_changed = base_scope is None or base_scope != current_scope
130
+ warnings = []
131
+ if scope_changed and resolved:
132
+ warnings.append("The scan scope changed between these scans (UDS exclusions or configured exclude_paths). "
133
+ "Findings listed as resolved may have been excluded rather than fixed.")
134
+ return {
135
+ "base_scan_id": base.get("scan_id"),
136
+ "current_scan_id": current.get("scan_id"),
137
+ "scope_changed": scope_changed,
138
+ "warnings": warnings,
139
+ "compared_tools": sorted(common),
140
+ "new": new,
141
+ "resolved": resolved,
142
+ "unchanged_count": len(unchanged),
143
+ "not_rescanned": not_rescanned,
144
+ "newly_covered": newly_covered,
145
+ "new_by_severity": by_severity(new),
146
+ "resolved_by_severity": by_severity(resolved),
147
+ "note": "Findings are compared only for scanners that executed in both scans; findings from scanners "
148
+ "that did not run again are listed as not re-scanned, never as resolved.",
149
+ }
@@ -0,0 +1,112 @@
1
+ """Safe, non-destructive project initialization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from universal_dev_security.detector import detect_project
8
+
9
+ DEFAULT_CONFIG = (
10
+ "# Universal Dev Security project configuration\n"
11
+ "# Never store credentials, tokens, or private keys in this file.\n\n"
12
+ "[project]\n"
13
+ 'name = "my-project"\n'
14
+ 'profile = "auto"\n\n'
15
+ "[policy]\n"
16
+ 'security_mode = "balanced"\n'
17
+ "allow_automatic_low_risk_fixes = true\n"
18
+ "require_user_approval_for_high_risk_changes = true\n"
19
+ "require_user_approval_for_git_push = true\n"
20
+ "# Exit non-zero from `uds scan` when findings reach this severity:\n"
21
+ "# none | unclassified | low | medium | high | critical\n"
22
+ 'fail_on = "none"\n'
23
+ "# Extra restrictions (regular expressions). Global rules cannot be removed.\n"
24
+ "deny_command_patterns = []\n"
25
+ "approval_command_patterns = []\n"
26
+ "protected_paths = []\n\n"
27
+ "[scanners]\n"
28
+ "# gitleaks is mandatory; semgrep, osv-scanner and trivy run when installed and applicable.\n"
29
+ 'enabled = ["gitleaks", "semgrep", "osv-scanner", "trivy"]\n'
30
+ "# OSV-Scanner sends package names and versions (not source code) to osv.dev.\n"
31
+ "# It is skipped unless this is true or `uds scan --allow-network` is used.\n"
32
+ "allow_network = false\n"
33
+ "# Empty = profile-based Semgrep Registry packs (rules are downloaded; metrics are off).\n"
34
+ "semgrep_configs = []\n"
35
+ "timeout_seconds = 600\n"
36
+ "exclude_paths = []\n\n"
37
+ "[testing]\n"
38
+ "# Command run by `uds test` and after automatic fixes, e.g. [\"pytest\", \"-q\"].\n"
39
+ "# Empty = auto-detect.\n"
40
+ "command = []\n\n"
41
+ "[reporting]\n"
42
+ "terminal = true\n"
43
+ "file_reports = true\n\n"
44
+ "[history]\n"
45
+ "limit = 50\n"
46
+ )
47
+
48
+ PROJECT_README = (
49
+ "# UDS project configuration\n\n"
50
+ "This directory contains local Universal Dev Security configuration.\n\n"
51
+ "- `config.toml`: project-level preferences.\n"
52
+ "- Never store passwords, API keys, tokens, private keys, or personal data here.\n"
53
+ "- Mandatory global policies cannot be weakened by project settings.\n"
54
+ "- `reports/`, `history/`, `backups/`: generated by UDS; excluded from Git.\n\n"
55
+ "Run `uds scan` to scan, `uds dashboard` to open the latest report, and\n"
56
+ "`uds policy show` to see the effective security policy.\n"
57
+ )
58
+
59
+ UDS_GITIGNORE = (
60
+ "# Generated by UDS. Reports and backups can reference sensitive file paths.\n"
61
+ "reports/\n"
62
+ "history/\n"
63
+ "backups/\n"
64
+ "cache/\n"
65
+ )
66
+
67
+
68
+ def initialize_project(path: Path) -> dict:
69
+ target = path.expanduser().resolve()
70
+ if not target.exists() or not target.is_dir():
71
+ return {"status": "error", "reason": "target_directory_not_found"}
72
+
73
+ uds_dir = target / ".uds"
74
+ config_path = uds_dir / "config.toml"
75
+ readme_path = uds_dir / "README.md"
76
+
77
+ if config_path.exists() or readme_path.exists():
78
+ return {
79
+ "status": "already_initialized",
80
+ "path": str(target),
81
+ "message": "Existing UDS files were left unchanged.",
82
+ }
83
+
84
+ detected = detect_project(target)["detected"]
85
+ created: list[str] = []
86
+ try:
87
+ uds_dir.mkdir(exist_ok=True)
88
+ with config_path.open("x", encoding="utf-8", newline="\n") as handle:
89
+ handle.write(DEFAULT_CONFIG)
90
+ created.append(".uds/config.toml")
91
+ with readme_path.open("x", encoding="utf-8", newline="\n") as handle:
92
+ handle.write(PROJECT_README)
93
+ created.append(".uds/README.md")
94
+ gitignore_path = uds_dir / ".gitignore"
95
+ if not gitignore_path.exists():
96
+ with gitignore_path.open("x", encoding="utf-8", newline="\n") as handle:
97
+ handle.write(UDS_GITIGNORE)
98
+ created.append(".uds/.gitignore")
99
+ except FileExistsError:
100
+ return {
101
+ "status": "already_initialized",
102
+ "path": str(target),
103
+ "message": "An existing file was detected; no overwrite was performed.",
104
+ }
105
+
106
+ return {
107
+ "status": "initialized",
108
+ "path": str(target),
109
+ "detected": detected,
110
+ "created": created,
111
+ "message": "Configuration created. No scanners or global settings were changed.",
112
+ }
@@ -0,0 +1,46 @@
1
+ """Integrations with AI coding tools, Git hosting and editors.
2
+
3
+ All integrations follow the same safety contract:
4
+ * dry-run by default — show the planned change and a diff;
5
+ * ``apply=True`` creates a recovery checkpoint first, then writes atomically;
6
+ * existing user configuration is merged, never silently overwritten;
7
+ * removal only deletes UDS-owned entries.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import difflib
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from universal_dev_security.recovery import RecoveryManager, resolve_inside, safe_write
16
+
17
+
18
+ def apply_changes(root: Path, changes: dict[str, str | None], label: str, apply: bool) -> dict[str, Any]:
19
+ """Plan (and optionally apply) file changes. ``None`` content deletes a file."""
20
+ root = root.expanduser().resolve()
21
+ planned = []
22
+ for relative, new in changes.items():
23
+ path = resolve_inside(root, relative)
24
+ old = path.read_text(encoding="utf-8") if path.is_file() else None
25
+ if old == new:
26
+ continue
27
+ diff = "".join(difflib.unified_diff(
28
+ (old or "").splitlines(keepends=True), (new or "").splitlines(keepends=True),
29
+ fromfile=f"a/{relative}", tofile=f"b/{relative}"))
30
+ planned.append({"path": relative, "action": "delete" if new is None else ("update" if old is not None else "create"),
31
+ "diff": diff})
32
+ result: dict[str, Any] = {"applied": False, "changes": planned, "checkpoint": None}
33
+ if not apply or not planned:
34
+ result["applied"] = apply and not planned
35
+ return result
36
+ manager = RecoveryManager(root)
37
+ checkpoint = manager.create(label, [c["path"] for c in planned])
38
+ for change in planned:
39
+ content = changes[change["path"]]
40
+ if content is None:
41
+ resolve_inside(root, change["path"]).unlink(missing_ok=True)
42
+ else:
43
+ safe_write(root, change["path"], content)
44
+ manager.seal(checkpoint)
45
+ result.update(applied=True, checkpoint=checkpoint)
46
+ return result
@@ -0,0 +1,45 @@
1
+ """Generic AI-agent integration interface (`uds guard`).
2
+
3
+ Any coding agent can ask UDS for a decision before acting by piping a JSON
4
+ object to ``uds guard`` (see docs/integrations.md)::
5
+
6
+ {"action": "shell", "command": "git push origin main", "project_root": "/repo"}
7
+ {"action": "write_file", "path": "src/app.py", "content": "...", "project_root": "/repo"}
8
+
9
+ Response (stdout)::
10
+
11
+ {"decision": "allow"|"ask"|"deny", "risk": "...", "reason": "...", "matches": [...]}
12
+
13
+ Exit codes: 0 allow, 1 ask (obtain explicit user approval), 2 deny, 3 invalid request.
14
+ Agents must treat any non-zero exit or unparsable output as "do not proceed".
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from universal_dev_security.config import ConfigError, load_config
23
+ from universal_dev_security.policy import Action, PolicyEngine
24
+
25
+ EXIT_CODES = {"allow": 0, "ask": 1, "deny": 2}
26
+ MAX_REQUEST_BYTES = 5 * 1024 * 1024
27
+
28
+
29
+ def guard(request_text: str, default_root: str) -> tuple[int, dict[str, Any]]:
30
+ if len(request_text.encode("utf-8", errors="replace")) > MAX_REQUEST_BYTES:
31
+ return 3, {"decision": "deny", "reason": "Request too large."}
32
+ try:
33
+ data = json.loads(request_text)
34
+ if isinstance(data, dict) and not data.get("project_root"):
35
+ data["project_root"] = default_root
36
+ action = Action.from_dict(data)
37
+ except (ValueError, TypeError) as exc:
38
+ return 3, {"decision": "deny", "reason": f"Invalid request: {exc}"}
39
+ try:
40
+ config = load_config(Path(action.project_root))
41
+ except ConfigError:
42
+ return 1, {"decision": "ask", "risk": "high", "reason": "UDS configuration could not be parsed; approval required."}
43
+ decision = PolicyEngine(config).evaluate(action)
44
+ payload = decision.to_dict()
45
+ return EXIT_CODES[decision.decision.value], payload
@@ -0,0 +1,192 @@
1
+ """Claude Code integration: PreToolUse hooks that enforce the UDS policy.
2
+
3
+ Installed configuration (merged into ``.claude/settings.json``)::
4
+
5
+ {"hooks": {"PreToolUse": [{"matcher": "<tools>", "hooks": [{"type": "command",
6
+ "command": "uds hook claude-code", "timeout": 30}]}]},
7
+ "permissions": {"ask": ["Bash(git push:*)"]}}
8
+
9
+ Hook protocol (Claude Code): the tool call is received as JSON on stdin. To
10
+ require approval or deny, the hook prints
11
+ ``{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision":
12
+ "ask"|"deny", "permissionDecisionReason": "..."}}`` and exits 0. When UDS has
13
+ no objection it prints nothing, so Claude Code's normal permission flow still
14
+ applies (UDS never auto-approves anything).
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import re
21
+ import sys
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from universal_dev_security.config import load_config
26
+ from universal_dev_security.integrations import apply_changes
27
+ from universal_dev_security.policy import Action, ActionKind, Decision, PolicyEngine
28
+
29
+ SETTINGS_PATH = ".claude/settings.json"
30
+ LOCAL_SETTINGS_PATH = ".claude/settings.local.json"
31
+ HOOK_MARKER = "hook claude-code"
32
+ MATCHER = "Bash|PowerShell|Write|Edit|MultiEdit|NotebookEdit|Read"
33
+ ASK_RULES = ["Bash(git push:*)", "PowerShell(git push:*)"]
34
+ _SAFE_PATH_RE = re.compile(r"^[\w\s.:\\/()+-]+$")
35
+
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Hook runtime
39
+ # ---------------------------------------------------------------------------
40
+
41
+ def _action_from_hook(payload: dict[str, Any], project_root: str) -> Action | None:
42
+ tool = str(payload.get("tool_name") or "")
43
+ data = payload.get("tool_input") if isinstance(payload.get("tool_input"), dict) else {}
44
+ if tool in ("Bash", "PowerShell"):
45
+ return Action(ActionKind.SHELL, command=str(data.get("command") or ""), project_root=project_root)
46
+ if tool == "Write":
47
+ return Action(ActionKind.WRITE_FILE, path=str(data.get("file_path") or ""),
48
+ content=str(data.get("content") or ""), project_root=project_root)
49
+ if tool == "Edit":
50
+ return Action(ActionKind.EDIT_FILE, path=str(data.get("file_path") or ""),
51
+ content=str(data.get("new_string") or ""), project_root=project_root)
52
+ if tool == "MultiEdit":
53
+ edits = data.get("edits") if isinstance(data.get("edits"), list) else []
54
+ content = "\n".join(str(e.get("new_string") or "") for e in edits if isinstance(e, dict))
55
+ return Action(ActionKind.EDIT_FILE, path=str(data.get("file_path") or ""), content=content,
56
+ project_root=project_root)
57
+ if tool == "NotebookEdit":
58
+ return Action(ActionKind.EDIT_FILE, path=str(data.get("notebook_path") or ""),
59
+ content=str(data.get("new_source") or ""), project_root=project_root)
60
+ if tool == "Read":
61
+ return Action(ActionKind.READ_FILE, path=str(data.get("file_path") or ""), project_root=project_root)
62
+ return None
63
+
64
+
65
+ def _decision_output(decision: str, reason: str) -> dict[str, Any]:
66
+ return {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": decision,
67
+ "permissionDecisionReason": f"UDS: {reason}"[:2000]}}
68
+
69
+
70
+ def handle_hook(payload: Any) -> dict[str, Any] | None:
71
+ """Evaluate a Claude Code PreToolUse payload. Returns hook output or None (no objection)."""
72
+ if not isinstance(payload, dict):
73
+ return _decision_output("ask", "Unrecognized hook input; approval required.")
74
+ if payload.get("hook_event_name") not in (None, "PreToolUse"):
75
+ return None
76
+ project_root = os.environ.get("CLAUDE_PROJECT_DIR") or str(payload.get("cwd") or os.getcwd())
77
+ action = _action_from_hook(payload, project_root)
78
+ if action is None:
79
+ return None
80
+ try:
81
+ config = load_config(Path(project_root))
82
+ except ValueError:
83
+ return _decision_output("ask", "The UDS project configuration could not be parsed; approval required.")
84
+ decision = PolicyEngine(config).evaluate(action)
85
+ if decision.decision is Decision.ALLOW:
86
+ return None
87
+ return _decision_output(decision.decision.value, decision.reason)
88
+
89
+
90
+ def run_hook(stdin_text: str) -> tuple[int, str]:
91
+ """Entry point used by `uds hook claude-code`. Fails closed (ask) on any error."""
92
+ try:
93
+ payload = json.loads(stdin_text or "{}")
94
+ output = handle_hook(payload)
95
+ except Exception: # noqa: BLE001 - a crashing guard must never silently allow
96
+ output = _decision_output("ask", "Internal UDS hook error; approval required.")
97
+ return 0, json.dumps(output) if output else ""
98
+
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # Installation
102
+ # ---------------------------------------------------------------------------
103
+
104
+ def hook_command(use_python_path: bool) -> str:
105
+ if use_python_path:
106
+ python = sys.executable
107
+ if not _SAFE_PATH_RE.match(python):
108
+ raise ValueError("The Python interpreter path contains characters that cannot be embedded safely.")
109
+ return f'"{python}" -m universal_dev_security {HOOK_MARKER}'
110
+ return f"uds {HOOK_MARKER}"
111
+
112
+
113
+ def _is_uds_hook(entry: Any) -> bool:
114
+ return isinstance(entry, dict) and any(
115
+ isinstance(h, dict) and HOOK_MARKER in str(h.get("command", "")) for h in entry.get("hooks", []) or [])
116
+
117
+
118
+ def merge_settings(existing: dict[str, Any], command: str) -> dict[str, Any]:
119
+ settings = json.loads(json.dumps(existing)) # deep copy
120
+ hooks = settings.setdefault("hooks", {})
121
+ if not isinstance(hooks, dict):
122
+ raise ValueError("Existing 'hooks' setting is not an object; refusing to modify it.")
123
+ pre = hooks.setdefault("PreToolUse", [])
124
+ if not isinstance(pre, list):
125
+ raise ValueError("Existing 'hooks.PreToolUse' is not a list; refusing to modify it.")
126
+ pre[:] = [entry for entry in pre if not _is_uds_hook(entry)]
127
+ pre.append({"matcher": MATCHER, "hooks": [{"type": "command", "command": command, "timeout": 30}]})
128
+ permissions = settings.setdefault("permissions", {})
129
+ if not isinstance(permissions, dict):
130
+ raise ValueError("Existing 'permissions' setting is not an object; refusing to modify it.")
131
+ ask = permissions.setdefault("ask", [])
132
+ if not isinstance(ask, list):
133
+ raise ValueError("Existing 'permissions.ask' is not a list; refusing to modify it.")
134
+ for rule in ASK_RULES:
135
+ if rule not in ask:
136
+ ask.append(rule)
137
+ return settings
138
+
139
+
140
+ def remove_from_settings(existing: dict[str, Any]) -> dict[str, Any]:
141
+ settings = json.loads(json.dumps(existing))
142
+ hooks = settings.get("hooks")
143
+ if isinstance(hooks, dict) and isinstance(hooks.get("PreToolUse"), list):
144
+ hooks["PreToolUse"] = [e for e in hooks["PreToolUse"] if not _is_uds_hook(e)]
145
+ if not hooks["PreToolUse"]:
146
+ del hooks["PreToolUse"]
147
+ if not hooks:
148
+ del settings["hooks"]
149
+ permissions = settings.get("permissions")
150
+ if isinstance(permissions, dict) and isinstance(permissions.get("ask"), list):
151
+ permissions["ask"] = [r for r in permissions["ask"] if r not in ASK_RULES]
152
+ if not permissions["ask"]:
153
+ del permissions["ask"]
154
+ if not permissions:
155
+ del settings["permissions"]
156
+ return settings
157
+
158
+
159
+ def install(root: Path, *, apply: bool = False, remove: bool = False, local: bool = False,
160
+ use_python_path: bool = False) -> dict[str, Any]:
161
+ root = root.expanduser().resolve()
162
+ relative = LOCAL_SETTINGS_PATH if local else SETTINGS_PATH
163
+ path = root / relative
164
+ existing: dict[str, Any] = {}
165
+ if path.is_file():
166
+ try:
167
+ existing = json.loads(path.read_text(encoding="utf-8") or "{}")
168
+ except ValueError as exc:
169
+ raise ValueError(f"{relative} is not valid JSON; refusing to modify it. Fix it manually first.") from exc
170
+ if not isinstance(existing, dict):
171
+ raise ValueError(f"{relative} is not a JSON object; refusing to modify it.")
172
+ if remove:
173
+ updated = remove_from_settings(existing)
174
+ new_content: str | None = json.dumps(updated, indent=2) + "\n" if (updated or path.is_file()) else None
175
+ else:
176
+ updated = merge_settings(existing, hook_command(use_python_path))
177
+ new_content = json.dumps(updated, indent=2) + "\n"
178
+ result = apply_changes(root, {relative: new_content}, f"claude-code integration ({'remove' if remove else 'install'})", apply)
179
+ result["settings_file"] = relative
180
+ if not remove:
181
+ location = (f"The hook runs this Python interpreter directly ({sys.executable}); keep that environment "
182
+ "installed, and prefer --local because the path is machine-specific."
183
+ if use_python_path else
184
+ "The hook runs `uds` from PATH; if it is not on PATH the hook cannot run. Use --python-path to "
185
+ "embed this interpreter (recommended together with --local, because absolute paths are "
186
+ "machine-specific).")
187
+ result["notes"] = [
188
+ location,
189
+ "The hook only adds 'ask'/'deny' decisions; it never auto-approves tool calls.",
190
+ "`git push` additionally requires approval through permissions.ask as defense in depth.",
191
+ ]
192
+ return result