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.
- trustline/__init__.py +3 -0
- trustline/baseline.py +126 -0
- trustline/cli.py +280 -0
- trustline/config.py +116 -0
- trustline/discovery.py +36 -0
- trustline/heuristics.py +282 -0
- trustline/hooks.py +290 -0
- trustline/rollback.py +119 -0
- trustline/sarif.py +49 -0
- trustline/scanner.py +33 -0
- trustline_cli-0.1.0.dist-info/METADATA +146 -0
- trustline_cli-0.1.0.dist-info/RECORD +16 -0
- trustline_cli-0.1.0.dist-info/WHEEL +5 -0
- trustline_cli-0.1.0.dist-info/entry_points.txt +2 -0
- trustline_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- trustline_cli-0.1.0.dist-info/top_level.txt +1 -0
trustline/heuristics.py
ADDED
|
@@ -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}")
|