rulewright 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.
Files changed (50) hide show
  1. rulewright/__init__.py +1 -0
  2. rulewright/cli.py +490 -0
  3. rulewright/cluster/__init__.py +0 -0
  4. rulewright/cluster/embed.py +66 -0
  5. rulewright/cluster/group.py +93 -0
  6. rulewright/config.py +19 -0
  7. rulewright/guard/__init__.py +0 -0
  8. rulewright/guard/emit.py +75 -0
  9. rulewright/guard/profile.py +172 -0
  10. rulewright/ingest/__init__.py +0 -0
  11. rulewright/ingest/git_history.py +185 -0
  12. rulewright/ingest/reviews.py +1 -0
  13. rulewright/ingest/sessions/__init__.py +0 -0
  14. rulewright/ingest/sessions/base.py +47 -0
  15. rulewright/ingest/sessions/claude_code.py +138 -0
  16. rulewright/ingest/sessions/codex.py +112 -0
  17. rulewright/mine/__init__.py +0 -0
  18. rulewright/mine/detectors.py +256 -0
  19. rulewright/mine/score.py +28 -0
  20. rulewright/mine/signals.py +20 -0
  21. rulewright/pipeline.py +146 -0
  22. rulewright/pr.py +123 -0
  23. rulewright/propose/__init__.py +0 -0
  24. rulewright/propose/guards.py +131 -0
  25. rulewright/propose/llm.py +141 -0
  26. rulewright/propose/redact.py +44 -0
  27. rulewright/propose/writer.py +240 -0
  28. rulewright/prove/__init__.py +0 -0
  29. rulewright/prove/agent.py +131 -0
  30. rulewright/prove/judge.py +62 -0
  31. rulewright/prove/runner.py +198 -0
  32. rulewright/prove/sandbox.py +138 -0
  33. rulewright/prove/tasks.py +85 -0
  34. rulewright/report.py +63 -0
  35. rulewright/rules/__init__.py +0 -0
  36. rulewright/rules/emit/__init__.py +55 -0
  37. rulewright/rules/emit/agents_md.py +9 -0
  38. rulewright/rules/emit/base.py +74 -0
  39. rulewright/rules/emit/claude_md.py +9 -0
  40. rulewright/rules/emit/copilot.py +9 -0
  41. rulewright/rules/emit/cursor.py +12 -0
  42. rulewright/rules/model.py +24 -0
  43. rulewright/rules/source.py +127 -0
  44. rulewright/rules/stale.py +142 -0
  45. rulewright/store.py +56 -0
  46. rulewright-0.1.0.dist-info/METADATA +144 -0
  47. rulewright-0.1.0.dist-info/RECORD +50 -0
  48. rulewright-0.1.0.dist-info/WHEEL +4 -0
  49. rulewright-0.1.0.dist-info/entry_points.txt +2 -0
  50. rulewright-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,75 @@
