trustline-cli 0.1.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.
@@ -0,0 +1,282 @@
1
+ """Static heuristic signals for prompt-injection / tampering in agent rules.
2
+
3
+ v0.1 ships 15 read-only rules. They are intentionally conservative static
4
+ matchers over plain text (and JSON structure for .mcp.json): no code execution,
5
+ no network calls. A hit is a *signal* to surface to the user (or, via hooks,
6
+ to block before the model trusts the file) -- not a verdict.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+ from dataclasses import dataclass
14
+
15
+ # --------------------------------------------------------------------------
16
+ # Rule plumbing
17
+ # --------------------------------------------------------------------------
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Rule:
22
+ id: str # e.g. "T001"
23
+ name: str
24
+ severity: str # "high" | "medium" | "low"
25
+ summary: str # human explanation of what this rule looks for
26
+
27
+ # detect(text) -> list[Finding]-style hints, or empty list when clean.
28
+ # Implementations are plain functions below; we keep them small & testable.
29
+ detect: object = None # callable(text: str) -> list[str] (trigger descriptions)
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Finding:
34
+ rule_id: str
35
+ severity: str
36
+ message: str
37
+ file: str = ""
38
+ line: int | None = None
39
+
40
+
41
+ RULES: list[Rule] = []
42
+ REGISTRY: dict[str, Rule] = {}
43
+
44
+
45
+ def _register(rule_id: str, name: str, severity: str, summary: str):
46
+ def deco(fn):
47
+ rule = Rule(id=rule_id, name=name, severity=severity, summary=summary, detect=fn)
48
+ RULES.append(rule)
49
+ REGISTRY[rule_id] = rule
50
+ return rule
51
+ return deco
52
+
53
+
54
+ # --------------------------------------------------------------------------
55
+ # Shared matchers
56
+ # --------------------------------------------------------------------------
57
+
58
+ _URL = r"https?://[^\s\"'`\)\]]+"
59
+ _SENSITIVE = r"(api[_-]?key|secret|token|password|credential|passwd|auth|env\b|env(?:ironment)?\s+variable|BEGIN\s+[A-Z ]*PRIVATE\s+KEY)"
60
+ _EXFIL_VERB = r"(send|post|upload|exfiltrat|leak|expose|transmit|phoning?\s+home)"
61
+
62
+
63
+ def _lines(text: str) -> list[tuple[int, str]]:
64
+ return [(i + 1, ln) for i, ln in enumerate(text.splitlines())]
65
+
66
+
67
+ # --------------------------------------------------------------------------
68
+ # T001-T015
69
+ # --------------------------------------------------------------------------
70
+
71
+
72
+ @_register("T001", "external-secret-exfil", "high",
73
+ "Instruction points at sending secrets / env vars to an external URL.")
74
+ def _t001(text: str) -> list[str]:
75
+ hits = []
76
+ for line_no, ln in _lines(text):
77
+ low = ln.lower()
78
+ if re.search(_URL, ln) and re.search(_SENSITIVE, low, re.IGNORECASE) and re.search(_EXFIL_VERB, low):
79
+ hits.append(f"line {line_no}: secret-like data + exfiltration verb near a URL")
80
+ return hits
81
+
82
+
83
+ @_register("T002", "download-and-execute", "high",
84
+ "Pipes a remote payload straight into a shell (curl|bash style).")
85
+ def _t002(text: str) -> list[str]:
86
+ pat = re.compile(
87
+ r"(?:curl|wget|iwr|Invoke-WebRequest|powershell).{0,40}?(?:\||\b&\b|\bpipe\b).{0,40}?(?:bash|sh|zsh|fish|powershell|pwsh|python3?|node|perl)",
88
+ re.IGNORECASE,
89
+ )
90
+ return [f"line {n}: download-and-execute pattern" for n, ln in _lines(text) if pat.search(ln)]
91
+
92
+
93
+ @_register("T003", "shell-profile-tamper", "high",
94
+ "Asks to modify the user's shell profile / rc files.")
95
+ def _t003(text: str) -> list[str]:
96
+ pat = re.compile(r"(?:~?/?)\.(?:bashrc|zshrc|profile|bash_profile|zprofile|fish/config\.fish|inputrc)", re.IGNORECASE)
97
+ return [f"line {n}: shell-profile tampering reference" for n, ln in _lines(text) if pat.search(ln)]
98
+
99
+
100
+ @_register("T004", "ssh-backdoor", "high",
101
+ "Asks to add SSH keys / authorized_keys entries.")
102
+ def _t004(text: str) -> list[str]:
103
+ pat = re.compile(r"(authorized_keys|\.ssh/.*id_[a-z]+|ssh-copy-id|AddKeysToAgent)", re.IGNORECASE)
104
+ return [f"line {n}: SSH key / authorized_keys manipulation" for n, ln in _lines(text) if pat.search(ln)]
105
+
106
+
107
+ @_register("T005", "privilege-escalation", "high",
108
+ "References sudoers / setuid / world-writable privilege changes.")
109
+ def _t005(text: str) -> list[str]:
110
+ pat = re.compile(r"(sudoers|visudo|chmod\s+4777|setuid|setgid|NOPASSWD|/etc/passwd|useradd\s+-o\s+-u\s+0)", re.IGNORECASE)
111
+ return [f"line {n}: privilege-escalation pattern" for n, ln in _lines(text) if pat.search(ln)]
112
+
113
+
114
+ @_register("T006", "persistence", "high",
115
+ "Schedules persistence: cron / systemd / launchd / startup items.")
116
+ def _t006(text: str) -> list[str]:
117
+ pat = re.compile(
118
+ r"(crontab|@reboot|systemctl\s+enable|\[Install\].*WantedBy|LaunchAgent|launchctl|StartupItems|schtasks|HKLM\\...\\Run|HKCU\\...\\Run)",
119
+ re.IGNORECASE,
120
+ )
121
+ return [f"line {n}: persistence mechanism reference" for n, ln in _lines(text) if pat.search(ln)]
122
+
123
+
124
+ @_register("T007", "obfuscated-payload", "high",
125
+ "Obfuscation: base64 / eval of encoded payloads.")
126
+ def _t007(text: str) -> list[str]:
127
+ patterns = [
128
+ re.compile(r"base64\s*-d|base64\s*--decode|from\s+base64\s+import", re.IGNORECASE),
129
+ re.compile(r"\beval\s*\(|exec\(\s*['\"`]|perl\s+-e\s*['\"]|ruby\s+-e\s*['\"]", re.IGNORECASE),
130
+ re.compile(r"python3?\s+-c\s*['\"][^'\"]{200,}", re.IGNORECASE),
131
+ re.compile(r"node\s+-e\s*['\"][^'\"]{100,}", re.IGNORECASE),
132
+ ]
133
+ hits = []
134
+ for n, ln in _lines(text):
135
+ if any(p.search(ln) for p in patterns):
136
+ hits.append(f"line {n}: obfuscated / encoded payload")
137
+ return hits
138
+
139
+
140
+ @_register("T008", "instruction-override", "high",
141
+ "Tells the agent to ignore earlier instructions / hide its actions.")
142
+ def _t008(text: str) -> list[str]:
143
+ patterns = [
144
+ re.compile(r"(ignore|disregard|skip|forget|override)\s+(all\s+)?(prior|previous|above|system|earlier)\s*(instructions?|rules?|prompts?|directives?)", re.IGNORECASE),
145
+ re.compile(r"(do not|don'?t|never)\s+(tell|mention|reveal|show|inform)\s+(the\s+)?(user|human)", re.IGNORECASE),
146
+ re.compile(r"(reply|respond|answer)\s*(with|only)?\s*(ok|fine|done|yes|no)\b.{0,20}(regardless|ignore)", re.IGNORECASE),
147
+ re.compile(r"ignore\s+(all\s+)?previous\s+instructions?", re.IGNORECASE),
148
+ ]
149
+ hits = []
150
+ for n, ln in _lines(text):
151
+ if any(p.search(ln) for p in patterns):
152
+ hits.append(f"line {n}: instruction-override / concealment language")
153
+ return hits
154
+
155
+
156
+ @_register("T009", "hide-from-git", "medium",
157
+ "Asks to keep changes out of git / commit history.")
158
+ def _t009(text: str) -> list[str]:
159
+ patterns = [
160
+ re.compile(r"do not\s+(commit|push|提交|推送)", re.IGNORECASE),
161
+ re.compile(r"(don'?t|never)\s+(commit|push)\s+(this|these|it|the\s+file)", re.IGNORECASE),
162
+ re.compile(r"git\s+(rm\s+--cached|reset\s+HEAD|update-index\s+--skip-worktree)", re.IGNORECASE),
163
+ re.compile(r"add\s+(this\s+)?file\s+to\s+\.gitignore\s+.*(secret|token|key)", re.IGNORECASE),
164
+ ]
165
+ hits = []
166
+ for n, ln in _lines(text):
167
+ if any(p.search(ln) for p in patterns):
168
+ hits.append(f"line {n}: asks to hide from version control")
169
+ return hits
170
+
171
+
172
+ @_register("T010", "mcp-supply-chain", "medium",
173
+ "MCP server runs an unpinned npx package or points at a non-official registry.")
174
+ def _t010(text: str) -> list[str]:
175
+ """JSON-aware: parse inline .mcp.json fragments (best effort)."""
176
+ hits = []
177
+ try:
178
+ data = json.loads(text)
179
+ servers = data.get("mcpServers", {}) if isinstance(data, dict) else {}
180
+ for name, cfg in servers.items():
181
+ if not isinstance(cfg, dict):
182
+ continue
183
+ cmd = str(cfg.get("command", ""))
184
+ args = cfg.get("args", []) if isinstance(cfg.get("args"), list) else []
185
+ if "npx" in cmd.lower() or "uvx" in cmd.lower():
186
+ joined = " ".join(str(a) for a in args)
187
+ # unpinned: contains @latest or a bare package name with no @version
188
+ if "@latest" in joined or not re.search(r"@[\d.]+", joined):
189
+ hits.append(f"MCP server '{name}': unpinned {cmd} package ({joined[:60]})")
190
+ url = cfg.get("url", "")
191
+ if isinstance(url, str) and re.search(_URL, url) and not re.search(r"(github|npmjs|registry\.npmjs)", url, re.IGNORECASE):
192
+ hits.append(f"MCP server '{name}': non-official remote url {url[:60]}")
193
+ except json.JSONDecodeError:
194
+ pass
195
+ return hits
196
+
197
+
198
+ @_register("T011", "git-remote-tamper", "high",
199
+ "Points git remotes at unknown hosts.")
200
+ def _t011(text: str) -> list[str]:
201
+ pat = re.compile(
202
+ r"git\s+remote\s+(add|set-url)\b.{0,60}?https?://(?![^\s]*?(github\.com|gitlab\.com|bitbucket\.org|git\.(sr\.ht|kernel\.org)))",
203
+ re.IGNORECASE,
204
+ )
205
+ return [f"line {n}: git remote pointing at non-official host" for n, ln in _lines(text) if pat.search(ln)]
206
+
207
+
208
+ @_register("T012", "hardcoded-secret", "high",
209
+ "Asks to write real credentials into files.")
210
+ def _t012(text: str) -> list[str]:
211
+ pat = re.compile(
212
+ r"(api[_-]?key|password|secret|token|client[_-]?secret)\s*[:=]\s*['\"][A-Za-z0-9_\-\.]{12,}['\"]",
213
+ re.IGNORECASE,
214
+ )
215
+ return [f"line {n}: hardcoded credential-looking literal" for n, ln in _lines(text) if pat.search(ln)]
216
+
217
+
218
+ @_register("T013", "sensitive-file-read", "high",
219
+ "Reads cloud / SSH credentials or secrets from the environment.")
220
+ def _t013(text: str) -> list[str]:
221
+ patterns = [
222
+ re.compile(r"(cat|type|print|read|open|copy|exfiltrat|upload).{0,60}?(~?/\.aws/credentials|~?/\.ssh/|/etc/(shadow|passwd|sudoers))", re.IGNORECASE),
223
+ re.compile(r"(print|echo|send|upload|export).{0,40}?(aws_|gcp_|azure_|OPENAI_API_KEY|ANTHROPIC_API_KEY|GITHUB_TOKEN|CI_)\w{0,30}?(=|env)", re.IGNORECASE),
224
+ ]
225
+ hits = []
226
+ for n, ln in _lines(text):
227
+ if any(p.search(ln) for p in patterns):
228
+ hits.append(f"line {n}: sensitive file / env access")
229
+ return hits
230
+
231
+
232
+ @_register("T014", "masked-executable", "medium",
233
+ "Hides an executable payload behind a benign-looking name (logo, .cache...).")
234
+ def _t014(text: str) -> list[str]:
235
+ pat = re.compile(
236
+ r"(\b(?:logo|avatar|image|icon|\.cache|\.thumbnails?|preview|banner)\w*\.(?:png|jpg|jpeg|gif|svg|ico|tmp|dat)\b)\s*(?:is|as|contains|writes?|runs?|executes?)\s*(?:a|an|the|shell|script|python|payload|binary|executable)?",
237
+ re.IGNORECASE,
238
+ )
239
+ return [f"line {n}: executable hidden behind benign name" for n, ln in _lines(text) if pat.search(ln)]
240
+
241
+
242
+ @_register("T015", "encoded-interpreter", "high",
243
+ "Long inline interpreter payloads (python -c / node -e) inside rules.")
244
+ def _t015(text: str) -> list[str]:
245
+ pat = re.compile(
246
+ r"\b(?:python3?|node|ruby|perl|php|bash|sh)\s+(?:-c|-e)\s*['\"][^'\"]{120,}",
247
+ re.IGNORECASE,
248
+ )
249
+ return [f"line {n}: long inline interpreter payload" for n, ln in _lines(text) if pat.search(ln)]
250
+
251
+
252
+ # --------------------------------------------------------------------------
253
+ # Public API
254
+ # --------------------------------------------------------------------------
255
+
256
+
257
+ def scan_text(text: str, file_rel: str = "") -> list[Finding]:
258
+ findings: list[Finding] = []
259
+ for rule in RULES:
260
+ fn = rule.detect
261
+ if fn is None:
262
+ continue
263
+ try:
264
+ triggers = fn(text) or []
265
+ except Exception: # defensive: a rule must never crash the scan
266
+ continue
267
+ for trig in triggers:
268
+ findings.append(
269
+ Finding(
270
+ rule_id=rule.id,
271
+ severity=rule.severity,
272
+ message=f"[{rule.name}] {trig}",
273
+ file=file_rel,
274
+ line=_first_line(trig) if False else None,
275
+ )
276
+ )
277
+ return findings
278
+
279
+
280
+ def _first_line(trig: str) -> int | None:
281
+ m = re.match(r"line (\d+):", trig)
282
+ return int(m.group(1)) if m else None
trustline/hooks.py ADDED
@@ -0,0 +1,290 @@
1
+ """Claude Code hook wiring for Trustline.
2
+
3
+ Installs / uninstalls hooks in ``.claude/settings.local.json`` (gitignored,
4
+ per-machine) and implements ``hook-check`` -- the command the hooks invoke.
5
+
6
+ Events (v1):
7
+ - SessionStart -> warn on out-of-baseline changes (cannot block)
8
+ - PreToolUse (Write/Edit/NotebookEdit) -> block with exit 2 when the target
9
+ managed file carries a high-severity tamper signal
10
+ - InstructionsLoaded -> block when the loaded rules file is out of baseline
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import sys
17
+ import time
18
+ from pathlib import Path
19
+
20
+ from .baseline import compare, load
21
+ from .config import Config
22
+
23
+ HOOK_SECTION_KEY = "hooks"
24
+ HOOK_COMMAND = "trustline hook-check"
25
+
26
+
27
+ def _hook_config() -> dict:
28
+ """Hook commands intentionally pass no --root: Claude Code executes hooks
29
+ with the project directory as cwd, and {CLAUDE_PROJECT_DIR} is not
30
+ substituted in every Claude Code version / headless mode. hook-check falls
31
+ back to cwd when --root is omitted."""
32
+ return {
33
+ "SessionStart": [
34
+ {
35
+ "hooks": [
36
+ {
37
+ "type": "command",
38
+ "command": f"{HOOK_COMMAND} --event session-start",
39
+ }
40
+ ]
41
+ }
42
+ ],
43
+ "PreToolUse": [
44
+ {
45
+ "matcher": "Write|Edit|NotebookEdit",
46
+ "hooks": [
47
+ {
48
+ "type": "command",
49
+ "command": f"{HOOK_COMMAND} --event pre-tool-use",
50
+ }
51
+ ],
52
+ }
53
+ ],
54
+ "InstructionsLoaded": [
55
+ {
56
+ "hooks": [
57
+ {
58
+ "type": "command",
59
+ "command": f"{HOOK_COMMAND} --event instructions-loaded",
60
+ }
61
+ ]
62
+ }
63
+ ],
64
+ }
65
+
66
+
67
+ def _settings_path(root: Path) -> Path:
68
+ return root / ".claude" / "settings.local.json"
69
+
70
+
71
+ def install_hooks(root: Path) -> Path:
72
+ """Merge our hook section into .claude/settings.local.json."""
73
+ path = _settings_path(root)
74
+ data: dict = {}
75
+ if path.exists():
76
+ data = json.loads(path.read_text(encoding="utf-8"))
77
+ data.setdefault(HOOK_SECTION_KEY, {})
78
+ for event, groups in _hook_config().items():
79
+ # replace our groups for this event (idempotent), keep unrelated entries
80
+ existing = data[HOOK_SECTION_KEY].get(event, [])
81
+ ours = _hook_config()[event]
82
+ kept = [g for g in existing if not _is_trustline_group(g)]
83
+ data[HOOK_SECTION_KEY][event] = kept + ours
84
+ path.parent.mkdir(parents=True, exist_ok=True)
85
+ path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
86
+ return path
87
+
88
+
89
+ def uninstall_hooks(root: Path) -> bool:
90
+ path = _settings_path(root)
91
+ if not path.exists():
92
+ return False
93
+ data = json.loads(path.read_text(encoding="utf-8"))
94
+ section = data.get(HOOK_SECTION_KEY, {})
95
+ removed = False
96
+ for event in list(_hook_config().keys()):
97
+ groups = section.get(event, [])
98
+ kept = [g for g in groups if not _is_trustline_group(g)]
99
+ if len(kept) != len(groups):
100
+ removed = True
101
+ if kept:
102
+ section[event] = kept
103
+ else:
104
+ section.pop(event, None)
105
+ data[HOOK_SECTION_KEY] = section
106
+ if not section:
107
+ data.pop(HOOK_SECTION_KEY, None)
108
+ path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
109
+ return removed
110
+
111
+
112
+ def _is_trustline_group(group: dict) -> bool:
113
+ for h in group.get("hooks", []):
114
+ if isinstance(h, dict) and h.get("type") == "command" and HOOK_COMMAND in str(h.get("command", "")):
115
+ return True
116
+ return False
117
+
118
+
119
+ # --------------------------------------------------------------------------
120
+ # hook-check
121
+ # --------------------------------------------------------------------------
122
+
123
+ _WRITE_TOOLS = {"Write", "Edit", "NotebookEdit", "MultiEdit"}
124
+
125
+
126
+ def _extract_target_path(payload: dict) -> str | None:
127
+ """Best-effort extraction of the file path from a hook payload."""
128
+ tool_input = payload.get("tool_input") or {}
129
+ candidates = [tool_input.get("file_path"), tool_input.get("path"), payload.get("file_path"), payload.get("path")]
130
+ for c in candidates:
131
+ if isinstance(c, str) and c:
132
+ return c
133
+ # deep search fallback
134
+ for key in ("file_path", "path"):
135
+ val = payload.get(key)
136
+ if isinstance(val, str) and val:
137
+ return val
138
+ return None
139
+
140
+
141
+ def _extract_incoming_text(payload: dict) -> str | None:
142
+ """Content the agent is ABOUT to write (PreToolUse fires before the write,
143
+ so the on-disk file is still the old one -- we must inspect the payload)."""
144
+ tool_input = payload.get("tool_input") or {}
145
+ for key in ("content", "new_string"):
146
+ val = tool_input.get(key)
147
+ if isinstance(val, str) and val:
148
+ return val
149
+ return None
150
+
151
+
152
+ def _audit(cfg: Config, event: str, decision: str, rel: str, rules: list[str]) -> None:
153
+ """Append one JSONL line per hook decision so real-session behaviour is
154
+ observable. Never raises (auditing must not break the hook protocol)."""
155
+ try:
156
+ line = {
157
+ "ts": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
158
+ "event": event,
159
+ "decision": decision,
160
+ "file": rel,
161
+ "rules": rules,
162
+ }
163
+ cfg.project_state.mkdir(parents=True, exist_ok=True)
164
+ with open(cfg.project_state / "audit.jsonl", "a", encoding="utf-8") as fh:
165
+ fh.write(json.dumps(line, ensure_ascii=False) + "\n")
166
+ except Exception:
167
+ pass
168
+
169
+
170
+ def run_check(root: Path, event: str, payload: dict) -> int:
171
+ """Execute a hook event. Returns exit code:
172
+ 0 -> ok (or nothing to do / no baseline)
173
+ 1 -> non-blocking advisory (stderr already printed)
174
+ 2 -> blocking (stderr explains why)
175
+ """
176
+ cfg = Config(root=root)
177
+ baseline = load(cfg)
178
+ if baseline is None:
179
+ sys.stderr.write("trustline: no baseline yet (run 'trustline init' to protect this project)\n")
180
+ return 0
181
+
182
+ if event == "session-start":
183
+ changes = compare(cfg, baseline)
184
+ if changes:
185
+ lines = "\n".join(f" [{c.change_type}] {c.relpath}" for c in changes[:20])
186
+ _audit(cfg, event, "warned", "", [c.relpath for c in changes][:10])
187
+ sys.stderr.write(
188
+ f"trustline: {len(changes)} agent-config file(s) differ from trusted baseline:\n{lines}\n"
189
+ "Run 'trustline status' and 'trustline rollback' to inspect / restore.\n"
190
+ )
191
+ else:
192
+ _audit(cfg, event, "ok", "", [])
193
+ return 0
194
+
195
+ if event == "instructions-loaded":
196
+ rel = _extract_target_path(payload)
197
+ if rel:
198
+ return _block_if_out_of_baseline(cfg, baseline, rel, payload)
199
+ _audit(cfg, event, "ok", "", [])
200
+ return 0
201
+
202
+ if event == "pre-tool-use":
203
+ tool = payload.get("tool_name", "")
204
+ if tool not in _WRITE_TOOLS:
205
+ return 0
206
+ rel = _extract_target_path(payload)
207
+ if not rel:
208
+ return 0
209
+ resolved = _resolve_managed(cfg, rel)
210
+ if not resolved:
211
+ return 0
212
+ rel_norm, _target = resolved
213
+
214
+ # (1) Incoming content is the primary signal: PreToolUse runs BEFORE
215
+ # the write, so scan the payload the agent wants to write.
216
+ incoming = _extract_incoming_text(payload)
217
+ if incoming:
218
+ from .heuristics import scan_text
219
+
220
+ incoming_findings = scan_text(incoming, file_rel=rel_norm)
221
+ high = [f for f in incoming_findings if f.severity == "high"]
222
+ if high:
223
+ _print_findings(high, "BLOCKED")
224
+ _audit(cfg, event, "blocked", rel_norm, [f.rule_id for f in high])
225
+ sys.stderr.write(
226
+ "trustline: refusing to write tamper-like content into a managed agent-config file.\n"
227
+ )
228
+ return 2
229
+
230
+ # (2) Baseline drift on disk -> warn (do not block: the agent may be
231
+ # legitimately fixing the file).
232
+ _warn_if_out_of_baseline(cfg, baseline, rel_norm)
233
+ _audit(cfg, event, "ok", rel_norm, [])
234
+ return 0
235
+
236
+ return 0
237
+
238
+
239
+ def _is_managed_rel(cfg: Config, rel: str) -> bool:
240
+ from .config import is_managed
241
+
242
+ return is_managed(rel, cfg.patterns)
243
+
244
+
245
+ def _resolve_managed(cfg: Config, rel: str) -> tuple[str, Path] | None:
246
+ rel = rel.strip()
247
+ while rel.startswith("./"):
248
+ rel = rel[2:]
249
+ if not _is_managed_rel(cfg, rel):
250
+ return None
251
+ target = cfg.root / Path(*rel.split("/"))
252
+ return rel, target
253
+
254
+
255
+ def _block_if_out_of_baseline(cfg: Config, baseline, rel: str, payload: dict) -> int:
256
+ resolved = _resolve_managed(cfg, rel)
257
+ if not resolved:
258
+ return 0
259
+ rel_norm, target = resolved
260
+ changes = {c.relpath: c for c in compare(cfg, baseline)}
261
+ ch = changes.get(rel_norm)
262
+ if ch is not None and ch.change_type in ("modified", "deleted"):
263
+ sys.stderr.write(
264
+ f"trustline: BLOCKED loading '{rel_norm}' -- differs from trusted baseline ({ch.change_type}).\n"
265
+ "Run 'trustline status' / 'trustline rollback' to inspect and restore.\n"
266
+ )
267
+ return 2
268
+ if ch is not None and ch.change_type == "added":
269
+ sys.stderr.write(
270
+ f"trustline: notice: '{rel_norm}' is new (not in baseline). Review then 'trustline init' to trust it.\n"
271
+ )
272
+ return 0
273
+
274
+
275
+ def _warn_if_out_of_baseline(cfg: Config, baseline, rel: str) -> None:
276
+ resolved = _resolve_managed(cfg, rel)
277
+ if not resolved:
278
+ return
279
+ rel_norm, _target = resolved
280
+ changes = {c.relpath: c for c in compare(cfg, baseline)}
281
+ if rel_norm in changes:
282
+ sys.stderr.write(
283
+ f"trustline: notice: '{rel_norm}' differs from trusted baseline ({changes[rel_norm].change_type}).\n"
284
+ )
285
+
286
+
287
+ def _print_findings(findings, prefix: str) -> None:
288
+ for f in findings[:10]:
289
+ loc = f.file or "-"
290
+ sys.stderr.write(f"trustline: {prefix} {f.severity.upper()} [{f.rule_id}] {loc}: {f.message}\n")
trustline/rollback.py ADDED
@@ -0,0 +1,119 @@
1
+ """Rollback: restore managed files to their trusted state.
2
+
3
+ Strategy (v1):
4
+ 1. If the file lives in a git working tree, restore from git HEAD (the most
5
+ recent committed state) -- but only when HEAD content matches the trusted
6
+ baseline content (otherwise the baseline predates HEAD and restoring HEAD
7
+ would *not* restore the trusted state).
8
+ 2. Otherwise (or when git can't help), restore from the content snapshot
9
+ stored by ``init`` under state_dir/projects/<root>/copies/<sha256>/.
10
+ 3. Soft rollback: the current (tampered) content is preserved as ``<file>.bak``
11
+ before overwrite, so a mistaken rollback is reversible.
12
+
13
+ Exact-match semantics are the responsibility of the caller (CLI / hooks).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import shutil
19
+ import subprocess
20
+ from pathlib import Path
21
+
22
+ from .baseline import sha256_file
23
+ from .config import Config, normalize_relpath
24
+
25
+
26
+ class RollbackResult:
27
+ def __init__(self, relpath: str, ok: bool, detail: str) -> None:
28
+ self.relpath = relpath
29
+ self.ok = ok
30
+ self.detail = detail
31
+
32
+
33
+ def _in_git_repo(root: Path) -> bool:
34
+ r = subprocess.run(
35
+ ["git", "-C", str(root), "rev-parse", "--is-inside-work-tree"],
36
+ capture_output=True, text=True, timeout=15,
37
+ )
38
+ return r.returncode == 0 and r.stdout.strip() == "true"
39
+
40
+
41
+ def _git_head_matches(root: Path, relpath: str, expected_sha: str) -> bool:
42
+ """True when the file content at git HEAD hashes to expected_sha."""
43
+ r = subprocess.run(
44
+ ["git", "-C", str(root), "show", f"HEAD:{relpath}"],
45
+ capture_output=True, timeout=15,
46
+ )
47
+ if r.returncode != 0:
48
+ return False
49
+ import hashlib
50
+
51
+ return hashlib.sha256(r.stdout).hexdigest() == expected_sha
52
+
53
+
54
+ def _git_restore(root: Path, relpath: str) -> bool:
55
+ r = subprocess.run(
56
+ ["git", "-C", str(root), "restore", "--source=HEAD", "--", relpath],
57
+ capture_output=True, text=True, timeout=30,
58
+ )
59
+ return r.returncode == 0
60
+
61
+
62
+ def _restore_from_copy(config: Config, relpath: str, target_sha: str, target: Path) -> bool:
63
+ src = config.copies_dir / target_sha
64
+ if not src.is_file():
65
+ return False
66
+ target.parent.mkdir(parents=True, exist_ok=True)
67
+ shutil.copy2(src, target)
68
+ return True
69
+
70
+
71
+ def rollback_file(config: Config, relpath: str, target_sha: str) -> RollbackResult:
72
+ """Restore ``relpath`` (relative to config.root) to content with
73
+ ``target_sha`` (from baseline entry)."""
74
+ target = config.root / Path(*relpath.split("/"))
75
+ if not target.exists():
76
+ # deleted file: recreate from snapshot (git can restore too)
77
+ if _in_git_repo(config.root) and _git_head_matches(config.root, relpath, target_sha):
78
+ if _git_restore(config.root, relpath):
79
+ return RollbackResult(relpath, True, "restored from git HEAD")
80
+ if _restore_from_copy(config, relpath, target_sha, target):
81
+ return RollbackResult(relpath, True, "recreated from trusted snapshot")
82
+ return RollbackResult(relpath, False, "file is deleted and no trusted snapshot/git copy available")
83
+
84
+ current_sha = sha256_file(target)
85
+ if current_sha == target_sha:
86
+ return RollbackResult(relpath, True, "already at trusted state")
87
+
88
+ # soft rollback: preserve tampered content OUTSIDE the project tree
89
+ # (state dir), so it never pollutes the working tree or the baseline.
90
+ bak_dir = config.project_state / "baks"
91
+ bak_dir.mkdir(parents=True, exist_ok=True)
92
+ bak = bak_dir / relpath
93
+ bak.parent.mkdir(parents=True, exist_ok=True)
94
+ shutil.copy2(target, bak)
95
+
96
+ restored = False
97
+ detail = ""
98
+ if _in_git_repo(config.root):
99
+ if _git_head_matches(config.root, relpath, target_sha):
100
+ restored = _git_restore(config.root, relpath)
101
+ detail = "restored from git HEAD"
102
+ elif _restore_from_copy(config, relpath, target_sha, target):
103
+ restored = True
104
+ detail = "restored from trusted snapshot (git HEAD differs from baseline)"
105
+ else:
106
+ restored = _restore_from_copy(config, relpath, target_sha, target)
107
+ detail = "restored from trusted snapshot"
108
+
109
+ if restored and sha256_file(target) != target_sha:
110
+ restored = False
111
+ detail = "restore produced unexpected content; kept .bak"
112
+
113
+ if not restored:
114
+ # put the tampered file back, keep .bak as the only copy of the truth
115
+ if bak.exists():
116
+ shutil.copy2(bak, target)
117
+ return RollbackResult(relpath, False, f"rollback failed ({detail or 'no source available'}); original kept")
118
+
119
+ return RollbackResult(relpath, True, f"{detail}; tampered copy kept at {bak}")