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 +1 -0
- agentdetox/__main__.py +5 -0
- agentdetox/adapters.py +161 -0
- agentdetox/agentscan.py +70 -0
- agentdetox/allowlist.py +101 -0
- agentdetox/cli.py +575 -0
- agentdetox/cluster.py +111 -0
- agentdetox/compat.py +76 -0
- agentdetox/consistency.py +289 -0
- agentdetox/context.py +272 -0
- agentdetox/corpus.py +68 -0
- agentdetox/daemon.py +380 -0
- agentdetox/demo.py +118 -0
- agentdetox/doctor.py +179 -0
- agentdetox/embed.py +104 -0
- agentdetox/engine.py +317 -0
- agentdetox/events.py +50 -0
- agentdetox/findings.py +64 -0
- agentdetox/hidden.py +247 -0
- agentdetox/hooks.py +83 -0
- agentdetox/install.py +134 -0
- agentdetox/integrity.py +371 -0
- agentdetox/ledger.py +145 -0
- agentdetox/persistence.py +203 -0
- agentdetox/policy.py +82 -0
- agentdetox/provenance.py +214 -0
- agentdetox/scan.py +114 -0
- agentdetox/session.py +113 -0
- agentdetox/settings.py +141 -0
- agentdetox/shell.py +108 -0
- agentdetox/status.py +132 -0
- agentdetox/trace.py +91 -0
- agentdetox-0.2.0.dist-info/METADATA +298 -0
- agentdetox-0.2.0.dist-info/RECORD +37 -0
- agentdetox-0.2.0.dist-info/WHEEL +4 -0
- agentdetox-0.2.0.dist-info/entry_points.txt +2 -0
- agentdetox-0.2.0.dist-info/licenses/LICENSE +202 -0
agentdetox/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
agentdetox/__main__.py
ADDED
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
|
agentdetox/agentscan.py
ADDED
|
@@ -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
|
agentdetox/allowlist.py
ADDED
|
@@ -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]}"')
|