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.
- universal_dev_security/__init__.py +3 -0
- universal_dev_security/__main__.py +4 -0
- universal_dev_security/cli.py +376 -0
- universal_dev_security/cli_extra.py +244 -0
- universal_dev_security/config.py +171 -0
- universal_dev_security/detector.py +201 -0
- universal_dev_security/engine.py +203 -0
- universal_dev_security/findings.py +199 -0
- universal_dev_security/fixes.py +138 -0
- universal_dev_security/gitguard.py +269 -0
- universal_dev_security/history.py +149 -0
- universal_dev_security/initializer.py +112 -0
- universal_dev_security/integrations/__init__.py +46 -0
- universal_dev_security/integrations/agents.py +45 -0
- universal_dev_security/integrations/claude_code.py +192 -0
- universal_dev_security/integrations/github.py +149 -0
- universal_dev_security/integrations/vscode.py +44 -0
- universal_dev_security/policy.py +384 -0
- universal_dev_security/projectfiles.py +80 -0
- universal_dev_security/recovery.py +168 -0
- universal_dev_security/redaction.py +132 -0
- universal_dev_security/reports.py +466 -0
- universal_dev_security/scanner.py +36 -0
- universal_dev_security/scanners/__init__.py +36 -0
- universal_dev_security/scanners/base.py +269 -0
- universal_dev_security/scanners/gitleaks.py +132 -0
- universal_dev_security/scanners/osv.py +156 -0
- universal_dev_security/scanners/semgrep.py +130 -0
- universal_dev_security/scanners/trivy.py +139 -0
- universal_dev_security/testing.py +100 -0
- universal_dev_security/updates.py +84 -0
- universal_dev_security-1.0.0.dist-info/METADATA +128 -0
- universal_dev_security-1.0.0.dist-info/RECORD +37 -0
- universal_dev_security-1.0.0.dist-info/WHEEL +5 -0
- universal_dev_security-1.0.0.dist-info/entry_points.txt +2 -0
- universal_dev_security-1.0.0.dist-info/licenses/LICENSE +163 -0
- 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
|