1
+ """Add allow rules to a Claude Code settings file without disturbing anything else in it."""
2
+
3
+ import difflib
4
+ import json
5
+ import os
6
+ import tempfile
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from rulewright.guard.profile import Pattern
11
+
12
+
13
+ class SettingsError(Exception):
14
+ """The settings file is not something we can safely edit."""
15
+
16
+
17
+ def settings_path(repo: Path, local: bool = False) -> Path:
18
+ name = "settings.local.json" if local else "settings.json"
19
+ return repo / ".claude" / name
20
+
21
+
22
+ def rule_for(pattern: Pattern) -> str:
23
+ return f"Bash({pattern.pattern}:*)"
24
+
25
+
26
+ def load_settings(path: Path) -> dict[str, Any]:
27
+ if not path.exists():
28
+ return {}
29
+ try:
30
+ data = json.loads(path.read_text(encoding="utf-8"))
31
+ except (OSError, ValueError) as e:
32
+ msg = f"{path} is not valid JSON ({e}); fix it first, nothing was changed."
33
+ raise SettingsError(msg) from None
34
+ if not isinstance(data, dict):
35
+ raise SettingsError(f"{path} does not hold a JSON object; nothing was changed.")
36
+ perms = data.get("permissions", {})
37
+ allow = perms.get("allow", []) if isinstance(perms, dict) else None
38
+ if not isinstance(allow, list):
39
+ raise SettingsError(f"{path} has an unexpected `permissions.allow`; nothing was changed.")
40
+ return data
41
+
42
+
43
+ def with_rules(settings: dict[str, Any], rules: list[str]) -> tuple[dict[str, Any], list[str]]:
44
+ """(new settings, the rules actually added). Existing rules and other keys are untouched."""
45
+ new = json.loads(json.dumps(settings))
46
+ perms = new.setdefault("permissions", {})
47
+ allow: list[Any] = perms.setdefault("allow", [])
48
+ added = [r for r in dict.fromkeys(rules) if r not in allow]
49
+ allow.extend(added)
50
+ return new, added
51
+
52
+
53
+ def render(settings: dict[str, Any]) -> str:
54
+ return json.dumps(settings, indent=2, ensure_ascii=False) + "\n"
55
+
56
+
57
+ def unified_diff(old: str, new: str, name: str) -> str:
58
+ return "".join(
59
+ difflib.unified_diff(
60
+ old.splitlines(keepends=True), new.splitlines(keepends=True),
61
+ fromfile=f"a/{name}", tofile=f"b/{name}",
62
+ )
63
+ ) # fmt: skip
64
+
65
+
66
+ def write_atomic(path: Path, text: str) -> None:
67
+ path.parent.mkdir(parents=True, exist_ok=True)
68
+ fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=path.name, suffix=".tmp")
69
+ try:
70
+ with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as fh:
71
+ fh.write(text)
72
+ os.replace(tmp, path)
73
+ except BaseException:
74
+ Path(tmp).unlink(missing_ok=True)
75
+ raise
@@ -0,0 +1,172 @@
1
+ """Turn the shell commands an agent ran into a minimal, safe permission allowlist.
2
+
3
+ No LLM is involved and no command is executed. A pattern only reaches the "safe" tier by being
4
+ one of our own curated prefixes (SAFE_PREFIXES), so text from a log can never become an allow
5
+ rule on its own. Everything else that was run often is reported as "review" (a person decides)
6
+ or "never" (too broad or too dangerous to offer at all).
7
+ """
8
+
9
+ import re
10
+ import shlex
11
+ from collections import defaultdict
12
+ from datetime import datetime
13
+ from typing import Literal
14
+
15
+ from pydantic import BaseModel
16
+
17
+ from rulewright.ingest.sessions.base import ToolCall
18
+ from rulewright.propose.redact import redact
19
+
20
+ Tier = Literal["safe", "review", "never"]
21
+
22
+ MIN_USES = 3
23
+ # Tools whose commands are POSIX shell, i.e. what a `Bash(...)` rule matches. Codex's
24
+ # `shell_command` is PowerShell on Windows, so it is left out and reported instead.
25
+ BASH_TOOLS = {"Bash", "shell", "exec_command", "local_shell"}
26
+
27
+ # Read-only, test, lint and build commands. Longest match wins; nothing here can delete,
28
+ # publish, install or reach the network on its own.
29
+ SAFE_PREFIXES: tuple[tuple[str, ...], ...] = (
30
+ ("pwd",), ("ls",), ("cd",), ("wc",),
31
+ ("git", "status"), ("git", "diff"), ("git", "log"), ("git", "show"),
32
+ ("git", "rev-parse"), ("git", "ls-files"), ("git", "blame"),
33
+ ("pytest",), ("python", "-m", "pytest"), ("uv", "run", "pytest"),
34
+ ("ruff", "check"), ("uv", "run", "ruff", "check"),
35
+ ("mypy",), ("uv", "run", "mypy"),
36
+ ("npm", "test"), ("npm", "run", "test"), ("npm", "run", "lint"),
37
+ ("npm", "run", "typecheck"), ("npm", "run", "build"),
38
+ ("pnpm", "test"), ("pnpm", "run", "test"), ("pnpm", "run", "lint"),
39
+ ("pnpm", "run", "typecheck"), ("pnpm", "run", "build"),
40
+ ("yarn", "test"), ("yarn", "lint"), ("yarn", "build"),
41
+ ("cargo", "test"), ("cargo", "check"), ("cargo", "clippy"), ("cargo", "build"),
42
+ ("go", "test"), ("go", "vet"), ("go", "build"),
43
+ ("make", "test"), ("make", "lint"),
44
+ ) # fmt: skip
45
+
46
+ # A rule for these would allow arbitrary code or arbitrary deletion, whatever follows.
47
+ NEVER_PROGRAMS = {
48
+ "python", "python3", "py", "node", "deno", "bun", "ruby", "perl", "php", "bash", "sh", "zsh",
49
+ "pwsh", "powershell", "cmd", "npx", "uvx", "pipx", "eval", "exec", "xargs", "env", "find",
50
+ "awk", "sed", "rm", "rmdir", "sudo", "su", "chmod", "chown", "dd", "mkfs", "kill", "pkill",
51
+ "curl", "wget", "nc", "ncat", "ssh", "scp", "sftp", "ftp", "docker", "podman", "kubectl",
52
+ "terraform", "mv", "cp", "tee", "xdg-open", "open", "start",
53
+ # wrappers: allowing `timeout` would allow `timeout 5 rm -rf x`
54
+ "timeout", "nice", "nohup", "time", "command", "builtin", "stdbuf", "watch", "setsid", "doas",
55
+ } # fmt: skip
56
+ NEVER_PAIRS = {
57
+ ("git", "push"), ("git", "reset"), ("git", "clean"), ("git", "checkout"), ("git", "rebase"),
58
+ ("git", "restore"), ("git", "stash"), ("git", "branch"), ("git", "config"), ("git", "remote"),
59
+ ("npm", "install"), ("npm", "publish"), ("npm", "exec"), ("pip", "install"),
60
+ ("uv", "run"), ("uv", "add"), ("uv", "publish"), ("gh", "api"), ("gh", "auth"),
61
+ } # fmt: skip
62
+
63
+ # Programs whose first argument is a subcommand; for any other, an argument is a file or a value.
64
+ SUBCOMMAND_PROGRAMS = {
65
+ "git", "npm", "pnpm", "yarn", "cargo", "go", "gh", "make", "uv", "pip", "pip3", "poetry",
66
+ "brew", "apt", "apt-get", "systemctl", "dotnet", "mvn", "gradle", "bundle", "rake",
67
+ } # fmt: skip
68
+ # Redirects that cannot write anywhere: stderr into stdout, or output thrown away.
69
+ _HARMLESS_REDIRECT = re.compile(r"\s\d?>&\d(?=\s|$)|\s\d?>>?\s*/dev/null(?=\s|$)")
70
+ _OPERATORS = {";", "&&", "||", "|", "&", "|&"}
71
+ _WORD = re.compile(r"^[A-Za-z][\w.+-]*$")
72
+
73
+
74
+ class Pattern(BaseModel):
75
+ pattern: str # e.g. "git status"; the rule is `Bash(git status:*)`
76
+ tier: Tier
77
+ uses: int
78
+ sessions: int
79
+ failures: int
80
+ denied: int
81
+ last_used: datetime
82
+ example: str # one redacted, shortened command
83
+
84
+
85
+ class Profile(BaseModel):
86
+ commands_seen: int
87
+ skipped_other_shell: int # PowerShell and the like: not matched by a Bash(...) rule
88
+ patterns: list[Pattern]
89
+
90
+ def tier(self, tier: Tier) -> list[Pattern]:
91
+ return [p for p in self.patterns if p.tier == tier]
92
+
93
+
94
+ def split_command(command: str) -> list[list[str]] | None:
95
+ """Simple commands joined by &&, ||, ; or |. None when it cannot be judged safely:
96
+ unbalanced quotes, command substitution, redirects, here-documents, env assignments."""
97
+ if "$(" in command or "`" in command or "<(" in command or ">(" in command:
98
+ return None
99
+ command = _HARMLESS_REDIRECT.sub("", command)
100
+ try:
101
+ lexer = shlex.shlex(command, posix=True, punctuation_chars=True)
102
+ lexer.whitespace_split = True
103
+ tokens = list(lexer)
104
+ except ValueError:
105
+ return None
106
+ parts: list[list[str]] = [[]]
107
+ for tok in tokens:
108
+ if tok in _OPERATORS:
109
+ parts.append([])
110
+ elif set(tok) <= set("<>&|;()") and tok:
111
+ return None # a redirect, subshell or odd operator
112
+ else:
113
+ parts[-1].append(tok)
114
+ parts = [p for p in parts if p]
115
+ if not parts or any("=" in p[0] and not p[0].startswith("-") for p in parts):
116
+ return None # empty, or FOO=bar cmd: the environment changes what the command does
117
+ return parts
118
+
119
+
120
+ def classify(tokens: list[str]) -> tuple[str, Tier]:
121
+ """(pattern, tier) for one simple command."""
122
+ best = max(
123
+ (p for p in SAFE_PREFIXES if tuple(tokens[: len(p)]) == p), key=len, default=None
124
+ )
125
+ if best is not None:
126
+ return " ".join(best), "safe"
127
+ program = tokens[0]
128
+ if "/" in program or "\\" in program or not _WORD.match(program):
129
+ return program, "never" # a path or odd name: not something to allow by prefix
130
+ takes_sub = program in SUBCOMMAND_PROGRAMS and len(tokens) > 1 and _WORD.match(tokens[1])
131
+ sub = tokens[1] if takes_sub else ""
132
+ pattern = f"{program} {sub}".strip()
133
+ if program.lower() in NEVER_PROGRAMS or (program, sub) in NEVER_PAIRS:
134
+ return pattern, "never"
135
+ return pattern, "review"
136
+
137
+
138
+ def build_profile(calls: list[ToolCall], min_uses: int = MIN_USES) -> Profile:
139
+ usable = [c for c in calls if c.tool in BASH_TOOLS]
140
+ seen: dict[tuple[str, Tier], list[tuple[ToolCall, str]]] = defaultdict(list)
141
+ for call in usable:
142
+ parts = split_command(call.command)
143
+ if parts is None:
144
+ continue
145
+ for tokens in parts:
146
+ pattern, tier = classify(tokens)
147
+ seen[(pattern, tier)].append((call, " ".join(tokens)))
148
+ patterns = []
149
+ for (pattern, tier), hits in seen.items():
150
+ if len(hits) < min_uses:
151
+ continue
152
+ calls_for = [c for c, _ in hits]
153
+ patterns.append(
154
+ Pattern(
155
+ pattern=pattern, tier=tier, uses=len(hits),
156
+ sessions=len({c.session_id for c in calls_for}),
157
+ failures=sum(c.failed for c in calls_for),
158
+ denied=sum(c.denied for c in calls_for),
159
+ last_used=max(c.ts for c in calls_for),
160
+ example=redact(hits[-1][1])[:100],
161
+ )
162
+ ) # fmt: skip
163
+ patterns.sort(key=lambda p: (-p.uses, p.pattern))
164
+ return Profile(
165
+ commands_seen=len(usable), skipped_other_shell=len(calls) - len(usable), patterns=patterns
166
+ )
167
+
168
+
169
+ def allowable(profile: Profile, include_review: bool = False) -> list[Pattern]:
170
+ """Patterns that may be written: never one the person refused, never the 'never' tier."""
171
+ tiers: set[Tier] = {"safe", "review"} if include_review else {"safe"}
172
+ return [p for p in profile.patterns if p.tier in tiers and p.denied == 0]
File without changes
@@ -0,0 +1,185 @@
1
+ """Turn `git log` into uniform commit events. No interpretation beyond agent attribution."""
2
+
3
+ import re
4
+ import subprocess
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+
8
+ from pydantic import BaseModel
9
+
10
+ _AGENTS = "claude|codex|copilot|cursor|devin|aider|openhands|jules|gemini|sweep"
11
+ _AGENT_TRAILER = re.compile(rf"^co-authored-by:.*\b({_AGENTS})\b", re.I | re.M)
12
+ # A bot counts only when it names a coding agent: release and dependency bots are not agents.
13
+ _AGENT_AUTHOR = re.compile(
14
+ rf"(?:{_AGENTS})[\w.+-]*\[bot\]|noreply@anthropic\.com|\+(?:copilot|claude)@users\.noreply",
15
+ re.I,
16
+ )
17
+ _REC = "\x1e" # starts a commit record
18
+ _FLD = "\x1f" # separates header fields
19
+ _END = "\x1d" # ends the header; numstat and patch follow
20
+ MAX_PATCH_CHARS = 20_000
21
+ MAX_LINES_PER_FILE = 1000 # per direction; keeps huge generated files from dominating memory
22
+ MAX_LINE_CHARS = 200
23
+ # Never evidence of an agent mistake and often huge, so git does not even print them.
24
+ _EXCLUDE = [
25
+ f":(exclude,glob)**/{pattern}"
26
+ for pattern in (
27
+ "package-lock.json", "pnpm-lock.yaml", "yarn.lock", "Cargo.lock", "uv.lock",
28
+ "poetry.lock", "Gemfile.lock", "composer.lock", "go.sum", "*.lock", "*.min.js",
29
+ "*.min.css", "*.snap", "*.svg", "*.map",
30
+ )
31
+ ] # fmt: skip
32
+
33
+ Range = tuple[int, int] # inclusive line range
34
+
35
+
36
+ class FileChange(BaseModel):
37
+ path: str
38
+ added: int
39
+ deleted: int
40
+ old_ranges: list[Range] = [] # lines removed or replaced, in the parent's numbering
41
+ new_ranges: list[Range] = [] # lines added or replaced, in this commit's numbering
42
+ added_lines: list[str] = [] # text of added lines (capped), untrusted
43
+ removed_lines: list[str] = [] # text of removed lines (capped), untrusted
44
+
45
+
46
+ class Commit(BaseModel):
47
+ sha: str
48
+ parents: list[str]
49
+ author_name: str
50
+ author_email: str
51
+ ts: datetime
52
+ subject: str
53
+ body: str
54
+ files: list[FileChange]
55
+ is_agent: bool
56
+ patch: str = "" # truncated unified diff, untrusted
57
+
58
+ @property
59
+ def added(self) -> int:
60
+ return sum(f.added for f in self.files)
61
+
62
+ @property
63
+ def deleted(self) -> int:
64
+ return sum(f.deleted for f in self.files)
65
+
66
+
67
+ def is_agent_commit(author_name: str, author_email: str, body: str) -> bool:
68
+ author = f"{author_name} <{author_email}>"
69
+ return bool(_AGENT_TRAILER.search(body) or _AGENT_AUTHOR.search(author))
70
+
71
+
72
+ def _git(repo: Path, *args: str) -> str:
73
+ return subprocess.run(
74
+ ["git", "-C", str(repo), *args],
75
+ check=True,
76
+ capture_output=True,
77
+ text=True,
78
+ encoding="utf-8",
79
+ ).stdout
80
+
81
+
82
+ def read_commits(repo: Path, limit: int = 500) -> list[Commit]:
83
+ """Return up to `limit` recent commits, oldest first."""
84
+ fmt = _FLD.join(["%H", "%P", "%an", "%ae", "%aI", "%s", "%b"]) + _END
85
+ out = _git(
86
+ repo, "log", f"-n{limit}", "--no-renames", "--numstat", "-p", "-U0",
87
+ f"--format={_REC}{fmt}", "--", ".", *_EXCLUDE,
88
+ ) # fmt: skip
89
+ commits: list[Commit] = []
90
+ for rec in out.split(_REC)[1:]:
91
+ header, _, rest = rec.partition(_END)
92
+ sha, parents, name, email, ts, subject, body = header.split(_FLD)
93
+ body = body.strip()
94
+ stat, patch = _split_stat_patch(rest)
95
+ commits.append(
96
+ Commit(
97
+ sha=sha,
98
+ parents=parents.split(),
99
+ author_name=name,
100
+ author_email=email,
101
+ ts=datetime.fromisoformat(ts),
102
+ subject=subject,
103
+ body=body,
104
+ files=_parse_files(stat, patch),
105
+ is_agent=is_agent_commit(name, email, body),
106
+ patch=patch[:MAX_PATCH_CHARS],
107
+ )
108
+ )
109
+ commits.reverse()
110
+ return commits
111
+
112
+
113
+ _NUMSTAT = re.compile(r"^(\d+|-)\t(\d+|-)\t(.+)$")
114
+ _DIFF_HEADER = re.compile(r"^diff --git a/.+ b/(.+)$")
115
+ _HUNK = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
116
+
117
+
118
+ def _split_stat_patch(text: str) -> tuple[str, str]:
119
+ idx = text.find("diff --git ")
120
+ if idx == -1:
121
+ return text, ""
122
+ return text[:idx], text[idx:]
123
+
124
+
125
+ def _span(start: str, length: str | None) -> Range | None:
126
+ n = 1 if length is None else int(length)
127
+ s = int(start)
128
+ return (s, s + n - 1) if n > 0 else None
129
+
130
+
131
+ class _Hunks:
132
+ def __init__(self) -> None:
133
+ self.old: list[Range] = []
134
+ self.new: list[Range] = []
135
+ self.added: list[str] = []
136
+ self.removed: list[str] = []
137
+
138
+
139
+ def _parse_hunks(patch: str) -> dict[str, _Hunks]:
140
+ hunks: dict[str, _Hunks] = {}
141
+ current: _Hunks | None = None
142
+ for line in patch.splitlines():
143
+ m = _DIFF_HEADER.match(line)
144
+ if m:
145
+ current = hunks[m.group(1)] = _Hunks()
146
+ continue
147
+ if current is None:
148
+ continue
149
+ h = _HUNK.match(line)
150
+ if h:
151
+ old = _span(h.group(1), h.group(2))
152
+ new = _span(h.group(3), h.group(4))
153
+ if old:
154
+ current.old.append(old)
155
+ if new:
156
+ current.new.append(new)
157
+ elif line.startswith("+") and not line.startswith("+++"):
158
+ if len(current.added) < MAX_LINES_PER_FILE:
159
+ current.added.append(line[1:].strip()[:MAX_LINE_CHARS])
160
+ elif line.startswith("-") and not line.startswith("---"):
161
+ if len(current.removed) < MAX_LINES_PER_FILE:
162
+ current.removed.append(line[1:].strip()[:MAX_LINE_CHARS])
163
+ return hunks
164
+
165
+
166
+ def _parse_files(stat: str, patch: str) -> list[FileChange]:
167
+ hunks = _parse_hunks(patch)
168
+ changes = []
169
+ for line in stat.splitlines():
170
+ m = _NUMSTAT.match(line)
171
+ if m:
172
+ a, d, path = m.groups()
173
+ h = hunks.get(path) or _Hunks()
174
+ changes.append(
175
+ FileChange(
176
+ path=path,
177
+ added=0 if a == "-" else int(a),
178
+ deleted=0 if d == "-" else int(d),
179
+ old_ranges=h.old,
180
+ new_ranges=h.new,
181
+ added_lines=h.added,
182
+ removed_lines=h.removed,
183
+ )
184
+ )
185
+ return changes
@@ -0,0 +1 @@
1
+ """PR review comments via the GitHub API. Optional: needs a GitHub token. Phase 2, not started."""
File without changes
@@ -0,0 +1,47 @@
1
+ """Plugin interface for agent session-log readers."""
2
+
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+ from typing import Literal, Protocol
6
+
7
+ from pydantic import BaseModel
8
+
9
+
10
+ class SessionMessage(BaseModel):
11
+ """One human or agent text message. Text is untrusted data."""
12
+
13
+ session_id: str
14
+ uuid: str
15
+ role: Literal["user", "assistant"]
16
+ text: str
17
+ ts: datetime
18
+
19
+
20
+ class SessionSource(Protocol):
21
+ """Reads the session logs one agent keeps for a repo. Add a new agent by adding a reader."""
22
+
23
+ name: str
24
+
25
+ def read(self, repo: Path) -> list[SessionMessage]:
26
+ """Messages for `repo`, ordered by time. Empty when the agent has no logs there."""
27
+ ...
28
+
29
+
30
+ class ToolCall(BaseModel):
31
+ """One shell command an agent ran. The command is untrusted data and is never executed."""
32
+
33
+ session_id: str
34
+ agent: str # which reader produced it, e.g. "claude_code"
35
+ tool: str # "Bash" for Claude Code; the shell tool name for others
36
+ command: str
37
+ ts: datetime
38
+ failed: bool = False # the tool reported an error
39
+ denied: bool = False # the person refused it when asked
40
+
41
+
42
+ class ToolCallSource(Protocol):
43
+ """Reads the commands one agent ran for a repo. Used by `guard`, not by `scan`."""
44
+
45
+ name: str
46
+
47
+ def read_calls(self, repo: Path) -> list[ToolCall]: ...
@@ -0,0 +1,138 @@
1
+ """Claude Code transcript reader.
2
+
3
+ Transcripts live in ~/.claude/projects/<project-slug>/<session-id>.jsonl, one JSON object per
4
+ line. The slug is the project path with every non-alphanumeric character replaced by "-".
5
+ Records of type "user" and "assistant" carry `message.content` as a string or a list of blocks;
6
+ only "text" blocks are kept (tool calls and tool results are skipped). Unknown or malformed
7
+ lines are skipped so a format change degrades to fewer messages, not a crash.
8
+ """
9
+
10
+ import json
11
+ import re
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from rulewright.ingest.sessions.base import SessionMessage, ToolCall
17
+
18
+
19
+ def project_slug(repo: Path) -> str:
20
+ return re.sub(r"[^A-Za-z0-9]", "-", str(repo.resolve()))
21
+
22
+
23
+ DENIED_MARKERS = ("doesn't want to proceed", "was rejected", "user rejected")
24
+
25
+
26
+ def _result_text(content: Any) -> str:
27
+ return content if isinstance(content, str) else _text_of(content)
28
+
29
+
30
+ def _text_of(content: Any) -> str:
31
+ if isinstance(content, str):
32
+ return content
33
+ if isinstance(content, list):
34
+ parts = [
35
+ b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"
36
+ ]
37
+ return "\n".join(p for p in parts if isinstance(p, str))
38
+ return ""
39
+
40
+
41
+ class ClaudeCodeSource:
42
+ name = "claude_code"
43
+
44
+ def __init__(self, projects_dir: Path | None = None) -> None:
45
+ self.projects_dir = projects_dir or Path.home() / ".claude" / "projects"
46
+
47
+ def find_dir(self, repo: Path) -> Path | None:
48
+ slug = project_slug(repo).lower() # drive letter case differs between tools on Windows
49
+ if not self.projects_dir.is_dir():
50
+ return None
51
+ for d in self.projects_dir.iterdir():
52
+ if d.is_dir() and d.name.lower() == slug:
53
+ return d
54
+ return None
55
+
56
+ def read(self, repo: Path) -> list[SessionMessage]:
57
+ d = self.find_dir(repo)
58
+ if d is None:
59
+ return []
60
+ messages: list[SessionMessage] = []
61
+ for path in sorted(d.glob("*.jsonl")):
62
+ messages.extend(self._read_file(path))
63
+ messages.sort(key=lambda m: m.ts)
64
+ return messages
65
+
66
+ def _read_file(self, path: Path) -> list[SessionMessage]:
67
+ out: list[SessionMessage] = []
68
+ with path.open(encoding="utf-8", errors="replace") as fh:
69
+ for line in fh:
70
+ msg = self._parse(line, path.stem)
71
+ if msg is not None:
72
+ out.append(msg)
73
+ return out
74
+
75
+ @staticmethod
76
+ def _parse(line: str, default_session: str) -> SessionMessage | None:
77
+ try:
78
+ rec = json.loads(line)
79
+ if rec.get("type") not in ("user", "assistant") or rec.get("isSidechain"):
80
+ return None
81
+ if rec.get("isMeta"):
82
+ return None
83
+ text = _text_of(rec["message"].get("content")).strip()
84
+ if not text:
85
+ return None
86
+ return SessionMessage(
87
+ session_id=rec.get("sessionId", default_session),
88
+ uuid=rec["uuid"],
89
+ role=rec["type"],
90
+ text=text,
91
+ ts=datetime.fromisoformat(rec["timestamp"].replace("Z", "+00:00")),
92
+ )
93
+ except (ValueError, KeyError, TypeError, AttributeError):
94
+ return None
95
+
96
+ def read_calls(self, repo: Path) -> list[ToolCall]:
97
+ """Bash commands with whether each failed or was refused (matched by tool_use_id)."""
98
+ d = self.find_dir(repo)
99
+ if d is None:
100
+ return []
101
+ calls: list[ToolCall] = []
102
+ for path in sorted(d.glob("*.jsonl")):
103
+ calls.extend(self._calls_in(path))
104
+ calls.sort(key=lambda c: c.ts)
105
+ return calls
106
+
107
+ @staticmethod
108
+ def _calls_in(path: Path) -> list[ToolCall]:
109
+ pending: dict[str, ToolCall] = {}
110
+ with path.open(encoding="utf-8", errors="replace") as fh:
111
+ for line in fh:
112
+ try:
113
+ rec = json.loads(line)
114
+ content = rec["message"]["content"]
115
+ if rec.get("isSidechain") or not isinstance(content, list):
116
+ continue
117
+ when = datetime.fromisoformat(rec["timestamp"].replace("Z", "+00:00"))
118
+ session = rec.get("sessionId", path.stem)
119
+ except (ValueError, KeyError, TypeError, AttributeError):
120
+ continue
121
+ for block in content:
122
+ if not isinstance(block, dict):
123
+ continue
124
+ kind = block.get("type")
125
+ if kind == "tool_use" and block.get("name") == "Bash":
126
+ command = (block.get("input") or {}).get("command")
127
+ if isinstance(command, str) and isinstance(block.get("id"), str):
128
+ pending[block["id"]] = ToolCall(
129
+ session_id=session, agent="claude_code", tool="Bash",
130
+ command=command, ts=when,
131
+ ) # fmt: skip
132
+ elif kind == "tool_result" and block.get("tool_use_id") in pending:
133
+ call = pending[block["tool_use_id"]]
134
+ if block.get("is_error"):
135
+ text = _result_text(block.get("content")).lower()
136
+ call.denied = any(m in text for m in DENIED_MARKERS)
137
+ call.failed = not call.denied
138
+ return list(pending.values())