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.
- sin_code_bundle/__init__.py +6 -0
- sin_code_bundle/agents_md.py +245 -0
- sin_code_bundle/ast_edit.py +323 -0
- sin_code_bundle/bench.py +506 -0
- sin_code_bundle/budget.py +51 -0
- sin_code_bundle/cache.py +131 -0
- sin_code_bundle/checkpoint.py +230 -0
- sin_code_bundle/cli.py +1943 -0
- sin_code_bundle/codocs.py +328 -0
- sin_code_bundle/dap_bridge.py +135 -0
- sin_code_bundle/data/codocs/SKILL.md +280 -0
- sin_code_bundle/gitnexus.py +368 -0
- sin_code_bundle/hashline.py +216 -0
- sin_code_bundle/hooks.py +249 -0
- sin_code_bundle/immortal_commit.py +288 -0
- sin_code_bundle/interceptor.py +119 -0
- sin_code_bundle/lsp_backend.py +303 -0
- sin_code_bundle/lsp_bootstrap.py +85 -0
- sin_code_bundle/markitdown.py +254 -0
- sin_code_bundle/mcp_config.py +455 -0
- sin_code_bundle/mcp_server.py +963 -0
- sin_code_bundle/memory.py +208 -0
- sin_code_bundle/merge_safety.py +313 -0
- sin_code_bundle/orchestration_worktrees.py +102 -0
- sin_code_bundle/policy.py +224 -0
- sin_code_bundle/preflight.py +152 -0
- sin_code_bundle/programming_workflow.py +541 -0
- sin_code_bundle/rtk.py +154 -0
- sin_code_bundle/safety.py +52 -0
- sin_code_bundle/session_warmup.py +247 -0
- sin_code_bundle/skills.py +188 -0
- sin_code_bundle/symbol_resolve.py +166 -0
- sin_code_bundle/tools/__init__.py +4 -0
- sin_code_bundle/tools/pypi_setup.py +289 -0
- sin_code_bundle/vfs.py +264 -0
- sin_code_bundle-0.9.2.dist-info/METADATA +470 -0
- sin_code_bundle-0.9.2.dist-info/RECORD +41 -0
- sin_code_bundle-0.9.2.dist-info/WHEEL +5 -0
- sin_code_bundle-0.9.2.dist-info/entry_points.txt +4 -0
- sin_code_bundle-0.9.2.dist-info/licenses/LICENSE +21 -0
- 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
|