tare-cli 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.
- tare/__init__.py +1 -0
- tare/agents.py +672 -0
- tare/calibrate.py +98 -0
- tare/capsule.py +246 -0
- tare/cli.py +312 -0
- tare/cliff.py +183 -0
- tare/dashboard.html +269 -0
- tare/dashboard.py +216 -0
- tare/fake.py +183 -0
- tare/journal.py +35 -0
- tare/judge.py +108 -0
- tare/overview.html +78 -0
- tare/probe.py +273 -0
- tare/recipe.py +102 -0
- tare/room.py +67 -0
- tare/swap.py +219 -0
- tare/trail.py +203 -0
- tare/usage.py +237 -0
- tare_cli-0.2.0.dist-info/METADATA +505 -0
- tare_cli-0.2.0.dist-info/RECORD +23 -0
- tare_cli-0.2.0.dist-info/WHEEL +4 -0
- tare_cli-0.2.0.dist-info/entry_points.txt +2 -0
- tare_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
tare/probe.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""tare probe: measure the room before the run (CONCEPT.md, layer 3).
|
|
2
|
+
|
|
3
|
+
Three readings, none of which asks the agent anything:
|
|
4
|
+
|
|
5
|
+
- context: the real CLI runs inside the room against a fake model endpoint, which
|
|
6
|
+
keeps the request, i.e. the context the harness actually assembled;
|
|
7
|
+
- control: the same run in the user's real setup (the dirty twin). The probe must find
|
|
8
|
+
the user's context there, or it is blind for that class;
|
|
9
|
+
- reach: a plain script inside the room looks for the user's files and inherited
|
|
10
|
+
environment variables; the same script outside is its control.
|
|
11
|
+
"""
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import subprocess
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from .agents import Capture, Real
|
|
20
|
+
from .fake import Fake
|
|
21
|
+
from .room import ROOM_HOME, TareError, bwrap, room_env, room_home
|
|
22
|
+
|
|
23
|
+
SECRET_PATHS = (".claude", ".claude.json", ".codex", ".agents", ".config/gh", ".ssh", ".aws", ".netrc",
|
|
24
|
+
".git-credentials", ".docker/config.json")
|
|
25
|
+
REACH_SCRIPT = ('for p in "$@"; do if [ -e "$p" ]; then echo "path $p"; fi; done; '
|
|
26
|
+
'env | cut -d= -f1 | sed "s/^/env /"')
|
|
27
|
+
SHELL_ENV = {"PWD", "OLDPWD", "SHLVL", "_"}
|
|
28
|
+
LABELS = {"instructions": "global instructions", "memories": "memories"}
|
|
29
|
+
# An offered MCP tool: an entry of the tools array, or a whole line of a deferred-tools
|
|
30
|
+
# listing (tool search). A mention inside prose, e.g. in instructions, is not one.
|
|
31
|
+
MCP_TOOL = re.compile(r"^(mcp__[A-Za-z0-9_.\-]+?__)[A-Za-z0-9_.\-]+$", re.M)
|
|
32
|
+
# Under load a dirty twin can send its request before its MCP servers are connected. A blind
|
|
33
|
+
# twin gets one more try after this pause, and the reading says so.
|
|
34
|
+
TWIN_RETRY_PAUSE = 10
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Finding:
|
|
39
|
+
kind: str
|
|
40
|
+
what: str
|
|
41
|
+
source: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Reading:
|
|
46
|
+
agent: str = "?"
|
|
47
|
+
version: str = "?"
|
|
48
|
+
leaks: list[Finding] = field(default_factory=list)
|
|
49
|
+
declared: list[Finding] = field(default_factory=list)
|
|
50
|
+
seen: list[str] = field(default_factory=list) # classes the control found in the dirty twin
|
|
51
|
+
blind: list[str] = field(default_factory=list) # classes the user has but the dirty twin did not show
|
|
52
|
+
retried: list[str] = field(default_factory=list) # classes a first dirty twin was blind for
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def zero(self) -> bool:
|
|
56
|
+
return not self.leaks and not self.blind
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _capture(agent, argv: list[str], env: dict[str, str], cwd: Path, fake: Fake) -> Capture:
|
|
60
|
+
proc = subprocess.run(argv, env=env, cwd=cwd, capture_output=True, text=True, timeout=300,
|
|
61
|
+
stdin=subprocess.DEVNULL)
|
|
62
|
+
capture = agent.capture(proc.stdout, fake.requests)
|
|
63
|
+
if capture is None:
|
|
64
|
+
detail = proc.stderr.strip()[-300:] or proc.stdout.strip()[-300:] or "no output"
|
|
65
|
+
raise TareError(f"{agent.name} never reached tare's fake model server, so the probe could not run "
|
|
66
|
+
f"(exit {proc.returncode}): {detail}")
|
|
67
|
+
return capture
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def instruction_lines(path: Path) -> list[str]:
|
|
71
|
+
"""Distinctive lines of a file of the user's, used as markers."""
|
|
72
|
+
try:
|
|
73
|
+
lines = [line.strip() for line in path.read_text().splitlines()]
|
|
74
|
+
except OSError:
|
|
75
|
+
return []
|
|
76
|
+
return [line[:80] for line in lines if len(line) >= 30][:5]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _mcp_prefixes(capture: Capture) -> set[str]:
|
|
80
|
+
return set(MCP_TOOL.findall(capture.text + "\n" + "\n".join(capture.tools)))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _without_paths(text: str) -> str:
|
|
84
|
+
"""Skill lines with their "(path)" removed: agy names each skill's file, and the room's
|
|
85
|
+
path (/home/tare/...) differs from the user's for the same skill."""
|
|
86
|
+
return re.sub(r" \([^)\n]*\)", "", text)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _server_prefix(name: str) -> str:
|
|
90
|
+
return "mcp__" + re.sub(r"[^A-Za-z0-9_-]", "_", name) + "__"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def parent_instructions(project: Path) -> list[Path]:
|
|
94
|
+
"""Instruction files in the project's parent directories: the user's, not the project's."""
|
|
95
|
+
return [d / name for d in project.parents for name in ("AGENTS.md", "CLAUDE.md") if (d / name).is_file()]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def score(agent, real: Real, twin: Capture, room: Capture, reach_in: str, reach_out: str,
|
|
99
|
+
bare: Capture | None = None, project: Path | None = None) -> Reading:
|
|
100
|
+
r = Reading(agent=agent.name, version=room.init.get("claude_code_version", "?"))
|
|
101
|
+
|
|
102
|
+
# instruction files above the project; an agent that does not read them cannot leak them
|
|
103
|
+
for path in parent_instructions(project) if project else []:
|
|
104
|
+
lines = instruction_lines(path)
|
|
105
|
+
if lines and any(line in twin.text for line in lines):
|
|
106
|
+
if "parent files" not in r.seen:
|
|
107
|
+
r.seen.append("parent files")
|
|
108
|
+
if any(line in room.text for line in lines):
|
|
109
|
+
r.leaks.append(Finding("parent files", "instructions above the project", str(path)))
|
|
110
|
+
|
|
111
|
+
# extensions: tools the dirty twin offers that a run without extensions does not
|
|
112
|
+
if bare is not None:
|
|
113
|
+
extension_tools = twin.tools - bare.tools
|
|
114
|
+
if extension_tools:
|
|
115
|
+
r.seen.append("extensions")
|
|
116
|
+
r.leaks += [Finding("extensions", name, "a tool of your extensions") for name in sorted(extension_tools & room.tools)]
|
|
117
|
+
|
|
118
|
+
# the user's own files: global instructions, and whatever else the agent reads (memories)
|
|
119
|
+
for kind, path in [("instructions", agent.instructions(real)), *agent.extra_markers(real)]:
|
|
120
|
+
lines = instruction_lines(path)
|
|
121
|
+
if lines:
|
|
122
|
+
(r.seen if any(line in twin.text for line in lines) else r.blind).append(kind)
|
|
123
|
+
if any(line in room.text for line in lines):
|
|
124
|
+
r.leaks.append(Finding(kind, LABELS.get(kind, kind), str(path)))
|
|
125
|
+
|
|
126
|
+
# the real home path: memory paths, parent-directory instruction files, hook output
|
|
127
|
+
home = f"{real.home}/"
|
|
128
|
+
if home in twin.text:
|
|
129
|
+
r.seen.append("home path")
|
|
130
|
+
if home in room.text:
|
|
131
|
+
r.leaks.append(Finding("home path", home, "your files"))
|
|
132
|
+
|
|
133
|
+
# MCP servers and connectors
|
|
134
|
+
twin_servers = _mcp_prefixes(twin)
|
|
135
|
+
connected = [s["name"] for s in twin.init.get("mcp_servers", []) if s.get("status") == "connected"]
|
|
136
|
+
if any(_server_prefix(name) not in twin_servers for name in connected):
|
|
137
|
+
r.blind.append("mcp")
|
|
138
|
+
elif twin_servers:
|
|
139
|
+
r.seen.append("mcp")
|
|
140
|
+
for prefix in sorted(_mcp_prefixes(room)):
|
|
141
|
+
source = "connected account (login)" if prefix.startswith("mcp__claude_ai_") else "MCP server"
|
|
142
|
+
r.leaks.append(Finding("mcp", prefix[5:-2], source))
|
|
143
|
+
|
|
144
|
+
# skills from plugins, the user's skill directories and the account
|
|
145
|
+
plugins = twin.init.get("plugins", [])
|
|
146
|
+
builtin = {p["name"] for p in plugins if p.get("path") == "builtin"}
|
|
147
|
+
user_plugins = {p["name"] for p in plugins if p.get("path") != "builtin"}
|
|
148
|
+
own: dict[str, Path] = {}
|
|
149
|
+
for skill_dir in agent.skill_dirs(real):
|
|
150
|
+
if skill_dir.is_dir():
|
|
151
|
+
own |= {d.name: skill_dir for d in skill_dir.iterdir() if d.is_dir() and not d.name.startswith(".")}
|
|
152
|
+
names = {s for s in twin.init.get("skills", []) if ":" in s and s.split(":")[0] not in builtin} | set(own)
|
|
153
|
+
listed = {}
|
|
154
|
+
for name in sorted(names):
|
|
155
|
+
# the skills listing has one "- name: description" line per skill ("- name (path): ..." in agy)
|
|
156
|
+
match = re.search(rf"^- {re.escape(name)}(?:[:( ].*)?$", twin.text, re.M)
|
|
157
|
+
if match:
|
|
158
|
+
namespace = name.split(":")[0] if ":" in name else None
|
|
159
|
+
source = (str(own[name]) if namespace is None
|
|
160
|
+
else f"plugin {namespace}" if namespace in user_plugins else "login (account skills)")
|
|
161
|
+
listed[_without_paths(match.group(0))[:160]] = source
|
|
162
|
+
if names:
|
|
163
|
+
(r.seen if listed else r.blind).append("skills")
|
|
164
|
+
counts: dict[str, int] = {}
|
|
165
|
+
room_lines = _without_paths(room.text)
|
|
166
|
+
for line, source in listed.items():
|
|
167
|
+
if line in room_lines:
|
|
168
|
+
counts[source] = counts.get(source, 0) + 1
|
|
169
|
+
r.leaks += [Finding("skills", f"{n} skill{'s' if n != 1 else ''}", source) for source, n in sorted(counts.items())]
|
|
170
|
+
if user_plugins:
|
|
171
|
+
r.seen.append("plugins")
|
|
172
|
+
r.leaks += [Finding("plugin", p["name"], p.get("path", "?"))
|
|
173
|
+
for p in room.init.get("plugins", []) if p.get("path") != "builtin"]
|
|
174
|
+
|
|
175
|
+
# the account email comes with subscription auth: declared, not a leak
|
|
176
|
+
email = agent.email(real)
|
|
177
|
+
if email and email in twin.text:
|
|
178
|
+
r.seen.append("email")
|
|
179
|
+
if email in room.text:
|
|
180
|
+
r.declared.append(Finding("email", "account email", "subscription login"))
|
|
181
|
+
|
|
182
|
+
# reach: the same script outside (control) and inside the room
|
|
183
|
+
if any(line.startswith("path ") for line in reach_out.splitlines()):
|
|
184
|
+
r.seen.append("reach")
|
|
185
|
+
allowed = set(room_env(agent)) | SHELL_ENV
|
|
186
|
+
for line in reach_in.splitlines():
|
|
187
|
+
kind, _, value = line.partition(" ")
|
|
188
|
+
if kind == "path":
|
|
189
|
+
r.leaks.append(Finding("reach", value, "visible inside the room"))
|
|
190
|
+
elif kind == "env" and value not in allowed:
|
|
191
|
+
r.leaks.append(Finding("env", value, "inherited environment"))
|
|
192
|
+
for name in getattr(agent, "secret_files", [agent.credentials]):
|
|
193
|
+
r.declared.append(Finding("credentials", f"{agent.room_config}/{name}", "copy of the login, needed by the CLI"))
|
|
194
|
+
return r
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def settle(run_twin, read) -> Reading:
|
|
198
|
+
"""Read with a dirty twin; a twin blind for some class gets one more try."""
|
|
199
|
+
reading = read(run_twin())
|
|
200
|
+
if reading.blind:
|
|
201
|
+
first = list(reading.blind)
|
|
202
|
+
time.sleep(TWIN_RETRY_PAUSE)
|
|
203
|
+
reading = read(run_twin())
|
|
204
|
+
reading.retried = first
|
|
205
|
+
return reading
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def probe(agent, real: Real, project: Path) -> Reading:
|
|
209
|
+
project = project.resolve()
|
|
210
|
+
targets = [str(real.home), str(real.config), *(str(real.home / p) for p in SECRET_PATHS)]
|
|
211
|
+
if Path("/mnt/c").is_dir():
|
|
212
|
+
targets.append("/mnt/c") # WSL: the Windows drive and its user profile
|
|
213
|
+
|
|
214
|
+
# the dirty twin: the user's real setup, as a plain terminal would start it
|
|
215
|
+
twin_env = {k: v for k, v in os.environ.items() if not k.startswith("CLAUDE") or k == "CLAUDE_CONFIG_DIR"}
|
|
216
|
+
|
|
217
|
+
def run_twin() -> Capture:
|
|
218
|
+
with Fake() as fake:
|
|
219
|
+
args, env = agent.probe(fake.url)
|
|
220
|
+
with agent.twin(real, fake.url) as extra:
|
|
221
|
+
env = twin_env | env | extra
|
|
222
|
+
argv = [str(real.binary), *args]
|
|
223
|
+
if hasattr(agent, "twin_argv"): # a twin that must not write into the real setup
|
|
224
|
+
argv = agent.twin_argv(argv, env)
|
|
225
|
+
return _capture(agent, argv, env, project, fake)
|
|
226
|
+
|
|
227
|
+
def in_room(home: Path, flags: list[str]) -> Capture:
|
|
228
|
+
with Fake() as fake:
|
|
229
|
+
agent.prepare_probe(home / Path(agent.room_config).relative_to(ROOM_HOME), fake.url)
|
|
230
|
+
args, env = agent.probe(fake.url)
|
|
231
|
+
argv = bwrap(agent, real, home, project, [agent.name, *flags, *args], env)
|
|
232
|
+
return _capture(agent, argv, {**os.environ}, project, fake)
|
|
233
|
+
|
|
234
|
+
with room_home(agent, real) as home:
|
|
235
|
+
room = in_room(home, agent.room_flags)
|
|
236
|
+
# where an agent loads extensions, a run without them shows which tools are its own
|
|
237
|
+
bare = in_room(home, [*agent.room_flags, *agent.bare_flags]) if getattr(agent, "bare_flags", None) else None
|
|
238
|
+
reach = ["/bin/sh", "-c", REACH_SCRIPT, "reach", *targets]
|
|
239
|
+
reach_in = subprocess.run(bwrap(agent, real, home, project, reach), capture_output=True, text=True,
|
|
240
|
+
timeout=60, check=True).stdout
|
|
241
|
+
reach_out = subprocess.run(reach, capture_output=True, text=True, timeout=60, check=True).stdout
|
|
242
|
+
return settle(run_twin, lambda twin: score(agent, real, twin, room, reach_in, reach_out, bare=bare,
|
|
243
|
+
project=project))
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def render(reading: Reading, project: Path) -> str:
|
|
247
|
+
width = max((len(f.what) for f in reading.leaks + reading.declared), default=0) + 2
|
|
248
|
+
|
|
249
|
+
def rows(findings):
|
|
250
|
+
return [f" {f.kind:<13}{f.what:<{width}}{f.source}" for f in findings]
|
|
251
|
+
|
|
252
|
+
version = f" {reading.version}" if reading.version != "?" else ""
|
|
253
|
+
out = [f"tare probe · {reading.agent}{version} · {project}",
|
|
254
|
+
f" control dirty twin shows: {', '.join(reading.seen) or 'nothing'}"]
|
|
255
|
+
if reading.retried:
|
|
256
|
+
out.append(f" retried the first dirty twin was blind for {', '.join(reading.retried)}. "
|
|
257
|
+
"This reading comes from a second one.")
|
|
258
|
+
for cls in reading.blind:
|
|
259
|
+
out.append(f" BLIND {cls}: you have it, but the dirty twin did not show it, so the room cannot be "
|
|
260
|
+
"proven clean of it")
|
|
261
|
+
if reading.leaks:
|
|
262
|
+
out += [" leaks", *rows(reading.leaks)]
|
|
263
|
+
else:
|
|
264
|
+
out.append(" leaks none")
|
|
265
|
+
if reading.declared:
|
|
266
|
+
out += [" declared", *rows(reading.declared)]
|
|
267
|
+
if reading.zero:
|
|
268
|
+
out.append("tare: 0.00")
|
|
269
|
+
elif reading.leaks:
|
|
270
|
+
out.append(f"tare: {len(reading.leaks)} leak{'s' if len(reading.leaks) != 1 else ''}")
|
|
271
|
+
else:
|
|
272
|
+
out.append("tare: not proven (blind)")
|
|
273
|
+
return "\n".join(out)
|
tare/recipe.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Recipes: how to repeat a Cliff or Swap run exactly.
|
|
2
|
+
|
|
3
|
+
Every run directory gets a recipe.json: the command, the task and check, a digest of the
|
|
4
|
+
project as it was, the agents with their CLI versions and arguments, and the parameters.
|
|
5
|
+
`tare rerun DIR` repeats the command and says what has changed since.
|
|
6
|
+
"""
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
import time
|
|
12
|
+
from importlib import metadata
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
# directories that hold tooling, not the project's content
|
|
16
|
+
SKIP = {".git", ".venv", "venv", "node_modules", "__pycache__", ".pytest_cache", ".ruff_cache"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def project_digest(project: Path) -> str:
|
|
20
|
+
digest = hashlib.sha256()
|
|
21
|
+
for root, dirs, files in os.walk(project):
|
|
22
|
+
dirs[:] = sorted(d for d in dirs if d not in SKIP)
|
|
23
|
+
for name in sorted(files):
|
|
24
|
+
path = Path(root) / name
|
|
25
|
+
if path.is_symlink() or not path.is_file():
|
|
26
|
+
continue
|
|
27
|
+
digest.update(str(path.relative_to(project)).encode() + b"\0")
|
|
28
|
+
digest.update(hashlib.sha256(path.read_bytes()).digest())
|
|
29
|
+
return digest.hexdigest()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def cli_version(real) -> str:
|
|
33
|
+
try:
|
|
34
|
+
proc = subprocess.run([str(real.binary), "--version"], capture_output=True, text=True, timeout=30,
|
|
35
|
+
stdin=subprocess.DEVNULL)
|
|
36
|
+
return (proc.stdout or proc.stderr).strip().splitlines()[0]
|
|
37
|
+
except (OSError, subprocess.TimeoutExpired, IndexError):
|
|
38
|
+
return "unknown"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def tare_version() -> str:
|
|
42
|
+
try:
|
|
43
|
+
return metadata.version("tare-cli")
|
|
44
|
+
except metadata.PackageNotFoundError:
|
|
45
|
+
return "unknown"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def without_out(argv: list[str]) -> list[str]:
|
|
49
|
+
"""The command without its --out, so a rerun gets a run directory of its own."""
|
|
50
|
+
result, skip = [], False
|
|
51
|
+
for i, arg in enumerate(argv):
|
|
52
|
+
if skip:
|
|
53
|
+
skip = False
|
|
54
|
+
continue
|
|
55
|
+
if arg == "--":
|
|
56
|
+
return result + argv[i:]
|
|
57
|
+
if arg == "--out":
|
|
58
|
+
skip = True
|
|
59
|
+
continue
|
|
60
|
+
if not arg.startswith("--out="):
|
|
61
|
+
result.append(arg)
|
|
62
|
+
return result
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def with_out(argv: list[str], out: Path) -> list[str]:
|
|
66
|
+
at = argv.index("--") if "--" in argv else len(argv)
|
|
67
|
+
return argv[:at] + ["--out", str(out)] + argv[at:]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def write(out: Path, argv: list[str], kind: str, project: Path, task: str, check: str,
|
|
71
|
+
agents: dict[str, tuple[object, object, list[str]]], params: dict) -> dict:
|
|
72
|
+
recipe = {
|
|
73
|
+
"kind": kind, "argv": without_out(argv), "out": str(out), "created": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
|
74
|
+
"tare": tare_version(), "task": task, "check": check, "project": str(project),
|
|
75
|
+
"project_digest": project_digest(project),
|
|
76
|
+
"agents": {key: {"name": agent.name, "args": args, "version": cli_version(real)}
|
|
77
|
+
for key, (agent, real, args) in agents.items()},
|
|
78
|
+
"params": params,
|
|
79
|
+
}
|
|
80
|
+
(out / "recipe.json").write_text(json.dumps(recipe, indent=2, ensure_ascii=False) + "\n")
|
|
81
|
+
return recipe
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def read(out: Path) -> dict:
|
|
85
|
+
return json.loads((out / "recipe.json").read_text())
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def drift(recipe: dict, reals: dict[str, object]) -> list[str]:
|
|
89
|
+
"""What is no longer as it was when the recipe was written."""
|
|
90
|
+
notes = []
|
|
91
|
+
project = Path(recipe["project"])
|
|
92
|
+
if not project.is_dir():
|
|
93
|
+
notes.append(f"the project {project} is gone")
|
|
94
|
+
elif project_digest(project) != recipe["project_digest"]:
|
|
95
|
+
notes.append(f"the project {project} has changed since the recipe was written")
|
|
96
|
+
for key, agent in recipe["agents"].items():
|
|
97
|
+
real = reals.get(agent["name"])
|
|
98
|
+
if real is not None and (now := cli_version(real)) != agent["version"]:
|
|
99
|
+
notes.append(f"agent {key} ({agent['name']}) was {agent['version']}, is now {now}")
|
|
100
|
+
if (now := tare_version()) != recipe["tare"]:
|
|
101
|
+
notes.append(f"tare was {recipe['tare']}, is now {now}")
|
|
102
|
+
return notes
|
tare/room.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""The room: a bubblewrap sandbox where nothing of the user's came along.
|
|
2
|
+
|
|
3
|
+
Inside, /home is an empty tmpfs. Only a fresh per-run home (with a copy of the agent's
|
|
4
|
+
login), the CLI and the project directory are mounted, and the environment is cleared
|
|
5
|
+
down to an allowlist. Linux and WSL only (bubblewrap, user namespaces).
|
|
6
|
+
"""
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import tempfile
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
ROOM_HOME = "/home/tare"
|
|
14
|
+
ROOM_PROJECT = "/work"
|
|
15
|
+
# Copied from the caller when set; everything else stays outside.
|
|
16
|
+
PASS_ENV = ("TERM", "COLORTERM", "LANG", "LC_ALL")
|
|
17
|
+
# The room must not refresh the login: a refresh may rotate the token family the real
|
|
18
|
+
# session depends on (CONCEPT.md, layer 2). An hour covers ordinary runs.
|
|
19
|
+
MIN_TOKEN_LIFETIME = 3600
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TareError(Exception):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@contextmanager
|
|
27
|
+
def room_home(agent, real):
|
|
28
|
+
"""A fresh home for one room, holding a copy of the agent's login; removed afterwards."""
|
|
29
|
+
left = agent.token_lifetime(real)
|
|
30
|
+
if left < MIN_TOKEN_LIFETIME:
|
|
31
|
+
raise TareError(f"the {agent.name} login token expires in {max(0, int(left // 60))} min: "
|
|
32
|
+
f"start any {agent.name} session to refresh it, then retry")
|
|
33
|
+
home = Path(tempfile.mkdtemp(prefix="tare-room-"))
|
|
34
|
+
try:
|
|
35
|
+
config = home / Path(agent.room_config).relative_to(ROOM_HOME)
|
|
36
|
+
config.mkdir(parents=True)
|
|
37
|
+
agent.seed(real, config)
|
|
38
|
+
yield home
|
|
39
|
+
finally:
|
|
40
|
+
shutil.rmtree(home, ignore_errors=True)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def room_env(agent, extra: dict[str, str] | None = None) -> dict[str, str]:
|
|
44
|
+
env = {k: os.environ[k] for k in PASS_ENV if k in os.environ}
|
|
45
|
+
env |= {"HOME": ROOM_HOME, "PATH": "/opt/agent:/usr/bin:/bin", **agent.room_env()}
|
|
46
|
+
return env | (extra or {})
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def bwrap(agent, real, home: Path, project: Path, command: list[str], extra_env: dict[str, str] | None = None,
|
|
50
|
+
extra_binds: list[str] | None = None) -> list[str]:
|
|
51
|
+
"""The bwrap command for one room. `command[0]` may be the agent's own name."""
|
|
52
|
+
binds, executable = agent.binds(real)
|
|
53
|
+
if command and command[0] == agent.name:
|
|
54
|
+
command = [executable, *command[1:]]
|
|
55
|
+
argv = ["bwrap", "--ro-bind", "/usr", "/usr", "--ro-bind", "/etc", "/etc",
|
|
56
|
+
"--symlink", "usr/bin", "/bin", "--symlink", "usr/lib", "/lib", "--symlink", "usr/lib64", "/lib64"]
|
|
57
|
+
if Path("/mnt/wsl").is_dir():
|
|
58
|
+
# WSL keeps the resolver behind a symlink into /mnt/wsl; without it DNS fails.
|
|
59
|
+
argv += ["--ro-bind", "/mnt/wsl", "/mnt/wsl"]
|
|
60
|
+
argv += ["--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", "--tmpfs", "/home",
|
|
61
|
+
"--bind", str(home), ROOM_HOME, *binds, *(extra_binds or []),
|
|
62
|
+
"--bind", str(project), ROOM_PROJECT, "--chdir", ROOM_PROJECT,
|
|
63
|
+
"--unshare-user", "--unshare-pid", "--unshare-ipc", "--unshare-uts", "--die-with-parent",
|
|
64
|
+
"--clearenv"]
|
|
65
|
+
for key, value in room_env(agent, extra_env).items():
|
|
66
|
+
argv += ["--setenv", key, value]
|
|
67
|
+
return argv + ["--", *command]
|
tare/swap.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Swap: the room or the model (epic Swap).
|
|
2
|
+
|
|
3
|
+
Two agents, a and b, each run the task once in a room, with a capsule per step. At each cut,
|
|
4
|
+
a fraction of each run, both rooms are crossed with both agents: every agent continues
|
|
5
|
+
every room, by the same rendered handoff, so diagonal and off-diagonal cells read the
|
|
6
|
+
same kind of input. The state effect is how much better the tails do in room a than in
|
|
7
|
+
room b, averaged over the agents; the model effect is how much better agent a does than
|
|
8
|
+
agent b, averaged over the rooms. Where an agent can resume its own session natively, a
|
|
9
|
+
native cell on its own room prices the handoff itself (foreignness). At cut 0 both rooms
|
|
10
|
+
are the untouched project, so the state effect there must be zero: the null check.
|
|
11
|
+
"""
|
|
12
|
+
import math
|
|
13
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from . import capsule as caps
|
|
18
|
+
from . import usage as usages
|
|
19
|
+
from .cliff import wilson
|
|
20
|
+
from .journal import Journal
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Side:
|
|
25
|
+
key: str # "a" or "b"
|
|
26
|
+
agent: object
|
|
27
|
+
real: object
|
|
28
|
+
args: list[str]
|
|
29
|
+
out: Path
|
|
30
|
+
steps: list = field(default_factory=list)
|
|
31
|
+
events: list = field(default_factory=list)
|
|
32
|
+
original: str = ""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def newcombe(x1: int, n1: int, x2: int, n2: int) -> tuple[float, float, float]:
|
|
36
|
+
"""Difference of two pass rates with a 95 % interval (Newcombe's hybrid score method)."""
|
|
37
|
+
p1, p2 = x1 / n1, x2 / n2
|
|
38
|
+
l1, u1 = wilson(x1, n1)
|
|
39
|
+
l2, u2 = wilson(x2, n2)
|
|
40
|
+
d = p1 - p2
|
|
41
|
+
return d, d - math.sqrt((p1 - l1) ** 2 + (u2 - p2) ** 2), d + math.sqrt((u1 - p1) ** 2 + (p2 - l2) ** 2)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def mean_effect(differences: list[tuple[float, float, float]]) -> tuple[float, float, float]:
|
|
45
|
+
"""Mean of independent differences; interval half-widths combine in quadrature."""
|
|
46
|
+
k = len(differences)
|
|
47
|
+
e = sum(d for d, _, _ in differences) / k
|
|
48
|
+
down = math.sqrt(sum((d - lo) ** 2 for d, lo, _ in differences)) / k
|
|
49
|
+
up = math.sqrt(sum((hi - d) ** 2 for d, _, hi in differences)) / k
|
|
50
|
+
return e, e - down, e + up
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class Cut:
|
|
55
|
+
fraction: float
|
|
56
|
+
steps: dict[str, int] # side -> step of its run
|
|
57
|
+
cells: dict[tuple[str, str], list[bool]] = field(default_factory=dict) # (room, agent) -> passes
|
|
58
|
+
native: dict[str, list[bool]] = field(default_factory=dict) # side -> passes of its own native resume
|
|
59
|
+
|
|
60
|
+
def count(self, room: str, agent: str) -> tuple[int, int]:
|
|
61
|
+
r = self.cells[(room, agent)]
|
|
62
|
+
return sum(r), len(r)
|
|
63
|
+
|
|
64
|
+
def state(self) -> tuple[float, float, float]:
|
|
65
|
+
return mean_effect([newcombe(*self.count("a", m), *self.count("b", m)) for m in "ab"])
|
|
66
|
+
|
|
67
|
+
def model(self) -> tuple[float, float, float]:
|
|
68
|
+
return mean_effect([newcombe(*self.count(r, "a"), *self.count(r, "b")) for r in "ab"])
|
|
69
|
+
|
|
70
|
+
def foreignness(self, side: str) -> tuple[float, float, float] | None:
|
|
71
|
+
if side not in self.native:
|
|
72
|
+
return None
|
|
73
|
+
native = self.native[side]
|
|
74
|
+
return newcombe(sum(native), len(native), *self.count(side, side))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def swap(a: Side, b: Side, project: Path, prompt: str, check: str, out: Path, *, cuts: list[float],
|
|
78
|
+
tails: int = 3, jobs: int = 3, workspace_only: bool = False, env: dict[str, str] | None = None,
|
|
79
|
+
journal: Journal | None = None) -> str:
|
|
80
|
+
sides = {"a": a, "b": b}
|
|
81
|
+
journal = journal or Journal(None)
|
|
82
|
+
journal("start", kind="swap", task=prompt, check=check, project=str(project), tails=tails, cuts=cuts,
|
|
83
|
+
sides={s.key: {"agent": s.agent.name, "args": s.args, "dir": s.out.name,
|
|
84
|
+
"billing": usages.billing(s.agent, s.real)} for s in (a, b)})
|
|
85
|
+
journal("phase", phase="original runs")
|
|
86
|
+
with ThreadPoolExecutor(max_workers=2) as pool:
|
|
87
|
+
list(pool.map(lambda s: caps.record(s.agent, s.real, project, s.out, prompt, s.args, env), (a, b)))
|
|
88
|
+
for side in (a, b):
|
|
89
|
+
passed, detail, spent = caps.check_original(side.agent, side.out, check)
|
|
90
|
+
side.steps = caps.capsules(side.out, side.agent)
|
|
91
|
+
side.events = side.agent.trail(caps.session_lines(side.out)).events
|
|
92
|
+
side.original = f"{'passed' if passed else 'failed'} ({detail}) after {len(side.steps) - 1} steps"
|
|
93
|
+
journal("original", side=side.key, passed=passed, detail=detail, steps=[s.tool for s in side.steps], **spent)
|
|
94
|
+
|
|
95
|
+
plan = [Cut(f, {k: round(f * (len(s.steps) - 1)) for k, s in sides.items()}) for f in cuts]
|
|
96
|
+
journal("plan", cuts=[{"fraction": c.fraction, "steps": c.steps} for c in plan])
|
|
97
|
+
journal("phase", phase="tails")
|
|
98
|
+
jobs_list = []
|
|
99
|
+
for i, cut in enumerate(plan):
|
|
100
|
+
for room in "ab":
|
|
101
|
+
capsule = sides[room].steps[cut.steps[room]]
|
|
102
|
+
for agent in "ab":
|
|
103
|
+
cut.cells[(room, agent)] = []
|
|
104
|
+
for n in range(tails):
|
|
105
|
+
jobs_list.append(("cell", i, room, agent, n, capsule))
|
|
106
|
+
if sides[room].agent.native_resume:
|
|
107
|
+
cut.native[room] = []
|
|
108
|
+
for n in range(tails):
|
|
109
|
+
jobs_list.append(("native", i, room, room, n, capsule))
|
|
110
|
+
|
|
111
|
+
def run(job):
|
|
112
|
+
kind, i, room, agent, n, capsule = job
|
|
113
|
+
name = f"cut{i}/{kind}-room-{room}-agent-{agent}-{n}"
|
|
114
|
+
tail_dir = out / "swap" / name
|
|
115
|
+
journal("tail", id=name, status="running", cut=i, room=room, agent=agent, kind=kind,
|
|
116
|
+
agent_name=sides[agent].agent.name, dir=f"swap/{name}")
|
|
117
|
+
if kind == "native":
|
|
118
|
+
s = sides[room]
|
|
119
|
+
tail = caps.run_tail(s.agent, s.real, s.out, capsule, prompt, s.args, check, n, env, tail_dir=tail_dir)
|
|
120
|
+
else:
|
|
121
|
+
s = sides[agent]
|
|
122
|
+
tail = caps.run_handoff(s.agent, s.real, tail_dir, capsule, sides[room].events, prompt, s.args, check,
|
|
123
|
+
env, workspace_only)
|
|
124
|
+
journal("tail", id=name, status="passed" if tail.passed else "failed", detail=tail.detail,
|
|
125
|
+
usage=tail.usage, check_usage=tail.check_usage)
|
|
126
|
+
return job, tail.passed
|
|
127
|
+
|
|
128
|
+
with ThreadPoolExecutor(max_workers=jobs) as pool:
|
|
129
|
+
for (kind, i, room, agent, _, _), passed in pool.map(run, jobs_list):
|
|
130
|
+
(plan[i].native[room] if kind == "native" else plan[i].cells[(room, agent)]).append(passed)
|
|
131
|
+
|
|
132
|
+
text = "\n".join(report(sides, plan, prompt, check, project, len(jobs_list), workspace_only)
|
|
133
|
+
+ usages.report(out)) + "\n"
|
|
134
|
+
(out / "report.txt").write_text(text)
|
|
135
|
+
journal("report", text=text)
|
|
136
|
+
journal("phase", phase="done")
|
|
137
|
+
return text
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _leader(state: tuple[float, float, float], model: tuple[float, float, float]) -> str:
|
|
141
|
+
"""Which explains more of the gap at a cut: the room, the model, both alike, or neither (both near zero)."""
|
|
142
|
+
if abs(state[0]) < 0.1 and abs(model[0]) < 0.1:
|
|
143
|
+
return "neither"
|
|
144
|
+
if abs(abs(state[0]) - abs(model[0])) < 0.1:
|
|
145
|
+
return "tie"
|
|
146
|
+
return "room" if abs(state[0]) > abs(model[0]) else "model"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _sign(effect: tuple[float, float, float]) -> str:
|
|
150
|
+
e, lo, hi = effect
|
|
151
|
+
return f"{e:+.2f} [{lo:+.2f}, {hi:+.2f}]"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def report(sides: dict[str, Side], plan: list[Cut], prompt: str, check: str, project: Path, spent: int,
|
|
155
|
+
workspace_only: bool) -> list[str]:
|
|
156
|
+
def who(s: Side) -> str:
|
|
157
|
+
return f"{s.agent.name}{' ' + ' '.join(s.args) if s.args else ''}"
|
|
158
|
+
|
|
159
|
+
lines = [f"tare swap · a: {who(sides['a'])} · b: {who(sides['b'])} · {project}",
|
|
160
|
+
f" task {prompt}", f" check {check}",
|
|
161
|
+
f" run a {sides['a'].original}", f" run b {sides['b'].original}",
|
|
162
|
+
f" handoff {'workspace only' if workspace_only else 'workspace and a written summary of the steps so far'}"]
|
|
163
|
+
dominant, deciding = [], []
|
|
164
|
+
for cut in plan:
|
|
165
|
+
lines += ["", f" cut {cut.fraction:.2f}: workspace a at step {cut.steps['a']}, workspace b at step {cut.steps['b']}",
|
|
166
|
+
" agent a agent b"]
|
|
167
|
+
for room in "ab":
|
|
168
|
+
row = []
|
|
169
|
+
for agent in "ab":
|
|
170
|
+
x, n = cut.count(room, agent)
|
|
171
|
+
lo, hi = wilson(x, n)
|
|
172
|
+
row.append(f"{x}/{n} {lo:.2f}-{hi:.2f}")
|
|
173
|
+
lines.append(f" workspace {room} {row[0]:<17}{row[1]}")
|
|
174
|
+
state, model = cut.state(), cut.model()
|
|
175
|
+
lines.append(f" state effect {_sign(state)} (positive: workspace a better)")
|
|
176
|
+
lines.append(f" model effect {_sign(model)} (positive: agent a better)")
|
|
177
|
+
for side in "ab":
|
|
178
|
+
cost = cut.foreignness(side)
|
|
179
|
+
if cost:
|
|
180
|
+
native, (passed, n) = cut.native[side], cut.count(side, side)
|
|
181
|
+
lines.append(f" handoff cost {side} {_sign(cost)} (own session {sum(native)}/{len(native)} against "
|
|
182
|
+
f"handoff {passed}/{n}, both in its own workspace)")
|
|
183
|
+
dominant.append(_leader(state, model))
|
|
184
|
+
deciding.append(state if dominant[-1] in ("room", "tie") else model)
|
|
185
|
+
lines.append("")
|
|
186
|
+
first = plan[0]
|
|
187
|
+
if first.fraction == 0:
|
|
188
|
+
_, lo, hi = first.state()
|
|
189
|
+
lines.append(" null check " + ("passed: no state effect at cut 0, where both workspaces are the untouched "
|
|
190
|
+
"project" if lo <= 0 <= hi else
|
|
191
|
+
f"FAILED: a state effect at cut 0 ({_sign(first.state())}), where both "
|
|
192
|
+
"workspaces are the same. Do not trust the effects above."))
|
|
193
|
+
shown = [i for i, d in enumerate(dominant) if d != "neither"]
|
|
194
|
+
late = next((i for i, d in enumerate(dominant) if d in ("room", "tie") and "model" in dominant[:i]), None)
|
|
195
|
+
if late is not None and dominant[late] == "tie" and "room" not in dominant:
|
|
196
|
+
verdict = (f"The model explains more than the workspace until cut {plan[late - 1].fraction:.2f}. From cut "
|
|
197
|
+
f"{plan[late].fraction:.2f} on, workspace and model explain about the same.")
|
|
198
|
+
elif "room" in dominant and "model" in dominant[:dominant.index("room")]:
|
|
199
|
+
at = dominant.index("room")
|
|
200
|
+
before = max(i for i in range(at) if dominant[i] == "model")
|
|
201
|
+
verdict = (f"Blame passes from the model to the workspace between cut {plan[before].fraction:.2f} "
|
|
202
|
+
f"and cut {plan[at].fraction:.2f}.")
|
|
203
|
+
elif not shown:
|
|
204
|
+
verdict = "Neither the workspace nor the model makes a difference at any cut."
|
|
205
|
+
elif {dominant[i] for i in shown} == {"model"}:
|
|
206
|
+
verdict = "The model explains more than the workspace " + ("at every cut." if len(shown) == len(plan)
|
|
207
|
+
else "wherever there is an effect.")
|
|
208
|
+
elif {dominant[i] for i in shown} == {"room"}:
|
|
209
|
+
verdict = "The workspace explains more than the model " + ("at every cut." if len(shown) == len(plan)
|
|
210
|
+
else "wherever there is an effect.")
|
|
211
|
+
else:
|
|
212
|
+
names = {"room": "workspace", "model": "model", "tie": "both alike", "neither": "neither"}
|
|
213
|
+
verdict = "No single pattern. What explains more, per cut: " + ", ".join(
|
|
214
|
+
f"{c.fraction:.2f} {names[d]}" for c, d in zip(plan, dominant)) + "."
|
|
215
|
+
if any(deciding[i][1] <= 0 <= deciding[i][2] for i in shown):
|
|
216
|
+
verdict += " The intervals behind this verdict include zero. More tails would settle it."
|
|
217
|
+
lines.append(f" verdict {verdict}")
|
|
218
|
+
lines.append(f" tails {spent}")
|
|
219
|
+
return lines
|