sin-code-bundle 0.9.2__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 (41) hide show
  1. sin_code_bundle/__init__.py +6 -0
  2. sin_code_bundle/agents_md.py +245 -0
  3. sin_code_bundle/ast_edit.py +323 -0
  4. sin_code_bundle/bench.py +506 -0
  5. sin_code_bundle/budget.py +51 -0
  6. sin_code_bundle/cache.py +131 -0
  7. sin_code_bundle/checkpoint.py +230 -0
  8. sin_code_bundle/cli.py +1943 -0
  9. sin_code_bundle/codocs.py +328 -0
  10. sin_code_bundle/dap_bridge.py +135 -0
  11. sin_code_bundle/data/codocs/SKILL.md +280 -0
  12. sin_code_bundle/gitnexus.py +368 -0
  13. sin_code_bundle/hashline.py +216 -0
  14. sin_code_bundle/hooks.py +249 -0
  15. sin_code_bundle/immortal_commit.py +288 -0
  16. sin_code_bundle/interceptor.py +119 -0
  17. sin_code_bundle/lsp_backend.py +303 -0
  18. sin_code_bundle/lsp_bootstrap.py +85 -0
  19. sin_code_bundle/markitdown.py +254 -0
  20. sin_code_bundle/mcp_config.py +455 -0
  21. sin_code_bundle/mcp_server.py +963 -0
  22. sin_code_bundle/memory.py +208 -0
  23. sin_code_bundle/merge_safety.py +313 -0
  24. sin_code_bundle/orchestration_worktrees.py +102 -0
  25. sin_code_bundle/policy.py +224 -0
  26. sin_code_bundle/preflight.py +152 -0
  27. sin_code_bundle/programming_workflow.py +541 -0
  28. sin_code_bundle/rtk.py +154 -0
  29. sin_code_bundle/safety.py +52 -0
  30. sin_code_bundle/session_warmup.py +247 -0
  31. sin_code_bundle/skills.py +188 -0
  32. sin_code_bundle/symbol_resolve.py +166 -0
  33. sin_code_bundle/tools/__init__.py +4 -0
  34. sin_code_bundle/tools/pypi_setup.py +289 -0
  35. sin_code_bundle/vfs.py +264 -0
  36. sin_code_bundle-0.9.2.dist-info/METADATA +470 -0
  37. sin_code_bundle-0.9.2.dist-info/RECORD +41 -0
  38. sin_code_bundle-0.9.2.dist-info/WHEEL +5 -0
  39. sin_code_bundle-0.9.2.dist-info/entry_points.txt +4 -0
  40. sin_code_bundle-0.9.2.dist-info/licenses/LICENSE +21 -0
  41. sin_code_bundle-0.9.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,224 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Risk-gating, approval, and tamper-evident audit logging for SIN tools.
