agents-city 0.5.2 → 0.5.4
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.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.es.md +11 -0
- package/README.md +10 -0
- package/benchmarks/latency/fake-claude-cli.mjs +3 -2
- package/bin/serve.py +63 -6
- package/bin/test +2 -2
- package/bin/test-alcance.py +470 -0
- package/bin/test-arnes.py +109 -0
- package/bin/test-channel.py +295 -9
- package/bin/test-cities.py +57 -0
- package/bin/test-claude-runtime.py +17 -6
- package/bin/test-connect-client.mjs +8 -0
- package/bin/test-connect.py +1 -1
- package/bin/test-contracts.py +129 -3
- package/bin/test-e2e.py +371 -0
- package/bin/test-seat.py +154 -1
- package/bin/test-serve.py +83 -0
- package/city/web/dist-hall/hall.js +384 -3240
- package/docs/managed-connect.md +5 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/channel/hub/lo-que-te-llega.ts +78 -0
- package/plugin/channel/hub/local-roads.ts +36 -0
- package/plugin/channel/hub/road-controller.ts +30 -5
- package/plugin/channel/local-hub.js +177 -105
- package/plugin/channel/local-hub.ts +8 -0
- package/plugin/channel/managed-connect-client.js +53 -3
- package/plugin/channel/managed-connect-client.manifest.json +2 -2
- package/plugin/channel/protocol.ts +24 -0
- package/plugin/commands/notice.md +14 -0
- package/plugin/hooks/ask-the-house.sh +25 -0
- package/plugin/hooks/hooks.json +10 -0
- package/plugin/hooks/notice-on-stop.sh +7 -1
- package/plugin/scripts/alcance.py +318 -0
- package/plugin/scripts/arnes.py +10 -1
- package/plugin/scripts/busca.py +13 -0
- package/plugin/scripts/card.py +40 -0
- package/plugin/scripts/cities.py +46 -4
- package/plugin/scripts/city-session.sh +80 -7
- package/plugin/scripts/domains.py +7 -0
- package/plugin/scripts/seat.py +33 -13
- package/plugin/skills/city/SKILL.md +12 -0
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The chair's hands: what the seat may touch, and what it has to ask for.
|
|
3
|
+
|
|
4
|
+
The bug this exists for has no error message. A seat was asked for a feature in
|
|
5
|
+
a Rails codebase; it ran `ls`, then `grep`, then answered — alone, well, and
|
|
6
|
+
without the three specialists the owner had configured ever hearing the
|
|
7
|
+
question. Nothing failed. That is the whole problem: a seat that reads the code
|
|
8
|
+
and answers looks exactly like a seat that consulted its city.
|
|
9
|
+
|
|
10
|
+
So the boundary is enforced at the tool call, and this suite is mostly the
|
|
11
|
+
unhappy half of it, because a guard that over-reaches is worse than none:
|
|
12
|
+
|
|
13
|
+
· it must never stop the chair working in its own city folder;
|
|
14
|
+
· it must never stop the very command the refusal recommends;
|
|
15
|
+
· one over-broad mount must cost that mount, not the whole seat;
|
|
16
|
+
· a house inside its own mounts must not notice this exists at all;
|
|
17
|
+
· and when it does refuse, the refusal has to be actionable — the owner's
|
|
18
|
+
name, their role, and the line that asks them.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import shutil
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
import tempfile
|
|
27
|
+
|
|
28
|
+
AQUI = os.path.dirname(os.path.abspath(__file__))
|
|
29
|
+
RAIZ = os.path.dirname(AQUI)
|
|
30
|
+
sys.path.insert(0, AQUI)
|
|
31
|
+
sys.path.insert(0, os.path.join(RAIZ, "plugin", "scripts"))
|
|
32
|
+
|
|
33
|
+
import alcance # noqa: E402
|
|
34
|
+
import cities # noqa: E402
|
|
35
|
+
import diario # noqa: E402
|
|
36
|
+
import seat as S # noqa: E402
|
|
37
|
+
import workspace # noqa: E402
|
|
38
|
+
from testlib import afirma, comprueba, resumen, roster # noqa: E402
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ── one city, two agents, real folders ───────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def ciudad():
|
|
45
|
+
"""A city whose two agents own real ground on disk.
|
|
46
|
+
|
|
47
|
+
Materialised mounts, not declared ones: the guard follows the symlink the
|
|
48
|
+
way the kernel does, and a fixture that only wrote the card would test a
|
|
49
|
+
different code path from the one that runs.
|
|
50
|
+
"""
|
|
51
|
+
base = os.path.realpath(tempfile.mkdtemp())
|
|
52
|
+
datos = os.path.join(base, "ciudad")
|
|
53
|
+
api = os.path.join(base, "codigo", "api")
|
|
54
|
+
docs = os.path.join(base, "papeles", "manual")
|
|
55
|
+
for d in (datos, api, docs, os.path.join(api, "app")):
|
|
56
|
+
os.makedirs(d, exist_ok=True)
|
|
57
|
+
open(os.path.join(api, "app", "router.rb"), "w").write("# routes\n")
|
|
58
|
+
open(os.path.join(docs, "guia.md"), "w").write("# guide\n")
|
|
59
|
+
with open(os.path.join(datos, "city.yml"), "w", encoding="utf-8") as f:
|
|
60
|
+
f.write("owner: ana\nname: home\nslug: home\nid: city-alcance-prueba\n")
|
|
61
|
+
ficha = os.path.join(datos, "ana.md")
|
|
62
|
+
agentes = roster(("api", "code", "dev"), ("manual", "knowledge", "seo"))
|
|
63
|
+
agentes[0]["mounts"] = [api]
|
|
64
|
+
agentes[1]["mounts"] = [docs]
|
|
65
|
+
S.escribe_ficha(ficha, "ana", "cpto", agentes)
|
|
66
|
+
for a in workspace.agentes(open(ficha).read(), datos):
|
|
67
|
+
workspace.sincroniza(a, datos)
|
|
68
|
+
return base, datos, api, docs
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def entorno(datos, **extra):
|
|
72
|
+
e = {"CITY_BUS_ACTOR": "seat", "AGENTS_CITY_DATA": datos}
|
|
73
|
+
e.update(extra)
|
|
74
|
+
return e
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def juzga(datos, herramienta, entrada, cwd=None, **extra):
|
|
78
|
+
"""One tool call through the guard. None means it was allowed."""
|
|
79
|
+
return alcance.juicio(
|
|
80
|
+
{"tool_name": herramienta, "tool_input": entrada, "cwd": cwd or datos},
|
|
81
|
+
entorno(datos, **extra),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def razon(veredicto):
|
|
86
|
+
return ((veredicto or {}).get("hookSpecificOutput") or {}).get("permissionDecisionReason", "")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ── the chair keeps its own city ─────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def lo_que_sigue_pudiendo(datos, api):
|
|
93
|
+
print(" the chair still has its own city")
|
|
94
|
+
|
|
95
|
+
afirma(
|
|
96
|
+
"· happy: it reads its own card",
|
|
97
|
+
juzga(datos, "Read", {"file_path": os.path.join(datos, "ana.md")}) is None,
|
|
98
|
+
)
|
|
99
|
+
afirma(
|
|
100
|
+
"· happy: and its own city.yml, roads and record",
|
|
101
|
+
all(
|
|
102
|
+
juzga(datos, "Read", {"file_path": os.path.join(datos, f)}) is None
|
|
103
|
+
for f in ("city.yml", "roads.json", "AGENTS.md")
|
|
104
|
+
),
|
|
105
|
+
)
|
|
106
|
+
afirma(
|
|
107
|
+
"· happy: an agent's workspace folder is the city's, not the agent's ground",
|
|
108
|
+
juzga(
|
|
109
|
+
datos,
|
|
110
|
+
"Read",
|
|
111
|
+
{"file_path": os.path.join(workspace.workspace_de(datos, "api"), "CLAUDE.md")},
|
|
112
|
+
)
|
|
113
|
+
is None,
|
|
114
|
+
)
|
|
115
|
+
afirma(
|
|
116
|
+
"· happy: a shell command that names no place at all",
|
|
117
|
+
juzga(datos, "Bash", {"command": "git status"}) is None,
|
|
118
|
+
)
|
|
119
|
+
afirma(
|
|
120
|
+
"· happy: somewhere nobody in this city owns",
|
|
121
|
+
juzga(datos, "Read", {"file_path": os.path.join(datos, "..", "nada.txt")}) is None,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# The refusal recommends `agents-city committee open --question "..."`, and a
|
|
125
|
+
# brief about a repo names that repo. A guard that denies its own remedy is
|
|
126
|
+
# a guard that just stops the seat.
|
|
127
|
+
brief = (
|
|
128
|
+
f'agents-city committee open --question "what should change in {api}/app" '
|
|
129
|
+
f'--outcome "a decision" --member api --done "it is written down"'
|
|
130
|
+
)
|
|
131
|
+
afirma(
|
|
132
|
+
"· happy: the door that asks is never the thing that is stopped",
|
|
133
|
+
juzga(datos, "Bash", {"command": brief}) is None,
|
|
134
|
+
brief[:120],
|
|
135
|
+
)
|
|
136
|
+
afirma(
|
|
137
|
+
"· happy: and neither is the repo's own committee door",
|
|
138
|
+
juzga(datos, "Bash", {"command": f"./bin/committee open --member api # {api}"}) is None,
|
|
139
|
+
)
|
|
140
|
+
# Prose that happens to contain a path is prose.
|
|
141
|
+
afirma(
|
|
142
|
+
"· happy: a quoted sentence that mentions a folder is not a hand in it",
|
|
143
|
+
juzga(datos, "Bash", {"command": f'echo "the answer is somewhere under {api}/app"'})
|
|
144
|
+
is None,
|
|
145
|
+
)
|
|
146
|
+
afirma(
|
|
147
|
+
"· happy: even when the sentence starts with the folder",
|
|
148
|
+
juzga(datos, "Bash", {"command": f'echo "{api}/app is where it lives"'}) is None,
|
|
149
|
+
)
|
|
150
|
+
afirma(
|
|
151
|
+
"· happy: and a sibling folder whose name merely starts the same way",
|
|
152
|
+
juzga(datos, "Read", {"file_path": f"{api}-viejo/router.rb"}) is None,
|
|
153
|
+
)
|
|
154
|
+
# The other side of that tiebreaker: a folder with a space in its name is a
|
|
155
|
+
# real folder, and plenty of people have one.
|
|
156
|
+
con_espacio = os.path.join(os.path.dirname(api), "api", "app", "mis notas.md")
|
|
157
|
+
open(con_espacio, "w").write("# notas\n")
|
|
158
|
+
afirma(
|
|
159
|
+
"· non-happy: a real path with a space in it is still that agent's ground",
|
|
160
|
+
juzga(datos, "Bash", {"command": f'cat "{con_espacio}"'}) is not None,
|
|
161
|
+
con_espacio,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
afirma(
|
|
165
|
+
"· happy: a house inside its own mounts never meets this guard",
|
|
166
|
+
juzga(
|
|
167
|
+
datos,
|
|
168
|
+
"Read",
|
|
169
|
+
{"file_path": os.path.join(api, "app", "router.rb")},
|
|
170
|
+
CITY_BUS_ACTOR="api",
|
|
171
|
+
)
|
|
172
|
+
is None,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ── and cannot do its agents' work ───────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def lo_que_ya_no_puede(datos, api, docs):
|
|
180
|
+
print(" and it cannot do its agents' work for them")
|
|
181
|
+
|
|
182
|
+
v = juzga(datos, "Read", {"file_path": os.path.join(api, "app", "router.rb")})
|
|
183
|
+
comprueba(
|
|
184
|
+
"· non-happy: reading a mounted repo is refused",
|
|
185
|
+
((v or {}).get("hookSpecificOutput") or {}).get("permissionDecision"),
|
|
186
|
+
"deny",
|
|
187
|
+
)
|
|
188
|
+
texto = razon(v)
|
|
189
|
+
afirma("· the refusal names who owns the ground", "api" in texto, texto[:200])
|
|
190
|
+
afirma("· and the role they hold here", "dev" in texto, texto[:200])
|
|
191
|
+
afirma(
|
|
192
|
+
"· and hands over the exact line that asks them",
|
|
193
|
+
"agents-city committee open" in texto and "--member api" in texto,
|
|
194
|
+
texto[:400],
|
|
195
|
+
)
|
|
196
|
+
afirma(
|
|
197
|
+
"· and says what to do when the answer has not come back yet",
|
|
198
|
+
"waiting" in texto,
|
|
199
|
+
texto[-300:],
|
|
200
|
+
)
|
|
201
|
+
afirma(
|
|
202
|
+
"· and whose call it is to open the chair's hands",
|
|
203
|
+
"--seat-reach open" in texto,
|
|
204
|
+
texto[-300:],
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
casos = [
|
|
208
|
+
("a shell that walks in", "Bash", {"command": f"cd {api} && ls"}),
|
|
209
|
+
("a semicolon instead of &&", "Bash", {"command": f"cd {api};ls"}),
|
|
210
|
+
("a grep across somebody's repo", "Bash", {"command": f"grep -rn router {api}"}),
|
|
211
|
+
("git run from outside it", "Bash", {"command": f"git -C {api} log --oneline"}),
|
|
212
|
+
("a redirection into it", "Bash", {"command": f"echo x >{api}/app/nuevo.rb"}),
|
|
213
|
+
("an edit", "Edit", {"file_path": os.path.join(api, "app", "router.rb")}),
|
|
214
|
+
("a new file that does not exist yet", "Write", {"file_path": os.path.join(api, "x.rb")}),
|
|
215
|
+
(
|
|
216
|
+
"a new file three folders deep that do not exist either",
|
|
217
|
+
"Write",
|
|
218
|
+
{"file_path": os.path.join(api, "nuevo", "sitio", "x.rb")},
|
|
219
|
+
),
|
|
220
|
+
("a grep tool", "Grep", {"pattern": "router", "path": api}),
|
|
221
|
+
("a glob", "Glob", {"pattern": os.path.join(api, "**", "*.rb")}),
|
|
222
|
+
("a folder of documents, not code", "Read", {"file_path": os.path.join(docs, "guia.md")}),
|
|
223
|
+
(
|
|
224
|
+
"the workspace symlink, which is the same ground",
|
|
225
|
+
"Read",
|
|
226
|
+
{"file_path": os.path.join(workspace.workspace_de(datos, "api"), "mounts", "api",
|
|
227
|
+
"app", "router.rb")},
|
|
228
|
+
),
|
|
229
|
+
]
|
|
230
|
+
for nombre, herramienta, entrada in casos:
|
|
231
|
+
v = juzga(datos, herramienta, entrada)
|
|
232
|
+
afirma(f"· non-happy: {nombre}", v is not None, json.dumps(entrada)[:160])
|
|
233
|
+
|
|
234
|
+
# Spelled differently, same ground.
|
|
235
|
+
previo = os.environ.get("HOME")
|
|
236
|
+
os.environ["HOME"] = os.path.dirname(os.path.dirname(api)) # the base of the fixture
|
|
237
|
+
try:
|
|
238
|
+
afirma(
|
|
239
|
+
"· non-happy: ~ is not a disguise",
|
|
240
|
+
juzga(datos, "Read", {"file_path": "~/codigo/api/app/router.rb"}) is not None,
|
|
241
|
+
)
|
|
242
|
+
afirma(
|
|
243
|
+
"· non-happy: nor is $HOME",
|
|
244
|
+
juzga(datos, "Bash", {"command": "ls $HOME/codigo/api/app"}) is not None,
|
|
245
|
+
)
|
|
246
|
+
finally:
|
|
247
|
+
if previo is None:
|
|
248
|
+
os.environ.pop("HOME", None)
|
|
249
|
+
else:
|
|
250
|
+
os.environ["HOME"] = previo
|
|
251
|
+
|
|
252
|
+
afirma(
|
|
253
|
+
"· non-happy: nor is a relative path out of the city folder",
|
|
254
|
+
juzga(
|
|
255
|
+
datos,
|
|
256
|
+
"Read",
|
|
257
|
+
{"file_path": os.path.join("..", "codigo", "api", "app", "router.rb")},
|
|
258
|
+
cwd=datos,
|
|
259
|
+
)
|
|
260
|
+
is not None,
|
|
261
|
+
)
|
|
262
|
+
afirma(
|
|
263
|
+
"· non-happy: the docs agent is named for its own ground, not the api one",
|
|
264
|
+
"manual" in razon(juzga(datos, "Read", {"file_path": os.path.join(docs, "guia.md")})),
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# ── the owner decides, and the guard never decides for them ──────────────────
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def la_puerta_del_dueno(datos, api):
|
|
272
|
+
print(" the owner keeps the key")
|
|
273
|
+
dentro = {"file_path": os.path.join(api, "app", "router.rb")}
|
|
274
|
+
afirma("· closed is the default", juzga(datos, "Read", dentro) is not None)
|
|
275
|
+
afirma(
|
|
276
|
+
"· happy: CITY_SEAT_REACH=open gives the chair its hands, now",
|
|
277
|
+
juzga(datos, "Read", dentro, CITY_SEAT_REACH="open") is None,
|
|
278
|
+
)
|
|
279
|
+
cities.pon_clave(datos, "seat_reach", "open")
|
|
280
|
+
try:
|
|
281
|
+
afirma(
|
|
282
|
+
"· happy: and seat_reach in city.yml gives them back for good",
|
|
283
|
+
juzga(datos, "Read", dentro) is None,
|
|
284
|
+
)
|
|
285
|
+
finally:
|
|
286
|
+
cities.pon_clave(datos, "seat_reach", "closed")
|
|
287
|
+
afirma("· non-happy: anything else means closed", juzga(datos, "Read", dentro) is not None)
|
|
288
|
+
afirma(
|
|
289
|
+
"· non-happy: and so does a value that only looks like consent",
|
|
290
|
+
juzga(datos, "Read", dentro, CITY_SEAT_REACH="opened") is not None,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# ── a guard that breaks a turn is worse than no guard ────────────────────────
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def nunca_se_lleva_la_ciudad_por_delante(base, api):
|
|
298
|
+
print(" one bad mount costs that mount, never the seat")
|
|
299
|
+
|
|
300
|
+
def con_mount(destino):
|
|
301
|
+
datos = os.path.join(base, "otra")
|
|
302
|
+
shutil.rmtree(datos, ignore_errors=True)
|
|
303
|
+
os.makedirs(datos)
|
|
304
|
+
with open(os.path.join(datos, "city.yml"), "w", encoding="utf-8") as f:
|
|
305
|
+
f.write("owner: ana\nname: otra\nslug: otra\nid: city-alcance-otra\n")
|
|
306
|
+
ficha = os.path.join(datos, "ana.md")
|
|
307
|
+
agentes = roster(("api", "code", "dev"))
|
|
308
|
+
agentes[0]["mounts"] = [destino]
|
|
309
|
+
S.escribe_ficha(ficha, "ana", "cpto", agentes)
|
|
310
|
+
for a in workspace.agentes(open(ficha).read(), datos):
|
|
311
|
+
workspace.sincroniza(a, datos)
|
|
312
|
+
return datos
|
|
313
|
+
|
|
314
|
+
# A mount that swallows the home directory would deny the chair its own
|
|
315
|
+
# card, its own record and its own goal — everything lives under a home.
|
|
316
|
+
# HOME has to be the fixture's own base for this to mean anything: with the
|
|
317
|
+
# developer's real home and a city in /tmp, nothing overlaps and the check
|
|
318
|
+
# passes without ever exercising the rule.
|
|
319
|
+
casa = os.path.join(base, "casa")
|
|
320
|
+
os.makedirs(casa, exist_ok=True)
|
|
321
|
+
open(os.path.join(casa, "notas.txt"), "w").write("mine\n")
|
|
322
|
+
previo = os.environ.get("HOME")
|
|
323
|
+
os.environ["HOME"] = casa
|
|
324
|
+
try:
|
|
325
|
+
datos = con_mount(casa)
|
|
326
|
+
afirma(
|
|
327
|
+
"· non-happy: a mount of the whole home directory is ignored, not honoured",
|
|
328
|
+
juzga(datos, "Read", {"file_path": os.path.join(casa, "notas.txt")}) is None,
|
|
329
|
+
)
|
|
330
|
+
finally:
|
|
331
|
+
if previo is None:
|
|
332
|
+
os.environ.pop("HOME", None)
|
|
333
|
+
else:
|
|
334
|
+
os.environ["HOME"] = previo
|
|
335
|
+
datos = con_mount(os.sep)
|
|
336
|
+
afirma(
|
|
337
|
+
"· non-happy: and so is a mount of the root of the disk",
|
|
338
|
+
juzga(datos, "Read", {"file_path": os.path.join(datos, "ana.md")}) is None,
|
|
339
|
+
)
|
|
340
|
+
datos = con_mount(os.path.join(base, "otra"))
|
|
341
|
+
afirma(
|
|
342
|
+
"· non-happy: a mount that swallows the city cannot lock the chair out of it",
|
|
343
|
+
juzga(datos, "Read", {"file_path": os.path.join(datos, "city.yml")}) is None,
|
|
344
|
+
)
|
|
345
|
+
# And with all three of those ignored, real ground is still real ground.
|
|
346
|
+
datos = con_mount(api)
|
|
347
|
+
afirma(
|
|
348
|
+
"· happy: an ordinary mount is still enforced afterwards",
|
|
349
|
+
juzga(datos, "Read", {"file_path": os.path.join(api, "app", "router.rb")}) is not None,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def nunca_rompe_el_turno(datos, api):
|
|
354
|
+
print(" and it never breaks the turn it is judging")
|
|
355
|
+
afirma(
|
|
356
|
+
"· non-happy: a tool_input that is not an object",
|
|
357
|
+
juzga(datos, "Read", "no soy un objeto") is None,
|
|
358
|
+
)
|
|
359
|
+
afirma("· non-happy: an empty command", juzga(datos, "Bash", {"command": ""}) is None)
|
|
360
|
+
afirma(
|
|
361
|
+
"· non-happy: an unbalanced quote still gets read",
|
|
362
|
+
juzga(datos, "Bash", {"command": f"grep -r ' {api}/app"}) is not None,
|
|
363
|
+
)
|
|
364
|
+
afirma(
|
|
365
|
+
"· non-happy: a city folder that is not there",
|
|
366
|
+
alcance.juicio(
|
|
367
|
+
{"tool_name": "Read", "tool_input": {"file_path": api}},
|
|
368
|
+
{"CITY_BUS_ACTOR": "seat", "AGENTS_CITY_DATA": os.path.join(datos, "no-existe")},
|
|
369
|
+
)
|
|
370
|
+
is None,
|
|
371
|
+
)
|
|
372
|
+
sin_ficha = tempfile.mkdtemp()
|
|
373
|
+
open(os.path.join(sin_ficha, "city.yml"), "w").write("owner: nadie\nid: x\n")
|
|
374
|
+
afirma(
|
|
375
|
+
"· non-happy: a city with no owner card judges nothing",
|
|
376
|
+
alcance.juicio(
|
|
377
|
+
{"tool_name": "Read", "tool_input": {"file_path": api}},
|
|
378
|
+
{"CITY_BUS_ACTOR": "seat", "AGENTS_CITY_DATA": sin_ficha},
|
|
379
|
+
)
|
|
380
|
+
is None,
|
|
381
|
+
)
|
|
382
|
+
shutil.rmtree(sin_ficha, ignore_errors=True)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
# ── it is written down ───────────────────────────────────────────────────────
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def queda_escrito(datos, api):
|
|
389
|
+
print(" every refusal is on disk before anybody has to remember it")
|
|
390
|
+
antes = len(diario.lee(datos))
|
|
391
|
+
juzga(datos, "Bash", {"command": f"grep -rn router {api}"})
|
|
392
|
+
lineas = [x for x in diario.lee(datos) if x.get("tipo") == "alcance"]
|
|
393
|
+
afirma("· the refusal is journalled", len(diario.lee(datos)) > antes and lineas, str(lineas))
|
|
394
|
+
ultima = lineas[-1]
|
|
395
|
+
comprueba("· with the agent whose ground it was", ultima.get("agente"), "api")
|
|
396
|
+
comprueba("· and the tool that tried", ultima.get("herramienta"), "Bash")
|
|
397
|
+
afirma("· and the exact path", api in str(ultima.get("ruta")), str(ultima))
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
# ── the wiring, end to end ───────────────────────────────────────────────────
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def el_gancho_de_verdad(datos, api):
|
|
404
|
+
print(" the hook itself, over stdin, as Claude runs it")
|
|
405
|
+
gancho = os.path.join(RAIZ, "plugin", "hooks", "ask-the-house.sh")
|
|
406
|
+
entrada = json.dumps(
|
|
407
|
+
{"tool_name": "Read", "tool_input": {"file_path": os.path.join(api, "app", "router.rb")},
|
|
408
|
+
"cwd": datos}
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
def corre(**extra):
|
|
412
|
+
env = dict(os.environ)
|
|
413
|
+
env.update({
|
|
414
|
+
"CLAUDE_PLUGIN_ROOT": os.path.join(RAIZ, "plugin"),
|
|
415
|
+
"AGENTS_CITY_DATA": datos,
|
|
416
|
+
"CITY_BUS_ACTOR": "seat",
|
|
417
|
+
})
|
|
418
|
+
env.update(extra)
|
|
419
|
+
return subprocess.run(
|
|
420
|
+
["/bin/bash", gancho], input=entrada, capture_output=True, text=True, env=env
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
r = corre()
|
|
424
|
+
afirma("· it answers JSON on stdout and nothing else", r.returncode == 0, r.stderr[-300:])
|
|
425
|
+
try:
|
|
426
|
+
salida = json.loads(r.stdout)
|
|
427
|
+
except ValueError:
|
|
428
|
+
salida = {}
|
|
429
|
+
comprueba(
|
|
430
|
+
"· non-happy: a real seat tool call is denied",
|
|
431
|
+
(salida.get("hookSpecificOutput") or {}).get("permissionDecision"),
|
|
432
|
+
"deny",
|
|
433
|
+
)
|
|
434
|
+
r = corre(CITY_BUS_ACTOR="api")
|
|
435
|
+
comprueba("· happy: a house's identical call is not", r.stdout.strip(), "{}")
|
|
436
|
+
r = corre(CITY_BUS_ACTOR="")
|
|
437
|
+
comprueba("· non-happy: and outside a city runtime it says nothing", r.stdout.strip(), "{}")
|
|
438
|
+
|
|
439
|
+
# The wiring is the half that looks finished while doing nothing: a hook
|
|
440
|
+
# nobody registered is a file.
|
|
441
|
+
hooks = json.load(open(os.path.join(RAIZ, "plugin", "hooks", "hooks.json")))
|
|
442
|
+
texto = json.dumps(hooks["hooks"]["PreToolUse"])
|
|
443
|
+
afirma("· and Claude is told to run it before every tool that names a place",
|
|
444
|
+
"ask-the-house.sh" in texto and "Bash" in texto and "Grep" in texto, texto[:400])
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def main():
|
|
448
|
+
previo = os.environ.get("AGENTS_CITY_HOME")
|
|
449
|
+
os.environ["AGENTS_CITY_HOME"] = tempfile.mkdtemp()
|
|
450
|
+
base, datos, api, docs = ciudad()
|
|
451
|
+
try:
|
|
452
|
+
lo_que_sigue_pudiendo(datos, api)
|
|
453
|
+
lo_que_ya_no_puede(datos, api, docs)
|
|
454
|
+
la_puerta_del_dueno(datos, api)
|
|
455
|
+
nunca_se_lleva_la_ciudad_por_delante(base, api)
|
|
456
|
+
nunca_rompe_el_turno(datos, api)
|
|
457
|
+
queda_escrito(datos, api)
|
|
458
|
+
el_gancho_de_verdad(datos, api)
|
|
459
|
+
finally:
|
|
460
|
+
shutil.rmtree(os.environ["AGENTS_CITY_HOME"], ignore_errors=True)
|
|
461
|
+
if previo is None:
|
|
462
|
+
os.environ.pop("AGENTS_CITY_HOME", None)
|
|
463
|
+
else:
|
|
464
|
+
os.environ["AGENTS_CITY_HOME"] = previo
|
|
465
|
+
shutil.rmtree(base, ignore_errors=True)
|
|
466
|
+
return resumen("alcance")
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
if __name__ == "__main__":
|
|
470
|
+
sys.exit(main())
|
package/bin/test-arnes.py
CHANGED
|
@@ -20,6 +20,7 @@ So there are two things to defend here, and the second is the hard one:
|
|
|
20
20
|
import json
|
|
21
21
|
import os
|
|
22
22
|
import re
|
|
23
|
+
import subprocess
|
|
23
24
|
import sys
|
|
24
25
|
import tempfile
|
|
25
26
|
|
|
@@ -119,12 +120,119 @@ def sin_deriva():
|
|
|
119
120
|
arnes.banderas("codex") == "" and arnes.banderas("opencode") == "",
|
|
120
121
|
f"codex={arnes.banderas('codex')!r}")
|
|
121
122
|
|
|
123
|
+
# And it survives a SHELL, which is what receives it.
|
|
124
|
+
#
|
|
125
|
+
# This is the check that was missing, and its absence cost every Claude
|
|
126
|
+
# window in a city. Emitted bare, `--settings {"a":"b","c":true}` is
|
|
127
|
+
# destroyed twice before Claude sees it: brace expansion splits it on the
|
|
128
|
+
# comma, quote removal eats the double quotes, and what arrives is
|
|
129
|
+
# `--settings {a:b}` — "Invalid JSON provided to --settings".
|
|
130
|
+
#
|
|
131
|
+
# Asserting that the string CONTAINS the right words could never have
|
|
132
|
+
# caught that. So this parses the line the way a shell does and reads the
|
|
133
|
+
# value back as JSON, which is the only claim that matters.
|
|
134
|
+
import shlex # noqa: PLC0415
|
|
135
|
+
|
|
136
|
+
palabras = shlex.split(arnes.banderas("claude"))
|
|
137
|
+
afirma("· the flags survive shell parsing as separate words",
|
|
138
|
+
"--settings" in palabras and "--disallowed-tools" in palabras, str(palabras))
|
|
139
|
+
valor = palabras[palabras.index("--settings") + 1]
|
|
140
|
+
try:
|
|
141
|
+
ajustes = json.loads(valor)
|
|
142
|
+
except json.JSONDecodeError as e:
|
|
143
|
+
ajustes = None
|
|
144
|
+
afirma("· and the settings value is still JSON afterwards", False, f"{valor!r}: {e}")
|
|
145
|
+
if ajustes is not None:
|
|
146
|
+
afirma("· and the settings value is still JSON afterwards", True, "")
|
|
147
|
+
comprueba("· with the cross-session path closed",
|
|
148
|
+
ajustes.get("crossSessionInbound"), "refuse")
|
|
149
|
+
afirma("· and every declared settings key inside it",
|
|
150
|
+
all(t["clave"] in ajustes
|
|
151
|
+
for t in arnes.declaracion()["claude"]["trato"]
|
|
152
|
+
if t.get("rinde") == "settings"),
|
|
153
|
+
str(ajustes))
|
|
154
|
+
# A real shell, not just a parser: brace expansion is the half `shlex`
|
|
155
|
+
# forgives, and it is the half that broke.
|
|
156
|
+
import subprocess # noqa: PLC0415
|
|
157
|
+
|
|
158
|
+
r = subprocess.run(
|
|
159
|
+
["bash", "-c", 'set -- ' + arnes.banderas("claude") + '; printf "%s\n" "$@"'],
|
|
160
|
+
capture_output=True, text=True,
|
|
161
|
+
)
|
|
162
|
+
entregado = [l for l in r.stdout.split("\n") if l]
|
|
163
|
+
comprueba("· a real shell hands over exactly four words", len(entregado), 4)
|
|
164
|
+
try:
|
|
165
|
+
json.loads(entregado[1])
|
|
166
|
+
afirma("· and the second is the settings, intact", True, "")
|
|
167
|
+
except json.JSONDecodeError as e:
|
|
168
|
+
afirma("· and the second is the settings, intact", False, f"{entregado!r}: {e}")
|
|
169
|
+
|
|
122
170
|
|
|
123
171
|
def _es_metodo_declarado(aguja, declaradas):
|
|
124
172
|
"""`approvalPolicy` declared, read through a method of the same name."""
|
|
125
173
|
return any(aguja.lower() == d.lower() for d in declaradas)
|
|
126
174
|
|
|
127
175
|
|
|
176
|
+
def valores_que_no_sobrevivirian(tmp):
|
|
177
|
+
"""Values that a shell would eat, or run.
|
|
178
|
+
|
|
179
|
+
The happy checks above prove today's declaration survives. They cannot
|
|
180
|
+
prove the NEXT one will: the values there are mild strings, and the bug
|
|
181
|
+
that broke every Claude window was a comma. The day somebody declares a
|
|
182
|
+
value with a space in it, or a quote, this has to hold — and if it does
|
|
183
|
+
not, it must fail here rather than in a person's terminal.
|
|
184
|
+
|
|
185
|
+
The last two are not a formatting concern. `$(...)` and backticks in an
|
|
186
|
+
unquoted argument are executed by the shell, and this declaration is read
|
|
187
|
+
from a file: a value that runs a command is a value that runs somebody
|
|
188
|
+
else's command.
|
|
189
|
+
"""
|
|
190
|
+
print(" values a shell would eat, or run")
|
|
191
|
+
testigo = os.path.join(tmp, "ejecutado")
|
|
192
|
+
hostiles = {
|
|
193
|
+
"conEspacio": "dos palabras",
|
|
194
|
+
"conComillas": 'dice "hola" y \'adios\'',
|
|
195
|
+
"conLlaves": "{a,b}",
|
|
196
|
+
"conPuntoYComa": "uno; echo dos",
|
|
197
|
+
"conDolar": "$HOME y ${OTRO}",
|
|
198
|
+
"conSustitucion": f"$(touch {testigo})",
|
|
199
|
+
"conAcentoGrave": f"`touch {testigo}`",
|
|
200
|
+
}
|
|
201
|
+
real = arnes.declaracion
|
|
202
|
+
arnes.declaracion = lambda: {
|
|
203
|
+
"claude": {
|
|
204
|
+
"trato": [
|
|
205
|
+
{"clave": k, "valor": v, "via": "x", "porque": "y" * 30, "rinde": "settings"}
|
|
206
|
+
for k, v in hostiles.items()
|
|
207
|
+
]
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
try:
|
|
211
|
+
linea = arnes.banderas("claude")
|
|
212
|
+
finally:
|
|
213
|
+
arnes.declaracion = real
|
|
214
|
+
|
|
215
|
+
# A real shell, because `shlex` forgives what bash does not.
|
|
216
|
+
r = subprocess.run(
|
|
217
|
+
["bash", "-c", "set -- " + linea + '; printf "%s\\n" "$@"'],
|
|
218
|
+
capture_output=True, text=True,
|
|
219
|
+
)
|
|
220
|
+
entregado = [l for l in r.stdout.split("\n") if l]
|
|
221
|
+
afirma("· a hostile declaration still comes out as two words",
|
|
222
|
+
len(entregado) == 2 and entregado[0] == "--settings",
|
|
223
|
+
f"{entregado!r} from {linea!r}")
|
|
224
|
+
afirma("· nothing in it was executed on the way",
|
|
225
|
+
not os.path.exists(testigo),
|
|
226
|
+
f"{testigo} exists: a declared value ran a command")
|
|
227
|
+
try:
|
|
228
|
+
vuelta = json.loads(entregado[1]) if len(entregado) > 1 else {}
|
|
229
|
+
except json.JSONDecodeError as e:
|
|
230
|
+
vuelta = {}
|
|
231
|
+
afirma("· and it is still JSON on the other side", False, f"{entregado[1]!r}: {e}")
|
|
232
|
+
for clave, valor in hostiles.items():
|
|
233
|
+
comprueba(f"· {clave} arrives exactly as declared", vuelta.get(clave), valor)
|
|
234
|
+
|
|
235
|
+
|
|
128
236
|
def lo_que_hay_en_el_disco():
|
|
129
237
|
print(" it reads the machine, and admits what it cannot read")
|
|
130
238
|
casa = tempfile.mkdtemp()
|
|
@@ -183,6 +291,7 @@ def el_informe():
|
|
|
183
291
|
def main():
|
|
184
292
|
la_declaracion()
|
|
185
293
|
sin_deriva()
|
|
294
|
+
valores_que_no_sobrevivirian(tempfile.mkdtemp())
|
|
186
295
|
lo_que_hay_en_el_disco()
|
|
187
296
|
el_informe()
|
|
188
297
|
return resumen("arnes")
|