agentdetox 0.2.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.
agentdetox/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
agentdetox/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
agentdetox/adapters.py ADDED
@@ -0,0 +1,161 @@
1
+ """Each agent's hook protocol: what its events look like and how it wants an answer.
2
+
3
+ Everything agent-specific stays here, so the engine and policy see only Writes, shell
4
+ commands and file paths. An agent without its own adapter gets Claude Code's reply
5
+ format and no ask prompt.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import re
13
+ from pathlib import Path
14
+
15
+ from .agentscan import read_text
16
+ from .engine import Write
17
+ from .policy import Decision
18
+
19
+ SHELL_TOOLS = {"bash", "shell", "exec", "exec_command", "local_shell", "run_terminal_cmd"}
20
+ _EVENTS = {"pretooluse": "pre", "posttooluse": "post", "afterfileedit": "post", "sessionstart": "session"}
21
+ _PATCH_FILE_RE = re.compile(r"^\*\*\* (Add|Update) File: (.+)$")
22
+
23
+
24
+ class Adapter:
25
+ name = "generic"
26
+ can_ask = False # whether the agent can put a question to the user mid-task
27
+ session_chars = 6000 # how much session-start context the agent takes
28
+
29
+ def event(self, payload: dict) -> str:
30
+ """pre (a tool call about to run), post (a file was written), session, or empty."""
31
+ return _EVENTS.get(str(payload.get("hook_event_name", "")).lower(), "")
32
+
33
+ def project(self, payload: dict) -> Path:
34
+ roots = payload.get("workspace_roots") or []
35
+ return Path(payload.get("cwd") or (roots[0] if roots else None) or os.getcwd())
36
+
37
+ def command(self, payload: dict) -> str | None:
38
+ """The command line, if the tool call is a shell command."""
39
+ if str(payload.get("tool_name", "")).lower() not in SHELL_TOOLS:
40
+ return None
41
+ command = (payload.get("tool_input") or {}).get("command", "")
42
+ return " ".join(map(str, command)) if isinstance(command, list) else command
43
+
44
+ def writes(self, payload: dict, project: Path) -> list[Write]:
45
+ """The file changes a tool call would make, from the edit tools' various inputs."""
46
+ tool_input = payload.get("tool_input") or {}
47
+ patch = next((v for v in tool_input.values() if isinstance(v, str) and "*** Begin Patch" in v), None)
48
+ if patch is not None:
49
+ return _patch_writes(patch, project)
50
+ path_value = next((tool_input[k] for k in ("file_path", "path", "target_file") if k in tool_input), None)
51
+ if not path_value:
52
+ return []
53
+ path = Path(path_value)
54
+ if not path.is_absolute():
55
+ path = project / path
56
+ old = read_text(path) if path.is_file() else ""
57
+ if "edits" in tool_input:
58
+ new = old
59
+ for e in tool_input["edits"]:
60
+ new = _apply_edit(new, e)
61
+ elif "old_string" in tool_input:
62
+ new = _apply_edit(old, tool_input)
63
+ else:
64
+ new = next((tool_input[k] for k in ("content", "contents", "code_edit", "new_string") if k in tool_input), "")
65
+ return [Write(path, old, str(new))]
66
+
67
+ def written(self, payload: dict, project: Path) -> list[Path]:
68
+ """Files a finished tool call wrote."""
69
+ tool_input = payload.get("tool_input") or {}
70
+ paths = [Path(p) for p in (tool_input.get("file_path"), tool_input.get("path"), payload.get("file_path")) if p]
71
+ return [p if p.is_absolute() else project / p for p in paths]
72
+
73
+ def transcript(self, payload: dict) -> str | None:
74
+ return None
75
+
76
+ def session(self, payload: dict) -> str:
77
+ """The agent's id for the session, for the ledger."""
78
+ return str(payload.get("session_id") or payload.get("conversation_id") or "")
79
+
80
+ def render_pre(self, decision: Decision) -> str:
81
+ if decision.action == "allow":
82
+ return ""
83
+ return json.dumps({"hookSpecificOutput": {
84
+ "hookEventName": "PreToolUse", "permissionDecision": decision.action,
85
+ "permissionDecisionReason": decision.reason}})
86
+
87
+ def render_session(self, text: str) -> str:
88
+ text = text[: self.session_chars]
89
+ if not text:
90
+ return ""
91
+ return json.dumps({"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": text}})
92
+
93
+ def render_allow(self) -> str:
94
+ return ""
95
+
96
+
97
+ class ClaudeCode(Adapter):
98
+ name = "claude-code"
99
+ can_ask = True
100
+ session_chars = 9000
101
+
102
+ def transcript(self, payload: dict) -> str | None:
103
+ return payload.get("transcript_path")
104
+
105
+
106
+ class Codex(Adapter):
107
+ name = "codex"
108
+
109
+
110
+ class Cursor(Adapter):
111
+ name = "cursor"
112
+ session_chars = 9000
113
+
114
+ def render_pre(self, decision: Decision) -> str:
115
+ out = {"permission": "deny" if decision.action != "allow" else "allow"}
116
+ if decision.action != "allow":
117
+ out.update(user_message=decision.reason, agent_message=decision.reason)
118
+ return json.dumps(out)
119
+
120
+ def render_session(self, text: str) -> str:
121
+ text = text[: self.session_chars]
122
+ return json.dumps({"additional_context": text} if text else {})
123
+
124
+ def render_allow(self) -> str:
125
+ # Cursor treats an empty or invalid reply from a permission hook as a block.
126
+ return json.dumps({"permission": "allow"})
127
+
128
+
129
+ ADAPTERS = {a.name: a for a in (ClaudeCode(), Codex(), Cursor())}
130
+
131
+
132
+ def get(agent: str) -> Adapter:
133
+ return ADAPTERS.get(agent) or Adapter()
134
+
135
+
136
+ def _apply_edit(text: str, edit: dict) -> str:
137
+ old, new = edit.get("old_string", ""), edit.get("new_string", "")
138
+ if not old:
139
+ return text + new
140
+ return text.replace(old, new) if edit.get("replace_all") else text.replace(old, new, 1)
141
+
142
+
143
+ def _patch_writes(patch: str, project: Path) -> list[Write]:
144
+ """Codex apply_patch: collect each file's added lines."""
145
+ writes: dict[Path, list[str]] = {}
146
+ current: Path | None = None
147
+ for line in patch.splitlines():
148
+ m = _PATCH_FILE_RE.match(line)
149
+ if m:
150
+ current = Path(m.group(2).strip())
151
+ current = current if current.is_absolute() else project / current
152
+ writes.setdefault(current, [])
153
+ elif line.startswith("*** "):
154
+ current = None
155
+ elif current is not None and line.startswith("+"):
156
+ writes[current].append(line[1:])
157
+ out = []
158
+ for path, added in writes.items():
159
+ old = read_text(path) if path.is_file() else ""
160
+ out.append(Write(path, old, old + "\n" + "\n".join(added)))
161
+ return out
@@ -0,0 +1,70 @@
1
+ """Scan an agent's persistent context files for planted instructions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from .consistency import find_contradictions, statements, store_statements
8
+ from .context import ContextFile, related_files
9
+ from .corpus import Doc
10
+ from .findings import Finding, line_at, pair_anchor, snippet
11
+ from .hidden import normalize, scan_doc
12
+ from .persistence import config_commands, text_findings
13
+
14
+
15
+ def read_text(path) -> str:
16
+ return path.read_text(encoding="utf-8", errors="replace")
17
+
18
+
19
+ def scan_text(doc_id: str, source: str, text: str, kind: str) -> list[Finding]:
20
+ """Findings for one piece of context text. `kind` is a ContextFile kind."""
21
+ if kind == "config":
22
+ return _scan_config(doc_id, source, text)
23
+ findings = scan_doc(Doc(doc_id, text, source))
24
+ norm = normalize(text)
25
+ for severity, label, pos in text_findings(text):
26
+ findings.append(Finding(doc_id, source, "persistence", severity, label, snippet(norm, pos),
27
+ anchor=line_at(norm, pos)))
28
+ return findings
29
+
30
+
31
+ def _scan_config(doc_id: str, source: str, text: str) -> list[Finding]:
32
+ findings = []
33
+ for where, command in config_commands(Path(source), text):
34
+ for severity, label, _ in text_findings(command):
35
+ findings.append(Finding(doc_id, source, "config-command", severity, f"{label} ({where})", command[:200],
36
+ anchor=command))
37
+ return findings
38
+
39
+
40
+ def consistency_findings(files: list[ContextFile], project: Path | None, nli) -> list[Finding]:
41
+ """Contradictions between files in each memory scope (one project's memory plus the
42
+ instructions that apply to it). Each conflicting pair is reported once."""
43
+ seen: set[frozenset] = set()
44
+ out: list[Finding] = []
45
+ for cf in files:
46
+ if cf.kind not in ("memory", "instructions"):
47
+ continue
48
+ own = statements(read_text(cf.path), str(cf.path))
49
+ if not own:
50
+ continue
51
+ others = store_statements(related_files(cf, project), skip={str(cf.path)})
52
+ # Sentences of one note were written together; within a file the model only adds noise.
53
+ for c in find_contradictions(own, others, nli):
54
+ key = frozenset((c.new.text, c.existing.text))
55
+ if key in seen:
56
+ continue
57
+ seen.add(key)
58
+ out.append(Finding(str(cf.path), str(cf.path), "contradiction", "medium",
59
+ f'contradicts "{c.existing.text[:120]}" ({c.existing.source}:{c.existing.line}), '
60
+ f"p={c.probability:.2f}", c.new.text[:200],
61
+ anchor=pair_anchor(c.new.text, c.existing.text)))
62
+ return out
63
+
64
+
65
+ def scan_context(files: list[ContextFile]) -> list[Finding]:
66
+ findings: list[Finding] = []
67
+ for cf in files:
68
+ text = read_text(cf.path)
69
+ findings.extend(scan_text(str(cf.path), str(cf.path), text, cf.kind))
70
+ return findings
@@ -0,0 +1,101 @@
1
+ """Findings the user reviewed and allowed: "always allow this one".
2
+
3
+ Ids come from what was flagged (the line, the conflicting pair, the untrusted source),
4
+ not from where or when, so one `agentdetox allow <id>` covers the write check, the
5
+ session notice and `agentdetox agent scan`. The file sits with the snapshots, and the
6
+ hooks make an agent ask before changing it, or an injected instruction could allow
7
+ itself.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import re
14
+ import time
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+
18
+ from .consistency import Contradiction
19
+ from .findings import ID_LENGTH, Finding, finding_id, make_id, pair_anchor
20
+ from .integrity import private_dir, store_dir, store_lock, write_private
21
+ from .provenance import CopyFinding
22
+
23
+ ID_RE = re.compile(rf"^[0-9a-f]{{{ID_LENGTH}}}$")
24
+ SCHEMA_VERSION = 1
25
+
26
+
27
+ @dataclass
28
+ class Flag:
29
+ id: str
30
+ what: str
31
+
32
+
33
+ def path() -> Path:
34
+ return store_dir() / "allowlist.json"
35
+
36
+
37
+ def entries() -> dict[str, dict]:
38
+ """Allowed ids. An unreadable file allows nothing: failing closed only costs a prompt."""
39
+ p = path()
40
+ try:
41
+ data = json.loads(p.read_text(encoding="utf-8")) if p.exists() else {}
42
+ except (OSError, ValueError):
43
+ return {}
44
+ if not isinstance(data, dict) or data.get("version", SCHEMA_VERSION) > SCHEMA_VERSION:
45
+ return {}
46
+ allowed = data.get("allowed")
47
+ return allowed if isinstance(allowed, dict) else {}
48
+
49
+
50
+ def _save(allowed: dict[str, dict]) -> None:
51
+ p = path()
52
+ if p.exists():
53
+ try:
54
+ data = json.loads(p.read_text(encoding="utf-8"))
55
+ except ValueError:
56
+ raise ValueError(f"{p} is not valid JSON; fix or delete it first") from None
57
+ if isinstance(data, dict) and data.get("version", SCHEMA_VERSION) > SCHEMA_VERSION:
58
+ raise ValueError(f"{p} was written by a newer AgentDetox; upgrade agentdetox")
59
+ tmp = private_dir(store_dir()) / "allowlist.json.tmp"
60
+ write_private(tmp, json.dumps({"version": SCHEMA_VERSION, "allowed": allowed}, indent=1,
61
+ ensure_ascii=False).encode())
62
+ tmp.replace(p)
63
+
64
+
65
+ def add(flag_id: str, what: str = "", note: str = "") -> None:
66
+ with store_lock():
67
+ allowed = entries()
68
+ allowed[flag_id] = {"what": what, "note": note, "added": time.strftime("%Y-%m-%dT%H:%M:%S")}
69
+ _save(allowed)
70
+
71
+
72
+ def remove(flag_id: str) -> bool:
73
+ with store_lock():
74
+ allowed = entries()
75
+ if allowed.pop(flag_id, None) is None:
76
+ return False
77
+ _save(allowed)
78
+ return True
79
+
80
+
81
+ def unsuppressed(findings: list[Finding]) -> list[Finding]:
82
+ allowed = entries()
83
+ return [f for f in findings if finding_id(f) not in allowed] if allowed else findings
84
+
85
+
86
+ def finding_flag(f: Finding) -> Flag:
87
+ detail = " ".join((f.anchor or f.evidence).split())[:100]
88
+ return Flag(finding_id(f), f"{f.message}" + (f" | {detail}" if detail and detail != f.message else ""))
89
+
90
+
91
+ def source_id(tool: str, origin: str) -> str:
92
+ return make_id("copied", tool, origin)
93
+
94
+
95
+ def copy_flag(c: CopyFinding) -> Flag:
96
+ return Flag(source_id(c.tool, c.origin), f"copied from {c.tool} {c.origin}".strip())
97
+
98
+
99
+ def contradiction_flag(c: Contradiction) -> Flag:
100
+ return Flag(make_id("contradiction", pair_anchor(c.new.text, c.existing.text)),
101
+ f'"{c.new.text[:100]}" contradicts "{c.existing.text[:100]}"')