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 ADDED
@@ -0,0 +1,3 @@
1
+ """Trustline: trusted baseline + auto-rollback for AI agent configuration files."""
2
+
3
+ __version__ = "0.1.0"
trustline/baseline.py ADDED
@@ -0,0 +1,126 @@
1
+ """Trusted baseline: SHA-256 snapshot of managed files + content copies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import shutil
8
+ import time
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+
12
+ from .config import Config, normalize_relpath
13
+
14
+ BASELINE_VERSION = 1
15
+
16
+
17
+ def sha256_file(path: Path) -> str:
18
+ h = hashlib.sha256()
19
+ with path.open("rb") as f:
20
+ for chunk in iter(lambda: f.read(65536), b""):
21
+ h.update(chunk)
22
+ return h.hexdigest()
23
+
24
+
25
+ def sha256_bytes(data: bytes) -> str:
26
+ return hashlib.sha256(data).hexdigest()
27
+
28
+
29
+ @dataclass
30
+ class FileEntry:
31
+ sha256: str
32
+ copied: bool = False # a content snapshot exists under copies/<sha>/
33
+ size: int = 0
34
+
35
+
36
+ @dataclass
37
+ class Change:
38
+ relpath: str
39
+ change_type: str # "added" | "modified" | "deleted"
40
+ old_sha: str = ""
41
+ new_sha: str = ""
42
+
43
+
44
+ @dataclass
45
+ class Baseline:
46
+ version: int = BASELINE_VERSION
47
+ created_at: str = ""
48
+ files: dict[str, FileEntry] = field(default_factory=dict)
49
+
50
+ def to_json(self) -> dict:
51
+ return {
52
+ "version": self.version,
53
+ "created_at": self.created_at,
54
+ "files": {k: {"sha256": v.sha256, "copied": v.copied, "size": v.size} for k, v in self.files.items()},
55
+ }
56
+
57
+ @classmethod
58
+ def from_json(cls, data: dict) -> "Baseline":
59
+ b = cls(version=data.get("version", BASELINE_VERSION), created_at=data.get("created_at", ""))
60
+ for k, v in (data.get("files") or {}).items():
61
+ b.files[k] = FileEntry(sha256=v.get("sha256", ""), copied=bool(v.get("copied")), size=int(v.get("size", 0)))
62
+ return b
63
+
64
+
65
+ def load(config: Config) -> Baseline | None:
66
+ p = config.baseline_path
67
+ if not p.exists():
68
+ return None
69
+ try:
70
+ return Baseline.from_json(json.loads(p.read_text(encoding="utf-8")))
71
+ except (json.JSONDecodeError, OSError):
72
+ return None
73
+
74
+
75
+ def save(config: Config, baseline: Baseline) -> Path:
76
+ config.ensure_dirs()
77
+ p = config.baseline_path
78
+ p.write_text(json.dumps(baseline.to_json(), indent=2, ensure_ascii=False), encoding="utf-8")
79
+ return p
80
+
81
+
82
+ def snapshot(config: Config) -> Baseline:
83
+ """Hash every managed file; store content copies under copies/<sha> when
84
+ config.with_copies is set so rollback works even outside a git repo."""
85
+ from .discovery import discover
86
+
87
+ baseline = Baseline(created_at=time.strftime("%Y-%m-%dT%H:%M:%S%z"))
88
+ for path in discover(config):
89
+ rel = normalize_relpath(config.root, path)
90
+ digest = sha256_file(path)
91
+ entry = FileEntry(sha256=digest, size=path.stat().st_size)
92
+ if config.with_copies:
93
+ dest = config.copies_dir / digest
94
+ if not dest.exists():
95
+ dest.parent.mkdir(parents=True, exist_ok=True)
96
+ shutil.copy2(path, dest)
97
+ entry.copied = True
98
+ baseline.files[rel] = entry
99
+ return baseline
100
+
101
+
102
+ def compare(config: Config, baseline: Baseline) -> list[Change]:
103
+ """Diff current managed files against the baseline."""
104
+ from .discovery import discover
105
+
106
+ changes: list[Change] = []
107
+ current: dict[str, Path] = {}
108
+ for path in discover(config):
109
+ rel = normalize_relpath(config.root, path)
110
+ current[rel] = path
111
+
112
+ for rel, path in current.items():
113
+ entry = baseline.files.get(rel)
114
+ if entry is None:
115
+ changes.append(Change(relpath=rel, change_type="added", new_sha=sha256_file(path)))
116
+ else:
117
+ digest = sha256_file(path)
118
+ if digest != entry.sha256:
119
+ changes.append(Change(relpath=rel, change_type="modified", old_sha=entry.sha256, new_sha=digest))
120
+
121
+ for rel in baseline.files:
122
+ if rel not in current:
123
+ changes.append(Change(relpath=rel, change_type="deleted", old_sha=baseline.files[rel].sha256))
124
+
125
+ changes.sort(key=lambda c: (c.change_type, c.relpath))
126
+ return changes
trustline/cli.py ADDED
@@ -0,0 +1,280 @@
1
+ """Trustline command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+
14
+ from . import __version__
15
+ from .baseline import compare, load, save, snapshot
16
+ from .config import Config, is_managed, normalize_relpath
17
+ from .hooks import install_hooks, run_check, uninstall_hooks
18
+ from .rollback import rollback_file
19
+ from .sarif import build_sarif
20
+ from .scanner import scan_file, scan_root, severity_rank
21
+
22
+ app = typer.Typer(add_completion=False, no_args_is_help=True, help="Trusted baseline + rollback for AI agent config files.")
23
+ console = Console(highlight=False)
24
+
25
+ _VERSION_STATE = {"root": None}
26
+
27
+
28
+ def _make_config(root: Optional[str] = None, no_copies: bool = False, patterns: Optional[str] = None) -> Config:
29
+ return Config(
30
+ root=Path(root) if root else None,
31
+ patterns=patterns.split(",") if patterns else None,
32
+ with_copies=not no_copies,
33
+ )
34
+
35
+
36
+ def _require_baseline(cfg: Config):
37
+ baseline = load(cfg)
38
+ if baseline is None:
39
+ console.print(
40
+ "[yellow]No trusted baseline yet.[/yellow] Run [bold]trustline init[/bold] first "
41
+ "(protect this project's agent config files)."
42
+ )
43
+ raise typer.Exit(2)
44
+ return baseline
45
+
46
+
47
+ # --------------------------------------------------------------------------
48
+ # init / status
49
+ # --------------------------------------------------------------------------
50
+
51
+
52
+ @app.command()
53
+ def init(
54
+ root: Optional[str] = typer.Option(None, "--root", "-r", help="Project root (default: cwd)"),
55
+ no_copies: bool = typer.Option(False, "--no-copies", help="Do not store content snapshots (rollback then needs git)"),
56
+ patterns: Optional[str] = typer.Option(None, "--patterns", help="Comma-separated managed-file patterns (override)"),
57
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt"),
58
+ ):
59
+ """Establish a trusted baseline for managed agent-config files."""
60
+ cfg = _make_config(root, no_copies, patterns)
61
+ from .discovery import discover
62
+
63
+ files = discover(cfg)
64
+ if not files:
65
+ console.print("[red]No managed agent-config files found under this root.[/red]")
66
+ console.print("Nothing to protect yet. Default patterns: .claude/**, CLAUDE.md, **/AGENTS.md, ...")
67
+ raise typer.Exit(1)
68
+
69
+ table = Table(title=f"Files to protect ({len(files)})")
70
+ table.add_column("relative path")
71
+ for f in files:
72
+ table.add_row(normalize_relpath(cfg.root, f))
73
+ console.print(table)
74
+
75
+ if not yes:
76
+ ok = typer.confirm("Establish trusted baseline for these files?", default=True)
77
+ if not ok:
78
+ raise typer.Exit(1)
79
+
80
+ baseline = snapshot(cfg)
81
+ path = save(cfg, baseline)
82
+ console.print(f"[green]Baseline saved[/green] -> {path}")
83
+ console.print(f"{len(baseline.files)} file(s) hashed{' and snapshotted' if cfg.with_copies else ''}.")
84
+
85
+
86
+ @app.command()
87
+ def status(
88
+ root: Optional[str] = typer.Option(None, "--root", "-r", help="Project root (default: cwd)"),
89
+ json_out: bool = typer.Option(False, "--json", help="Emit JSON"),
90
+ ):
91
+ """Show which managed files differ from the trusted baseline."""
92
+ cfg = _make_config(root)
93
+ baseline = _require_baseline(cfg)
94
+ changes = compare(cfg, baseline)
95
+
96
+ if json_out:
97
+ console.print_json(data=[c.__dict__ for c in changes])
98
+ return
99
+
100
+ if not changes:
101
+ console.print("[green]Clean: all managed files match the trusted baseline.[/green]")
102
+ return
103
+
104
+ table = Table(title=f"{len(changes)} change(s) vs baseline")
105
+ table.add_column("type")
106
+ table.add_column("file")
107
+ for c in changes:
108
+ color = {"added": "green", "deleted": "red", "modified": "yellow"}[c.change_type]
109
+ table.add_row(f"[{color}]{c.change_type}[/{color}]", c.relpath)
110
+ console.print(table)
111
+ console.print("Hint: [bold]trustline rollback[/bold] restores; [bold]trustline init[/bold] re-baselines.")
112
+
113
+
114
+ # --------------------------------------------------------------------------
115
+ # scan
116
+ # --------------------------------------------------------------------------
117
+
118
+
119
+ @app.command()
120
+ def scan(
121
+ files: list[str] = typer.Argument(None, help="Restrict scan to these paths (managed files only)"),
122
+ root: Optional[str] = typer.Option(None, "--root", "-r", help="Project root (default: cwd)"),
123
+ sarif: bool = typer.Option(False, "--sarif", help="Emit SARIF 2.1.0"),
124
+ ):
125
+ """Scan managed files for tamper / injection signals (static heuristics)."""
126
+ cfg = _make_config(root)
127
+ if files:
128
+ findings = []
129
+ for raw in files:
130
+ p = (cfg.root / raw) if not Path(raw).is_absolute() else Path(raw)
131
+ rel = normalize_relpath(cfg.root, p)
132
+ if not is_managed(rel, cfg.patterns):
133
+ console.print(f"[dim]skip (not managed): {rel}[/dim]")
134
+ continue
135
+ findings.extend(scan_file(p, cfg))
136
+ else:
137
+ findings = scan_root(cfg)
138
+
139
+ findings.sort(key=severity_rank)
140
+ if sarif:
141
+ console.print_json(data=build_sarif(findings))
142
+ return
143
+
144
+ if not findings:
145
+ console.print("[green]No tamper signals detected in managed files.[/green]")
146
+ return
147
+
148
+ table = Table(title=f"{len(findings)} signal(s) (static heuristics, review before acting)")
149
+ table.add_column("sev")
150
+ table.add_column("rule")
151
+ table.add_column("file")
152
+ table.add_column("detail")
153
+ for f in findings:
154
+ color = {"high": "red", "medium": "yellow", "low": "blue"}[f.severity]
155
+ table.add_row(f"[{color}]{f.severity}[/{color}]", f.rule_id, f.file, f.message)
156
+ console.print(table)
157
+
158
+
159
+ # --------------------------------------------------------------------------
160
+ # rollback
161
+ # --------------------------------------------------------------------------
162
+
163
+
164
+ @app.command()
165
+ def rollback(
166
+ paths: list[str] = typer.Argument(None, help="Files to restore (relative paths); omit for --all"),
167
+ root: Optional[str] = typer.Option(None, "--root", "-r", help="Project root (default: cwd)"),
168
+ all_files: bool = typer.Option(False, "--all", help="Restore every out-of-baseline managed file"),
169
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
170
+ ):
171
+ """Restore managed files to the trusted baseline (git or snapshots; tampered copy kept as .bak)."""
172
+ cfg = _make_config(root)
173
+ baseline = _require_baseline(cfg)
174
+ changes = compare(cfg, baseline)
175
+
176
+ if not changes:
177
+ console.print("[green]Nothing to roll back.[/green]")
178
+ return
179
+
180
+ if all_files:
181
+ targets = changes
182
+ elif paths:
183
+ wanted = {p.lstrip("./") for p in paths}
184
+ targets = [c for c in changes if c.relpath in wanted]
185
+ missing = wanted - {c.relpath for c in targets}
186
+ if missing:
187
+ console.print(f"[yellow]No change for: {', '.join(sorted(missing))}[/yellow]")
188
+ else:
189
+ console.print("[yellow]Nothing selected.[/yellow] Use --all or pass file paths.")
190
+ raise typer.Exit(1)
191
+
192
+ if not targets:
193
+ console.print("[yellow]Selected files are all clean.[/yellow]")
194
+ return
195
+
196
+ table = Table(title="Will restore")
197
+ table.add_column("type")
198
+ table.add_column("file")
199
+ for c in targets:
200
+ table.add_row(c.change_type, c.relpath)
201
+ console.print(table)
202
+ if not yes and not typer.confirm("Proceed with rollback?", default=False):
203
+ raise typer.Exit(1)
204
+
205
+ ok = 0
206
+ for c in targets:
207
+ if c.change_type == "added":
208
+ console.print(f"[yellow]skip (added after baseline, nothing trusted to restore to): {c.relpath}[/yellow]")
209
+ continue
210
+ result = rollback_file(cfg, c.relpath, c.old_sha)
211
+ if result.ok:
212
+ console.print(f"[green]restored[/green] {result.relpath} ({result.detail})")
213
+ ok += 1
214
+ else:
215
+ console.print(f"[red]failed[/red] {result.relpath}: {result.detail}")
216
+ console.print(f"\n{ok}/{len([c for c in targets if c.change_type != 'added'])} restored.")
217
+
218
+
219
+ # --------------------------------------------------------------------------
220
+ # export-sarif / hook
221
+ # --------------------------------------------------------------------------
222
+
223
+
224
+ @app.command("export-sarif")
225
+ def export_sarif(
226
+ root: Optional[str] = typer.Option(None, "--root", "-r", help="Project root (default: cwd)"),
227
+ ):
228
+ """Scan all managed files and print findings as SARIF 2.1.0."""
229
+ cfg = _make_config(root)
230
+ findings = scan_root(cfg)
231
+ console.print_json(data=build_sarif(findings))
232
+
233
+
234
+ @app.command("hook")
235
+ def hook(
236
+ action: str = typer.Argument(..., help="install | uninstall"),
237
+ root: Optional[str] = typer.Option(None, "--root", "-r", help="Project root (default: cwd)"),
238
+ ):
239
+ """Install or uninstall Claude Code hooks for this project."""
240
+ cfg = _make_config(root)
241
+ if action == "install":
242
+ path = install_hooks(cfg.root)
243
+ console.print(f"[green]Hooks installed[/green] -> {path}")
244
+ console.print("Restart Claude Code. Events: SessionStart (warn), PreToolUse Write/Edit (block), InstructionsLoaded (block).")
245
+ return
246
+ if action == "uninstall":
247
+ removed = uninstall_hooks(cfg.root)
248
+ console.print("[green]Hooks removed.[/green]" if removed else "[yellow]No trustline hooks found.[/yellow]")
249
+ return
250
+ console.print(f"[red]Unknown action: {action}[/red] (install | uninstall)")
251
+ raise typer.Exit(1)
252
+
253
+
254
+ @app.command("hook-check", hidden=True)
255
+ def hook_check(
256
+ event: str = typer.Option(..., "--event", help="session-start | pre-tool-use | instructions-loaded"),
257
+ root: Optional[str] = typer.Option(None, "--root", "-r", help="Project root (default: cwd)"),
258
+ ):
259
+ """Entry point invoked by Claude Code hooks (reads hook JSON on stdin)."""
260
+ cfg = _make_config(root)
261
+ payload = {}
262
+ raw = sys.stdin.read()
263
+ if raw.strip():
264
+ try:
265
+ payload = json.loads(raw)
266
+ except json.JSONDecodeError:
267
+ payload = {}
268
+ code = run_check(cfg.root, event, payload)
269
+ raise typer.Exit(code)
270
+
271
+
272
+ @app.callback()
273
+ def _version_flag(version: bool = typer.Option(False, "--version", help="Show version")):
274
+ if version:
275
+ console.print(f"trustline {__version__}")
276
+ raise typer.Exit()
277
+
278
+
279
+ if __name__ == "__main__":
280
+ app()
trustline/config.py ADDED
@@ -0,0 +1,116 @@
1
+ """Configuration: managed file patterns, state directory, tiny glob matcher."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ import os
7
+ from pathlib import Path, PurePosixPath
8
+
9
+ # Default set of files that can steer an AI coding agent. Kept deliberately
10
+ # narrow: patterns are matched relative to the project root.
11
+ MANAGED_PATTERNS: list[str] = [
12
+ ".claude/**",
13
+ "CLAUDE.md",
14
+ "**/AGENTS.md",
15
+ "**/.cursorrules",
16
+ "**/.cursor/rules/**",
17
+ "**/.mcp.json",
18
+ "**/.codex/**",
19
+ "**/.github/copilot-instructions.md",
20
+ ]
21
+
22
+ STATE_DIR_ENV = "TRUSTLINE_STATE_DIR"
23
+
24
+
25
+ def default_state_dir() -> Path:
26
+ """State lives under ~/.config/trustline (override with env var)."""
27
+ override = os.environ.get(STATE_DIR_ENV)
28
+ if override:
29
+ return Path(override).expanduser()
30
+ return Path.home() / ".config" / "trustline"
31
+
32
+
33
+ def glob_match(pattern: str, relpath: str) -> bool:
34
+ """Match a relative POSIX path against a glob supporting ``**``.
35
+
36
+ ``**`` matches any number of path segments (including zero). Pure-Python,
37
+ no external dependency.
38
+ """
39
+ psegs = PurePosixPath(relpath).parts
40
+ gsegs = pattern.split("/")
41
+
42
+ def rec(gi: int, pi: int) -> bool:
43
+ if gi == len(gsegs):
44
+ return pi == len(psegs)
45
+ g = gsegs[gi]
46
+ if g == "**":
47
+ if gi == len(gsegs) - 1:
48
+ return True
49
+ for k in range(pi, len(psegs) + 1):
50
+ if rec(gi + 1, k):
51
+ return True
52
+ return False
53
+ if pi >= len(psegs):
54
+ return False
55
+ if fnmatch.fnmatchcase(psegs[pi], g):
56
+ return rec(gi + 1, pi + 1)
57
+ return False
58
+
59
+ return rec(0, 0)
60
+
61
+
62
+ def is_managed(relpath: str, patterns: list[str] | None = None) -> bool:
63
+ pats = patterns if patterns is not None else MANAGED_PATTERNS
64
+ rp = relpath.replace(os.sep, "/")
65
+ if rp.startswith("./"):
66
+ rp = rp[2:]
67
+ return any(glob_match(p, rp) for p in pats)
68
+
69
+
70
+ def normalize_relpath(root: Path, path: Path) -> str:
71
+ """Relative path from root using forward slashes."""
72
+ return path.relative_to(root).as_posix()
73
+
74
+
75
+ def state_dir_for(root: Path, config: "Config") -> Path:
76
+ """Per-project baseline path (safe dirname derived from resolved root)."""
77
+ return config.state_dir / "projects" / _safe(root)
78
+
79
+
80
+ def _safe(root: Path) -> str:
81
+ resolved = str(root.resolve()).replace(os.sep, "_").lstrip("_")
82
+ return resolved or "root"
83
+
84
+
85
+ class Config:
86
+ """Runtime configuration (CLI options override defaults)."""
87
+
88
+ def __init__(
89
+ self,
90
+ root: Path | str | None = None,
91
+ patterns: list[str] | None = None,
92
+ state_dir: Path | str | None = None,
93
+ with_copies: bool = True,
94
+ ) -> None:
95
+ self.root: Path = Path(root).resolve() if root else Path.cwd().resolve()
96
+ self.patterns: list[str] = patterns if patterns is not None else list(MANAGED_PATTERNS)
97
+ self.state_dir: Path = Path(state_dir).resolve() if state_dir else default_state_dir().resolve()
98
+ self.with_copies = with_copies
99
+
100
+ # -- derived paths ------------------------------------------------------
101
+ @property
102
+ def project_state(self) -> Path:
103
+ return state_dir_for(self.root, self)
104
+
105
+ @property
106
+ def baseline_path(self) -> Path:
107
+ return self.project_state / "baseline.json"
108
+
109
+ @property
110
+ def copies_dir(self) -> Path:
111
+ return self.project_state / "copies"
112
+
113
+ def ensure_dirs(self) -> None:
114
+ self.project_state.mkdir(parents=True, exist_ok=True)
115
+ if self.with_copies:
116
+ self.copies_dir.mkdir(parents=True, exist_ok=True)
trustline/discovery.py ADDED
@@ -0,0 +1,36 @@
1
+ """Discovery of managed agent-configuration files under a project root.
2
+
3
+ Walks the tree with pruning: directories that can never contain useful agent
4
+ rules (.git, node_modules, venv, build output, caches...) are not descended
5
+ into, which keeps scans fast on real repositories.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from pathlib import Path
12
+
13
+ from .config import Config, is_managed, normalize_relpath
14
+
15
+ SKIP_DIRS = {
16
+ ".git", ".hg", ".svn",
17
+ "node_modules", "bower_components",
18
+ ".venv", "venv", "env", ".tox", ".nox",
19
+ "target", "dist", "build", "out", ".next", ".nuxt", ".turbo",
20
+ "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".idea", ".vscode",
21
+ ".terraform", ".serverless",
22
+ }
23
+
24
+
25
+ def discover(config: Config) -> list[Path]:
26
+ found: list[Path] = []
27
+ root = config.root
28
+ for dirpath, dirnames, filenames in os.walk(root):
29
+ # prune in place
30
+ dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS and not d.startswith(".") or d == ".claude" or d == ".cursor" or d == ".codex" or d == ".github")
31
+ for fname in sorted(filenames):
32
+ p = Path(dirpath) / fname
33
+ rel = normalize_relpath(root, p)
34
+ if is_managed(rel, config.patterns):
35
+ found.append(p)
36
+ return found