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/__init__.py
ADDED
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
|