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.
Files changed (42) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.es.md +11 -0
  3. package/README.md +10 -0
  4. package/benchmarks/latency/fake-claude-cli.mjs +3 -2
  5. package/bin/serve.py +63 -6
  6. package/bin/test +2 -2
  7. package/bin/test-alcance.py +470 -0
  8. package/bin/test-arnes.py +109 -0
  9. package/bin/test-channel.py +295 -9
  10. package/bin/test-cities.py +57 -0
  11. package/bin/test-claude-runtime.py +17 -6
  12. package/bin/test-connect-client.mjs +8 -0
  13. package/bin/test-connect.py +1 -1
  14. package/bin/test-contracts.py +129 -3
  15. package/bin/test-e2e.py +371 -0
  16. package/bin/test-seat.py +154 -1
  17. package/bin/test-serve.py +83 -0
  18. package/city/web/dist-hall/hall.js +384 -3240
  19. package/docs/managed-connect.md +5 -0
  20. package/package.json +1 -1
  21. package/plugin/.claude-plugin/plugin.json +1 -1
  22. package/plugin/channel/hub/lo-que-te-llega.ts +78 -0
  23. package/plugin/channel/hub/local-roads.ts +36 -0
  24. package/plugin/channel/hub/road-controller.ts +30 -5
  25. package/plugin/channel/local-hub.js +177 -105
  26. package/plugin/channel/local-hub.ts +8 -0
  27. package/plugin/channel/managed-connect-client.js +53 -3
  28. package/plugin/channel/managed-connect-client.manifest.json +2 -2
  29. package/plugin/channel/protocol.ts +24 -0
  30. package/plugin/commands/notice.md +14 -0
  31. package/plugin/hooks/ask-the-house.sh +25 -0
  32. package/plugin/hooks/hooks.json +10 -0
  33. package/plugin/hooks/notice-on-stop.sh +7 -1
  34. package/plugin/scripts/alcance.py +318 -0
  35. package/plugin/scripts/arnes.py +10 -1
  36. package/plugin/scripts/busca.py +13 -0
  37. package/plugin/scripts/card.py +40 -0
  38. package/plugin/scripts/cities.py +46 -4
  39. package/plugin/scripts/city-session.sh +80 -7
  40. package/plugin/scripts/domains.py +7 -0
  41. package/plugin/scripts/seat.py +33 -13
  42. 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")