3
+
4
+ MCP has no native access control. This module wraps every tool execution with:
5
+ - a per-tool risk classification (read | write | exec | network)
6
+ - a configurable policy (allow | ask | deny) per risk class
7
+ - an append-only, hash-chained audit log under .sin/audit/log.jsonl
8
+ - path sandboxing helpers so tools cannot read/write outside the project root
9
+
10
+ Policy is loaded from .sin/policy.yaml (falls back to safe defaults).
11
+
12
+ Docs: policy.doc.md
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import json
19
+ import os
20
+ import time
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+ from typing import Callable, Literal, Optional
24
+
25
+ try:
26
+ import yaml
27
+ except ImportError: # pragma: no cover
28
+ yaml = None # type: ignore
29
+
30
+ RiskClass = Literal["read", "write", "exec", "network"]
31
+ Decision = Literal["allow", "ask", "deny"]
32
+
33
+ # ── Tool risk classification ─────────────────────────────────────────
34
+ # New MCP tools must be added here so the policy engine can rate them.
35
+ TOOL_RISK: dict[str, RiskClass] = {
36
+ "impact": "read",
37
+ "semantic_diff": "read",
38
+ "semantic_review": "read",
39
+ "architectural_debt": "read",
40
+ "prove": "read",
41
+ "verify_tests": "exec",
42
+ "mock_env": "network",
43
+ }
44
+
45
+ # Safe defaults: reads are silent, everything else prompts.
46
+ # Never set "exec" or "network" to "allow" without explicit user opt-in.
47
+ DEFAULT_POLICY: dict[RiskClass, Decision] = {
48
+ "read": "allow",
49
+ "write": "ask",
50
+ "exec": "ask",
51
+ "network": "ask",
52
+ }
53
+
54
+
55
+ class PolicyError(RuntimeError):
56
+ """Raised when a tool call is denied by policy."""
57
+
58
+
59
+ # ── Policy: Rule Container ────────────────────────────────────────────
60
+ @dataclass
61
+ class Policy:
62
+ """Loaded policy rules + auto-approval flag.
63
+
64
+ `auto_approve` is a kill switch for the approval prompt: if True
65
+ (or `SIN_AUTO_APPROVE=1` in the env), all "ask" decisions pass
66
+ without user interaction. Use only in trusted CI.
67
+ """
68
+
69
+ rules: dict[RiskClass, Decision] = field(default_factory=lambda: dict(DEFAULT_POLICY))
70
+ auto_approve: bool = field(default_factory=lambda: os.environ.get("SIN_AUTO_APPROVE") == "1")
71
+
72
+ @classmethod
73
+ def load(cls, root: Path = Path(".")) -> "Policy":
74
+ """Load policy from `<root>/.sin/policy.yaml`, falling back to defaults.
75
+
76
+ Missing file or missing PyYAML → returns a `Policy` populated with
77
+ `DEFAULT_POLICY` and `auto_approve` derived from `SIN_AUTO_APPROVE`.
78
+ User-supplied `rules` are merged on top of defaults (per-key override).
79
+ """
80
+ path = root / ".sin" / "policy.yaml"
81
+ if path.exists() and yaml is not None:
82
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
83
+ rules = {**DEFAULT_POLICY, **(data.get("rules") or {})}
84
+ return cls(rules=rules, auto_approve=bool(data.get("auto_approve", False)))
85
+ return cls()
86
+
87
+ def decide(self, tool: str) -> Decision:
88
+ """Map a tool name to its policy decision.
89
+
90
+ Unknown tools default to risk class `"exec"` (fail-closed — the
91
+ default decision for `exec` is `"ask"`, so they prompt unless
92
+ `auto_approve` is on).
93
+ """
94
+ risk = TOOL_RISK.get(tool, "exec")
95
+ return self.rules.get(risk, "ask")
96
+
97
+
98
+ # ── Tamper-evident Audit Log (hash chain) ────────────────────────────────
99
+ class AuditLog:
100
+ """Append-only JSONL log under `<root>/.sin/audit/log.jsonl`.
101
+
102
+ Each entry's `hash` is `sha256(prev_hash || canonical_json(entry))`,
103
+ forming a hash chain. `verify_chain()` re-walks the file to confirm
104
+ no entry has been edited or removed. Argument *values* are never
105
+ logged — only the *keys* (to avoid leaking secrets via the audit log).
106
+ """
107
+
108
+ def __init__(self, root: Path = Path(".")) -> None:
109
+ self.path = root / ".sin" / "audit" / "log.jsonl"
110
+ # parents=True — creates .sin/ and .sin/audit/ in one shot
111
+ self.path.parent.mkdir(parents=True, exist_ok=True)
112
+
113
+ def _last_hash(self) -> str:
114
+ if not self.path.exists():
115
+ return "0" * 64
116
+ last = ""
117
+ for line in self.path.read_text(encoding="utf-8").splitlines():
118
+ if line.strip():
119
+ last = line
120
+ if not last:
121
+ return "0" * 64
122
+ return json.loads(last).get("hash", "0" * 64)
123
+
124
+ def record(self, tool: str, args: dict, decision: Decision, outcome: str) -> str:
125
+ """Append one entry to the log and return its hash.
126
+
127
+ `args` is inspected by *key* only (sorted) — values are not stored,
128
+ so secrets in tool args never reach disk.
129
+ """
130
+ prev = self._last_hash()
131
+ entry = {
132
+ "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
133
+ "tool": tool,
134
+ "risk": TOOL_RISK.get(tool, "exec"),
135
+ "decision": decision,
136
+ "outcome": outcome,
137
+ "args_keys": sorted(args.keys()),
138
+ "prev": prev,
139
+ }
140
+ # sort_keys=True — canonical form so verify_chain() can reproduce
141
+ # the exact same digest bit-for-bit regardless of dict insertion order
142
+ digest = hashlib.sha256(
143
+ (prev + json.dumps(entry, sort_keys=True)).encode("utf-8")
144
+ ).hexdigest()
145
+ entry["hash"] = digest
146
+ with self.path.open("a", encoding="utf-8") as fh:
147
+ fh.write(json.dumps(entry) + "\n")
148
+ return digest
149
+
150
+ def verify_chain(self) -> bool:
151
+ """Return True if the hash chain is intact (no tampering)."""
152
+ if not self.path.exists():
153
+ return True
154
+ prev = "0" * 64
155
+ for line in self.path.read_text(encoding="utf-8").splitlines():
156
+ if not line.strip():
157
+ continue
158
+ entry = json.loads(line)
159
+ stored = entry.pop("hash", "")
160
+ if entry.get("prev") != prev:
161
+ return False
162
+ # MUST use sort_keys=True here to match the digest computed in
163
+ # record(); otherwise any field-order change in json.dumps would
164
+ # fail verification even on a benign log.
165
+ recomputed = hashlib.sha256(
166
+ (prev + json.dumps(entry, sort_keys=True)).encode("utf-8")
167
+ ).hexdigest()
168
+ if recomputed != stored:
169
+ return False
170
+ prev = stored
171
+ return True
172
+
173
+
174
+ # ── Path Sandboxing ───────────────────────────────────────────────────────
175
+ def ensure_within_root(target: str | Path, root: Optional[str | Path] = None) -> Path:
176
+ """Resolve `target` and guarantee it stays inside the project root."""
177
+ root_path = Path(root or os.environ.get("SIN_PROJECT_ROOT", ".")).resolve()
178
+ resolved = (
179
+ (root_path / target).resolve()
180
+ if not Path(target).is_absolute() # type: ignore[arg-type]
181
+ else Path(target).resolve() # type: ignore[arg-type]
182
+ )
183
+ if root_path not in resolved.parents and resolved != root_path:
184
+ raise PolicyError(f"path '{resolved}' is outside project root '{root_path}'")
185
+ return resolved
186
+
187
+
188
+ # ── Guarded Tool Wrapper (MCP gate) ────────────────────────────────────────
189
+ def guarded(
190
+ tool: str,
191
+ args: dict,
192
+ run: Callable[[], dict],
193
+ root: Path = Path("."),
194
+ approver: Optional[Callable[[str, dict], bool]] = None,
195
+ ) -> dict:
196
+ """Apply policy + audit around a tool execution.
197
+
198
+ `approver` is called for 'ask' decisions; defaults to auto-deny unless
199
+ SIN_AUTO_APPROVE=1 (so non-interactive runs are safe by default).
200
+ """
201
+ policy = Policy.load(root)
202
+ audit = AuditLog(root)
203
+ decision = policy.decide(tool)
204
+
205
+ if decision == "deny":
206
+ audit.record(tool, args, decision, "denied")
207
+ raise PolicyError(f"tool '{tool}' denied by policy (risk={TOOL_RISK.get(tool)})")
208
+
209
+ if decision == "ask":
210
+ approved = policy.auto_approve or (approver(tool, args) if approver else False)
211
+ if not approved:
212
+ audit.record(tool, args, decision, "rejected")
213
+ raise PolicyError(
214
+ f"tool '{tool}' requires approval (risk={TOOL_RISK.get(tool)}). "
215
+ "Set SIN_AUTO_APPROVE=1 or adjust .sin/policy.yaml."
216
+ )
217
+
218
+ try:
219
+ result = run()
220
+ audit.record(tool, args, decision, "ok")
221
+ return result
222
+ except Exception as exc: # noqa: BLE001
223
+ audit.record(tool, args, decision, f"error:{type(exc).__name__}")
224
+ raise
@@ -0,0 +1,152 @@
1
+ # Purpose: Pre-flight safety gate — checks before state-changing tool calls.
2
+ # Docs: preflight.doc.md
3
+ """Consolidates policy (sin_check_architecture) + docs (codocs) + git + tests
4
+ into 1 call. Run BEFORE sin_write, sin_edit, sin_bash, sin_ast_edit.
5
+
6
+ Docs: preflight.doc.md
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import subprocess
12
+ from pathlib import Path
13
+ from typing import Any, Dict, Optional
14
+
15
+
16
+ class PreflightChecker:
17
+ """Run all pre-flight checks in one go.
18
+
19
+ Each check is independent — failure of one does not block the others.
20
+ Returns a structured dict with per-check results and a derived risk score.
21
+ """
22
+
23
+ def __init__(self, repo_root: Optional[Path] = None) -> None:
24
+ self.repo_root = Path(repo_root) if repo_root else Path.cwd()
25
+
26
+ def check(self, tool_name: str, tool_input: Dict[str, Any]) -> Dict[str, Any]:
27
+ """Run policy + docs + git + tests checks.
28
+
29
+ Args:
30
+ tool_name: tool about to be called (e.g. ``sin_write``).
31
+ tool_input: arguments to that tool.
32
+
33
+ Returns:
34
+ Dict with ``allowed``, ``policy_ok``, ``docs_ok``, ``git_clean``,
35
+ ``tests_status``, ``estimated_risk``, ``violations`` and ``details``.
36
+ """
37
+ result: Dict[str, Any] = {
38
+ "tool_name": tool_name,
39
+ "allowed": True,
40
+ "policy_ok": True,
41
+ "docs_ok": True,
42
+ "git_clean": True,
43
+ "tests_status": "unknown",
44
+ "estimated_risk": "low",
45
+ "violations": [],
46
+ "details": {},
47
+ }
48
+
49
+ # ── 1. Policy check (existing SINInterceptor) ────────────────────
50
+ # Reuses the same rule engine as sin_check_architecture, so behaviour
51
+ # stays consistent with the single-call variant.
52
+ try:
53
+ from .interceptor import SINInterceptor
54
+
55
+ policy = SINInterceptor(repo_root=self.repo_root).preflight(tool_name, tool_input)
56
+ result["policy_ok"] = policy.get("allowed", True)
57
+ result["violations"] = policy.get("violations", [])
58
+ if not result["policy_ok"]:
59
+ result["allowed"] = False
60
+ result["estimated_risk"] = "high"
61
+ except Exception as exc:
62
+ result["details"]["policy_error"] = str(exc)
63
+
64
+ # ── 2. Docs check (codocs) ───────────────────────────────────────
65
+ # Surfaces broken .doc.md references; non-fatal but raises risk.
66
+ try:
67
+ from . import codocs
68
+
69
+ broken = codocs.find_broken(str(self.repo_root))
70
+ result["docs_ok"] = not bool(broken)
71
+ if not result["docs_ok"]:
72
+ result["details"]["broken_docs"] = [b.to_dict() for b in broken]
73
+ except Exception as exc:
74
+ result["details"]["docs_error"] = str(exc)
75
+
76
+ # ── 3. Git status ────────────────────────────────────────────────
77
+ # Skipped silently if the directory is not a git repository.
78
+ try:
79
+ if (self.repo_root / ".git").exists():
80
+ proc = subprocess.run(
81
+ ["git", "status", "--porcelain"],
82
+ cwd=self.repo_root,
83
+ capture_output=True,
84
+ text=True,
85
+ timeout=5,
86
+ )
87
+ if proc.returncode == 0:
88
+ changes = proc.stdout.strip()
89
+ result["git_clean"] = not bool(changes)
90
+ if changes:
91
+ result["details"]["git_changes_count"] = len(changes.split("\n"))
92
+ else:
93
+ result["git_clean"] = False
94
+ result["details"]["git_error"] = proc.stderr[-500:]
95
+ except subprocess.TimeoutExpired:
96
+ result["details"]["git_error"] = "git status timeout"
97
+ except FileNotFoundError:
98
+ result["details"]["git_error"] = "git not installed"
99
+ except Exception as exc:
100
+ result["details"]["git_error"] = str(exc)
101
+
102
+ # ── 4. Test collection (pytest --collect-only) ───────────────────
103
+ # Only runs when a tests/ or test/ directory exists AND pytest is
104
+ # importable. Collection (not execution) keeps the pre-flight cheap.
105
+ try:
106
+ has_tests = (self.repo_root / "tests").exists() or (self.repo_root / "test").exists()
107
+ if has_tests:
108
+ proc = subprocess.run(
109
+ ["python3", "-m", "pytest", "--collect-only", "-q"],
110
+ cwd=self.repo_root,
111
+ capture_output=True,
112
+ text=True,
113
+ timeout=15,
114
+ )
115
+ if proc.returncode == 0:
116
+ result["tests_status"] = "pass"
117
+ # Capture the "N tests collected" summary line for context.
118
+ for line in proc.stdout.split("\n"):
119
+ if "tests collected" in line.lower():
120
+ result["details"]["tests_collected"] = line.strip()
121
+ break
122
+ else:
123
+ result["tests_status"] = "fail"
124
+ result["details"]["test_errors"] = proc.stderr[-500:]
125
+ except subprocess.TimeoutExpired:
126
+ result["tests_status"] = "timeout"
127
+ except FileNotFoundError:
128
+ # pytest not installed — non-fatal, leave status as "unknown".
129
+ result["tests_status"] = "skipped"
130
+ except Exception as exc:
131
+ result["details"]["tests_error"] = str(exc)
132
+
133
+ # ── 5. Risk estimation ───────────────────────────────────────────
134
+ # 5 independent signals; 0 → low, 1-2 → medium, 3+ → high + block.
135
+ risk_signals = sum(
136
+ [
137
+ not result["policy_ok"],
138
+ not result["docs_ok"],
139
+ not result["git_clean"],
140
+ result["tests_status"] == "fail",
141
+ len(result["violations"]) > 0,
142
+ ]
143
+ )
144
+ if risk_signals == 0:
145
+ result["estimated_risk"] = "low"
146
+ elif risk_signals <= 2:
147
+ result["estimated_risk"] = "medium"
148
+ else:
149
+ result["estimated_risk"] = "high"
150
+ result["allowed"] = False
151
+
152
+ return result