agents-city 0.3.0-beta.21

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 (440) hide show
  1. package/.claude-plugin/marketplace.json +16 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +5 -0
  4. package/README.es.md +2021 -0
  5. package/README.md +1999 -0
  6. package/benchmarks/committee/README.md +39 -0
  7. package/benchmarks/committee/metrics.py +51 -0
  8. package/benchmarks/committee/run.py +64 -0
  9. package/benchmarks/committee/traces/01-single.json +8 -0
  10. package/benchmarks/committee/traces/02-mesh.json +16 -0
  11. package/benchmarks/committee/traces/03-chair.json +15 -0
  12. package/benchmarks/latency/README.md +35 -0
  13. package/benchmarks/latency/fake-claude-cli.mjs +119 -0
  14. package/benchmarks/latency/fake-native-server.mjs +340 -0
  15. package/benchmarks/latency/fake-tui.py +91 -0
  16. package/benchmarks/latency/live.py +380 -0
  17. package/benchmarks/stress/README.md +20 -0
  18. package/benchmarks/stress/run.py +487 -0
  19. package/bin/agents +4 -0
  20. package/bin/agents-city.js +116 -0
  21. package/bin/benchmark +17 -0
  22. package/bin/bus +6 -0
  23. package/bin/cities +3 -0
  24. package/bin/city +86 -0
  25. package/bin/committee +6 -0
  26. package/bin/demo +200 -0
  27. package/bin/exit +5 -0
  28. package/bin/hall +73 -0
  29. package/bin/hall.html +426 -0
  30. package/bin/logs +3 -0
  31. package/bin/report.py +14 -0
  32. package/bin/reset +3 -0
  33. package/bin/road +3 -0
  34. package/bin/seat +10 -0
  35. package/bin/serve.py +1428 -0
  36. package/bin/setup.py +753 -0
  37. package/bin/skills +3 -0
  38. package/bin/test +131 -0
  39. package/bin/test-adapter.py +183 -0
  40. package/bin/test-admision.py +99 -0
  41. package/bin/test-avatar.py +58 -0
  42. package/bin/test-benchmark.py +40 -0
  43. package/bin/test-broker.py +334 -0
  44. package/bin/test-cage.py +206 -0
  45. package/bin/test-card.py +402 -0
  46. package/bin/test-channel.py +379 -0
  47. package/bin/test-cities.py +453 -0
  48. package/bin/test-claude-runtime.py +252 -0
  49. package/bin/test-committee.py +340 -0
  50. package/bin/test-contracts.py +544 -0
  51. package/bin/test-crecimiento.py +164 -0
  52. package/bin/test-demo.py +308 -0
  53. package/bin/test-doctor.py +106 -0
  54. package/bin/test-domains.py +151 -0
  55. package/bin/test-evidencia.py +50 -0
  56. package/bin/test-exit.py +128 -0
  57. package/bin/test-hall-protocol.py +93 -0
  58. package/bin/test-launch.py +169 -0
  59. package/bin/test-live-feed.py +508 -0
  60. package/bin/test-pairing.py +99 -0
  61. package/bin/test-parcels.py +283 -0
  62. package/bin/test-runtime-failures.py +252 -0
  63. package/bin/test-runtime-ui.py +488 -0
  64. package/bin/test-runtime.py +268 -0
  65. package/bin/test-rutas.py +80 -0
  66. package/bin/test-seat.py +1386 -0
  67. package/bin/test-security.py +150 -0
  68. package/bin/test-serve.py +1057 -0
  69. package/bin/test-stress.py +64 -0
  70. package/bin/test-widgets.py +143 -0
  71. package/bin/test-workspace.py +188 -0
  72. package/bin/testlib.py +95 -0
  73. package/bin/tokens.py +14 -0
  74. package/bus/scripts/deploy.sh +60 -0
  75. package/bus/scripts/mint-token.sh +68 -0
  76. package/bus/scripts/setup-dev.sh +79 -0
  77. package/bus/scripts/test-channel.ts +8 -0
  78. package/bus/scripts/test-hub.ts +266 -0
  79. package/bus/scripts/test-local.sh +40 -0
  80. package/bus/scripts/test-queue.ts +132 -0
  81. package/bus/worker/package-lock.json +1971 -0
  82. package/bus/worker/package.json +17 -0
  83. package/bus/worker/src/index.ts +485 -0
  84. package/bus/worker/tsconfig.json +13 -0
  85. package/bus/worker/wrangler.toml +32 -0
  86. package/city/oven/README.md +59 -0
  87. package/city/oven/collect.py +43 -0
  88. package/city/oven/oven.html +4 -0
  89. package/city/scripts/history.py +120 -0
  90. package/city/scripts/seed.py +442 -0
  91. package/city/web/assets/modelos/License.txt +28 -0
  92. package/city/web/assets/modelos/PROCEDENCIA.txt +3 -0
  93. package/city/web/assets/modelos/building-a.glb +0 -0
  94. package/city/web/assets/modelos/building-b.glb +0 -0
  95. package/city/web/assets/modelos/building-c.glb +0 -0
  96. package/city/web/assets/modelos/building-d.glb +0 -0
  97. package/city/web/assets/modelos/building-e.glb +0 -0
  98. package/city/web/assets/modelos/building-f.glb +0 -0
  99. package/city/web/assets/modelos/building-g.glb +0 -0
  100. package/city/web/assets/modelos/building-h.glb +0 -0
  101. package/city/web/assets/sprites/building-a.png +0 -0
  102. package/city/web/assets/sprites/building-b.png +0 -0
  103. package/city/web/assets/sprites/building-c.png +0 -0
  104. package/city/web/assets/sprites/building-d.png +0 -0
  105. package/city/web/assets/sprites/building-e.png +0 -0
  106. package/city/web/assets/sprites/building-f.png +0 -0
  107. package/city/web/assets/sprites/building-g.png +0 -0
  108. package/city/web/assets/sprites/building-h.png +0 -0
  109. package/city/web/assets/sprites/building-i.png +0 -0
  110. package/city/web/assets/sprites/building-j.png +0 -0
  111. package/city/web/assets/sprites/building-k.png +0 -0
  112. package/city/web/assets/sprites/building-l.png +0 -0
  113. package/city/web/assets/sprites/building-m.png +0 -0
  114. package/city/web/assets/sprites/building-n.png +0 -0
  115. package/city/web/assets/sprites/building-skyscraper-a.png +0 -0
  116. package/city/web/assets/sprites/building-skyscraper-b.png +0 -0
  117. package/city/web/assets/sprites/building-skyscraper-c.png +0 -0
  118. package/city/web/assets/sprites/building-skyscraper-d.png +0 -0
  119. package/city/web/assets/sprites/building-skyscraper-e.png +0 -0
  120. package/city/web/assets/sprites/catalogo.json +47 -0
  121. package/city/web/assets/sprites/detail-awning-wide.png +0 -0
  122. package/city/web/assets/sprites/detail-awning.png +0 -0
  123. package/city/web/assets/sprites/detail-overhang-wide.png +0 -0
  124. package/city/web/assets/sprites/detail-overhang.png +0 -0
  125. package/city/web/assets/sprites/detail-parasol-a.png +0 -0
  126. package/city/web/assets/sprites/detail-parasol-b.png +0 -0
  127. package/city/web/assets/sprites/low-detail-building-a.png +0 -0
  128. package/city/web/assets/sprites/low-detail-building-b.png +0 -0
  129. package/city/web/assets/sprites/low-detail-building-c.png +0 -0
  130. package/city/web/assets/sprites/low-detail-building-d.png +0 -0
  131. package/city/web/assets/sprites/low-detail-building-e.png +0 -0
  132. package/city/web/assets/sprites/low-detail-building-f.png +0 -0
  133. package/city/web/assets/sprites/low-detail-building-g.png +0 -0
  134. package/city/web/assets/sprites/low-detail-building-h.png +0 -0
  135. package/city/web/assets/sprites/low-detail-building-i.png +0 -0
  136. package/city/web/assets/sprites/low-detail-building-j.png +0 -0
  137. package/city/web/assets/sprites/low-detail-building-k.png +0 -0
  138. package/city/web/assets/sprites/low-detail-building-l.png +0 -0
  139. package/city/web/assets/sprites/low-detail-building-m.png +0 -0
  140. package/city/web/assets/sprites/low-detail-building-n.png +0 -0
  141. package/city/web/assets/sprites/low-detail-building-wide-a.png +0 -0
  142. package/city/web/assets/sprites/low-detail-building-wide-b.png +0 -0
  143. package/city/web/assets/sprites/medidas.json +248 -0
  144. package/city/web/dist/city.js +939 -0
  145. package/city/web/dist/index.html +294 -0
  146. package/city/web/dist/modelos/License.txt +28 -0
  147. package/city/web/dist/modelos/PROCEDENCIA.txt +3 -0
  148. package/city/web/dist/modelos/building-a.glb +0 -0
  149. package/city/web/dist/modelos/building-b.glb +0 -0
  150. package/city/web/dist/modelos/building-c.glb +0 -0
  151. package/city/web/dist/modelos/building-d.glb +0 -0
  152. package/city/web/dist/modelos/building-e.glb +0 -0
  153. package/city/web/dist/modelos/building-f.glb +0 -0
  154. package/city/web/dist/modelos/building-g.glb +0 -0
  155. package/city/web/dist/modelos/building-h.glb +0 -0
  156. package/city/web/dist/sprites/building-a.png +0 -0
  157. package/city/web/dist/sprites/building-b.png +0 -0
  158. package/city/web/dist/sprites/building-c.png +0 -0
  159. package/city/web/dist/sprites/building-d.png +0 -0
  160. package/city/web/dist/sprites/building-e.png +0 -0
  161. package/city/web/dist/sprites/building-f.png +0 -0
  162. package/city/web/dist/sprites/building-g.png +0 -0
  163. package/city/web/dist/sprites/building-h.png +0 -0
  164. package/city/web/dist/sprites/building-i.png +0 -0
  165. package/city/web/dist/sprites/building-j.png +0 -0
  166. package/city/web/dist/sprites/building-k.png +0 -0
  167. package/city/web/dist/sprites/building-l.png +0 -0
  168. package/city/web/dist/sprites/building-m.png +0 -0
  169. package/city/web/dist/sprites/building-n.png +0 -0
  170. package/city/web/dist/sprites/building-skyscraper-a.png +0 -0
  171. package/city/web/dist/sprites/building-skyscraper-b.png +0 -0
  172. package/city/web/dist/sprites/building-skyscraper-c.png +0 -0
  173. package/city/web/dist/sprites/building-skyscraper-d.png +0 -0
  174. package/city/web/dist/sprites/building-skyscraper-e.png +0 -0
  175. package/city/web/dist/sprites/catalogo.json +47 -0
  176. package/city/web/dist/sprites/detail-awning-wide.png +0 -0
  177. package/city/web/dist/sprites/detail-awning.png +0 -0
  178. package/city/web/dist/sprites/detail-overhang-wide.png +0 -0
  179. package/city/web/dist/sprites/detail-overhang.png +0 -0
  180. package/city/web/dist/sprites/detail-parasol-a.png +0 -0
  181. package/city/web/dist/sprites/detail-parasol-b.png +0 -0
  182. package/city/web/dist/sprites/low-detail-building-a.png +0 -0
  183. package/city/web/dist/sprites/low-detail-building-b.png +0 -0
  184. package/city/web/dist/sprites/low-detail-building-c.png +0 -0
  185. package/city/web/dist/sprites/low-detail-building-d.png +0 -0
  186. package/city/web/dist/sprites/low-detail-building-e.png +0 -0
  187. package/city/web/dist/sprites/low-detail-building-f.png +0 -0
  188. package/city/web/dist/sprites/low-detail-building-g.png +0 -0
  189. package/city/web/dist/sprites/low-detail-building-h.png +0 -0
  190. package/city/web/dist/sprites/low-detail-building-i.png +0 -0
  191. package/city/web/dist/sprites/low-detail-building-j.png +0 -0
  192. package/city/web/dist/sprites/low-detail-building-k.png +0 -0
  193. package/city/web/dist/sprites/low-detail-building-l.png +0 -0
  194. package/city/web/dist/sprites/low-detail-building-m.png +0 -0
  195. package/city/web/dist/sprites/low-detail-building-n.png +0 -0
  196. package/city/web/dist/sprites/low-detail-building-wide-a.png +0 -0
  197. package/city/web/dist/sprites/low-detail-building-wide-b.png +0 -0
  198. package/city/web/dist/sprites/medidas.json +248 -0
  199. package/city/web/dist-hall/hall.js +264 -0
  200. package/city/web/index.html +294 -0
  201. package/city/web/package.json +18 -0
  202. package/city/web/sella.py +21 -0
  203. package/city/web/src/activity-actors.ts +47 -0
  204. package/city/web/src/activity.ts +172 -0
  205. package/city/web/src/ayuntamiento.ts +580 -0
  206. package/city/web/src/draw.ts +640 -0
  207. package/city/web/src/game-speech.ts +79 -0
  208. package/city/web/src/hall.ts +1867 -0
  209. package/city/web/src/main.ts +2225 -0
  210. package/city/web/src/oven.ts +124 -0
  211. package/city/web/src/people.ts +244 -0
  212. package/city/web/src/presencia.ts +193 -0
  213. package/city/web/src/puertas.ts +180 -0
  214. package/city/web/src/repo-roles.ts +59 -0
  215. package/city/worker/package.json +16 -0
  216. package/city/worker/schema.sql +117 -0
  217. package/city/worker/src/index.ts +702 -0
  218. package/city/worker/src/square.ts +218 -0
  219. package/city/worker/wrangler.toml +58 -0
  220. package/demo/ada.md +50 -0
  221. package/demo/bruno.md +62 -0
  222. package/demo/camila.md +56 -0
  223. package/demo/city/ada.md +40 -0
  224. package/demo/city/city.yml +8 -0
  225. package/demo/city/parcels.yml +15 -0
  226. package/demo/city/roads.json +4 -0
  227. package/demo/city/units.yml +10 -0
  228. package/demo/clinica/city.yml +8 -0
  229. package/demo/clinica/parcels.yml +15 -0
  230. package/demo/clinica/roads.json +4 -0
  231. package/demo/clinica/units.yml +9 -0
  232. package/demo/clinica/vera.md +46 -0
  233. package/demo/dante.md +54 -0
  234. package/demo/despacho/city.yml +8 -0
  235. package/demo/despacho/marta.md +46 -0
  236. package/demo/despacho/parcels.yml +15 -0
  237. package/demo/despacho/roads.json +4 -0
  238. package/demo/despacho/units.yml +9 -0
  239. package/demo/elsa.md +50 -0
  240. package/demo/farid.md +52 -0
  241. package/demo/greta.md +50 -0
  242. package/demo/hugo.md +49 -0
  243. package/demo/iris.md +49 -0
  244. package/demo/jonas.md +51 -0
  245. package/demo/kira.md +51 -0
  246. package/demo/luca.md +49 -0
  247. package/demo/parcels.yml +92 -0
  248. package/demo/seed.py +20 -0
  249. package/demo/show.py +161 -0
  250. package/demo/stories.py +824 -0
  251. package/demo/units.yml +33 -0
  252. package/docs/agents-first.md +99 -0
  253. package/docs/glossary.md +55 -0
  254. package/docs/map-live-layers.md +73 -0
  255. package/docs/security.md +231 -0
  256. package/docs/self-host.md +151 -0
  257. package/docs/testing.md +205 -0
  258. package/package.json +73 -0
  259. package/plugin/.claude-plugin/plugin.json +62 -0
  260. package/plugin/.mcp.json +15 -0
  261. package/plugin/channel/activity-cli.ts +16 -0
  262. package/plugin/channel/adapter-prompts.ts +113 -0
  263. package/plugin/channel/adapter.js +4292 -0
  264. package/plugin/channel/adapter.ts +133 -0
  265. package/plugin/channel/bus.js +20188 -0
  266. package/plugin/channel/bus.ts +131 -0
  267. package/plugin/channel/city-config.ts +125 -0
  268. package/plugin/channel/claude-channel.ts +58 -0
  269. package/plugin/channel/cli-args.ts +75 -0
  270. package/plugin/channel/client.js +4200 -0
  271. package/plugin/channel/client.ts +38 -0
  272. package/plugin/channel/committee/activity.ts +256 -0
  273. package/plugin/channel/committee/collection.ts +154 -0
  274. package/plugin/channel/committee/decision.ts +175 -0
  275. package/plugin/channel/committee/floor.ts +135 -0
  276. package/plugin/channel/committee/guards.ts +30 -0
  277. package/plugin/channel/committee/history.ts +38 -0
  278. package/plugin/channel/committee/render.ts +102 -0
  279. package/plugin/channel/committee/service.ts +72 -0
  280. package/plugin/channel/committee/storage.ts +92 -0
  281. package/plugin/channel/committee/types.ts +163 -0
  282. package/plugin/channel/committee/view.ts +63 -0
  283. package/plugin/channel/committee-cli.ts +109 -0
  284. package/plugin/channel/delivery-metrics.ts +61 -0
  285. package/plugin/channel/delivery-queue.ts +118 -0
  286. package/plugin/channel/hub/activity-controller.ts +72 -0
  287. package/plugin/channel/hub/activity-feed.ts +169 -0
  288. package/plugin/channel/hub/committee-controller.ts +60 -0
  289. package/plugin/channel/hub/connections.ts +48 -0
  290. package/plugin/channel/hub/diagnostics.ts +67 -0
  291. package/plugin/channel/hub/envelope-validity.ts +109 -0
  292. package/plugin/channel/hub/envelopes.ts +66 -0
  293. package/plugin/channel/hub/lifecycle.ts +91 -0
  294. package/plugin/channel/hub/local-roads.ts +89 -0
  295. package/plugin/channel/hub/remote-roads.ts +165 -0
  296. package/plugin/channel/hub/road-controller.ts +99 -0
  297. package/plugin/channel/hub-client.ts +149 -0
  298. package/plugin/channel/local-hub.js +5995 -0
  299. package/plugin/channel/local-hub.ts +349 -0
  300. package/plugin/channel/map-reporter.ts +48 -0
  301. package/plugin/channel/package-lock.json +1731 -0
  302. package/plugin/channel/package.json +19 -0
  303. package/plugin/channel/protocol.ts +98 -0
  304. package/plugin/channel/road-cli.ts +18 -0
  305. package/plugin/channel/run.sh +15 -0
  306. package/plugin/channel/runtime/claude.ts +458 -0
  307. package/plugin/channel/runtime/codex-config.ts +77 -0
  308. package/plugin/channel/runtime/codex.ts +669 -0
  309. package/plugin/channel/runtime/command.ts +62 -0
  310. package/plugin/channel/runtime/factory.ts +15 -0
  311. package/plugin/channel/runtime/json-rpc.ts +154 -0
  312. package/plugin/channel/runtime/kimi.ts +277 -0
  313. package/plugin/channel/runtime/opencode.ts +248 -0
  314. package/plugin/channel/runtime/process.ts +84 -0
  315. package/plugin/channel/runtime/types.ts +45 -0
  316. package/plugin/channel/runtime-files.ts +73 -0
  317. package/plugin/channel/runtime-gateway.js +6396 -0
  318. package/plugin/channel/runtime-gateway.ts +358 -0
  319. package/plugin/channel/runtime-metrics.ts +52 -0
  320. package/plugin/channel/runtime-subscription.ts +180 -0
  321. package/plugin/channel/terminal-delivery.ts +131 -0
  322. package/plugin/channel/untrusted.ts +60 -0
  323. package/plugin/commands/committee.md +31 -0
  324. package/plugin/commands/exit.md +32 -0
  325. package/plugin/commands/goals.md +20 -0
  326. package/plugin/commands/join.md +18 -0
  327. package/plugin/commands/notice.md +20 -0
  328. package/plugin/commands/propose.md +19 -0
  329. package/plugin/commands/round.md +17 -0
  330. package/plugin/commands/session.md +22 -0
  331. package/plugin/commands/settings.md +25 -0
  332. package/plugin/commands/setup.md +21 -0
  333. package/plugin/commands/team.md +23 -0
  334. package/plugin/domains/custom.md +27 -0
  335. package/plugin/domains/finance.md +31 -0
  336. package/plugin/domains/healthcare.md +34 -0
  337. package/plugin/domains/legal.md +31 -0
  338. package/plugin/domains/marketing.md +33 -0
  339. package/plugin/domains/operations.md +31 -0
  340. package/plugin/domains/research.md +32 -0
  341. package/plugin/domains/sales.md +30 -0
  342. package/plugin/domains/software.md +37 -0
  343. package/plugin/hooks/activity.sh +6 -0
  344. package/plugin/hooks/digging.sh +68 -0
  345. package/plugin/hooks/growth.sh +42 -0
  346. package/plugin/hooks/hooks.json +106 -0
  347. package/plugin/hooks/notice-on-pr.sh +35 -0
  348. package/plugin/hooks/notice-on-stop.sh +80 -0
  349. package/plugin/hooks/notice-pending.sh +58 -0
  350. package/plugin/hooks/solo-en-ciudad.sh +23 -0
  351. package/plugin/hooks/tokens.sh +47 -0
  352. package/plugin/roles/examples/account-executive.md +25 -0
  353. package/plugin/roles/examples/ai-manager.md +34 -0
  354. package/plugin/roles/examples/associate.md +24 -0
  355. package/plugin/roles/examples/brand-lead.md +20 -0
  356. package/plugin/roles/examples/cfo.md +20 -0
  357. package/plugin/roles/examples/city-lead.md +25 -0
  358. package/plugin/roles/examples/clinical-director.md +26 -0
  359. package/plugin/roles/examples/clinical-ops.md +25 -0
  360. package/plugin/roles/examples/clinician.md +25 -0
  361. package/plugin/roles/examples/compliance.md +27 -0
  362. package/plugin/roles/examples/content.md +24 -0
  363. package/plugin/roles/examples/controller.md +27 -0
  364. package/plugin/roles/examples/cpto.md +41 -0
  365. package/plugin/roles/examples/customer-success.md +25 -0
  366. package/plugin/roles/examples/data-engineer.md +37 -0
  367. package/plugin/roles/examples/data.md +60 -0
  368. package/plugin/roles/examples/dev.md +37 -0
  369. package/plugin/roles/examples/devops.md +39 -0
  370. package/plugin/roles/examples/enablement.md +24 -0
  371. package/plugin/roles/examples/ethics.md +24 -0
  372. package/plugin/roles/examples/fin-analytics.md +27 -0
  373. package/plugin/roles/examples/health-compliance.md +25 -0
  374. package/plugin/roles/examples/health-data.md +26 -0
  375. package/plugin/roles/examples/knowledge.md +25 -0
  376. package/plugin/roles/examples/lifecycle.md +26 -0
  377. package/plugin/roles/examples/llm-engineer.md +34 -0
  378. package/plugin/roles/examples/managing-partner.md +20 -0
  379. package/plugin/roles/examples/methods.md +23 -0
  380. package/plugin/roles/examples/operations-lead.md +23 -0
  381. package/plugin/roles/examples/ops.md +24 -0
  382. package/plugin/roles/examples/patient-safety.md +26 -0
  383. package/plugin/roles/examples/performance.md +27 -0
  384. package/plugin/roles/examples/po.md +36 -0
  385. package/plugin/roles/examples/process-owner.md +23 -0
  386. package/plugin/roles/examples/product-design.md +39 -0
  387. package/plugin/roles/examples/program-manager.md +24 -0
  388. package/plugin/roles/examples/quality.md +25 -0
  389. package/plugin/roles/examples/research-director.md +24 -0
  390. package/plugin/roles/examples/research-ops.md +25 -0
  391. package/plugin/roles/examples/researcher.md +25 -0
  392. package/plugin/roles/examples/revenue-lead.md +25 -0
  393. package/plugin/roles/examples/revops.md +26 -0
  394. package/plugin/roles/examples/seo.md +27 -0
  395. package/plugin/roles/examples/specialist.md +24 -0
  396. package/plugin/scripts/admision.py +165 -0
  397. package/plugin/scripts/apaga.py +232 -0
  398. package/plugin/scripts/avatar.py +211 -0
  399. package/plugin/scripts/broker.py +531 -0
  400. package/plugin/scripts/cage.py +252 -0
  401. package/plugin/scripts/capabilities.py +197 -0
  402. package/plugin/scripts/card.py +312 -0
  403. package/plugin/scripts/cities.py +520 -0
  404. package/plugin/scripts/city-env.sh +90 -0
  405. package/plugin/scripts/city-runtime.sh +87 -0
  406. package/plugin/scripts/city-session.sh +526 -0
  407. package/plugin/scripts/city_env.py +67 -0
  408. package/plugin/scripts/crecimiento.py +140 -0
  409. package/plugin/scripts/deliberations.py +48 -0
  410. package/plugin/scripts/doctor.py +172 -0
  411. package/plugin/scripts/domains.py +201 -0
  412. package/plugin/scripts/evidencia.py +64 -0
  413. package/plugin/scripts/find-repos.sh +111 -0
  414. package/plugin/scripts/gh.py +159 -0
  415. package/plugin/scripts/hall_protocol.py +147 -0
  416. package/plugin/scripts/hook_activity.py +139 -0
  417. package/plugin/scripts/launch.py +94 -0
  418. package/plugin/scripts/logs.py +98 -0
  419. package/plugin/scripts/pairing.py +185 -0
  420. package/plugin/scripts/parcels.py +132 -0
  421. package/plugin/scripts/read-card.py +51 -0
  422. package/plugin/scripts/report.py +201 -0
  423. package/plugin/scripts/reset.py +180 -0
  424. package/plugin/scripts/roads.py +230 -0
  425. package/plugin/scripts/roles.py +274 -0
  426. package/plugin/scripts/runtime_log.py +69 -0
  427. package/plugin/scripts/runtime_processes.py +132 -0
  428. package/plugin/scripts/rutas.py +83 -0
  429. package/plugin/scripts/seat.py +1349 -0
  430. package/plugin/scripts/tokens.py +194 -0
  431. package/plugin/scripts/trust-repos.py +59 -0
  432. package/plugin/scripts/ui.py +280 -0
  433. package/plugin/scripts/units.py +80 -0
  434. package/plugin/scripts/workspace.py +364 -0
  435. package/plugin/skills/city/SKILL.md +191 -0
  436. package/templates/blank.md +39 -0
  437. package/templates/finance.md +44 -0
  438. package/templates/legal.md +45 -0
  439. package/templates/marketing.md +48 -0
  440. package/templates/product.md +45 -0
@@ -0,0 +1,334 @@
1
+ #!/usr/bin/env python3
2
+ """The broker: real git against a bare origin, a stubbed gh, a live server.
3
+
4
+ Everything runs below a throwaway HOME. The happy paths use a REAL `git push`
5
+ to a local bare remote — no network — and a `gh` stub planted on PATH that
6
+ records its arguments. The unhappy paths are the product: work on the default
7
+ branch refused, a missing token 401, a wrong token 403, a tampered audit line
8
+ caught by the chain, and a stopped broker that leaves no orphan behind.
9
+ """
10
+
11
+ import json
12
+ import os
13
+ import shutil
14
+ import subprocess
15
+ import sys
16
+ import tempfile
17
+ import time
18
+ import urllib.error
19
+ import urllib.request
20
+
21
+ AQUI = os.path.dirname(os.path.abspath(__file__))
22
+ RAIZ = os.path.dirname(AQUI)
23
+ sys.path.insert(0, os.path.join(RAIZ, "plugin", "scripts"))
24
+ sys.path.insert(0, AQUI)
25
+
26
+ import broker # noqa: E402
27
+ from testlib import afirma, comprueba, detiene_proceso, resumen # noqa: E402
28
+
29
+ BROKER = os.path.join(RAIZ, "plugin", "scripts", "broker.py")
30
+
31
+
32
+ def _git(repo, *args):
33
+ return subprocess.run(["git", "-C", repo, *args], capture_output=True, text=True)
34
+
35
+
36
+ def prepara():
37
+ base = tempfile.mkdtemp(prefix="agents-city-broker-")
38
+ os.environ["AGENTS_CITY_HOME"] = os.path.join(base, "app")
39
+ datos = os.path.join(base, "city")
40
+ os.makedirs(datos)
41
+ origen = os.path.join(base, "origin.git")
42
+ subprocess.run(["git", "init", "-q", "--bare", origen], check=True)
43
+ repo = os.path.join(base, "repo")
44
+ subprocess.run(["git", "init", "-q", "-b", "main", repo], check=True)
45
+ _git(repo, "config", "user.email", "t@t")
46
+ _git(repo, "config", "user.name", "t")
47
+ with open(os.path.join(repo, "f.txt"), "w") as f:
48
+ f.write("a\n")
49
+ _git(repo, "add", "f.txt")
50
+ _git(repo, "commit", "-qm", "first")
51
+ _git(repo, "remote", "add", "origin", origen)
52
+ _git(repo, "push", "-q", "origin", "main")
53
+ stubs = os.path.join(base, "stubs")
54
+ os.makedirs(stubs)
55
+ with open(os.path.join(stubs, "gh"), "w") as f:
56
+ f.write('#!/bin/sh\necho "$@" > "$GH_STUB_LOG"\n'
57
+ 'echo "https://github.com/x/y/pull/7"\n')
58
+ os.chmod(os.path.join(stubs, "gh"), 0o755)
59
+ return base, datos, repo, origen, stubs
60
+
61
+
62
+ def pide(url, ruta, token, cuerpo=None):
63
+ peticion = urllib.request.Request(
64
+ url + ruta, data=json.dumps(cuerpo or {}).encode(), method="POST",
65
+ headers={"Authorization": f"Bearer {token}"} if token else {})
66
+ try:
67
+ with urllib.request.urlopen(peticion, timeout=10) as r:
68
+ return r.status, json.loads(r.read())
69
+ except urllib.error.HTTPError as e:
70
+ return e.code, json.loads(e.read() or b"{}")
71
+
72
+
73
+ def arranca(datos, entorno):
74
+ proceso = subprocess.Popen([sys.executable, BROKER, "serve", "--data", datos],
75
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
76
+ text=True, env=entorno)
77
+ limite = time.monotonic() + 10
78
+ while time.monotonic() < limite:
79
+ url = broker.url_de(datos)
80
+ if url:
81
+ return proceso, url
82
+ time.sleep(0.05)
83
+ proceso.kill()
84
+ # Never a blind failure: whatever the child said is the whole diagnosis,
85
+ # and CI is the one place nobody can re-run it by hand to find out.
86
+ salida, error = proceso.communicate(timeout=5)
87
+ raise RuntimeError(
88
+ f"the broker never wrote its endpoint\n stdout: {salida.strip()}\n"
89
+ f" stderr: {error.strip()}"
90
+ )
91
+
92
+
93
+ def todo():
94
+ base, datos, repo, origen, stubs = prepara()
95
+ entorno = dict(os.environ, PATH=stubs + os.pathsep + os.environ["PATH"],
96
+ GH_STUB_LOG=os.path.join(base, "gh.log"))
97
+ proceso = None
98
+ try:
99
+ ficha = broker.acuna(datos, "web", repo, solo_fichero=True)
100
+ afirma("mint writes the token file with 0600",
101
+ oct(os.stat(ficha).st_mode & 0o777) == "0o600", ficha)
102
+ token = open(ficha).read().strip()
103
+ guardados = json.load(open(os.path.join(broker.estado_de(datos), "tokens.json")))
104
+ afirma("only the hash of the token is stored, never the token",
105
+ token not in json.dumps(guardados) and len(guardados) == 1)
106
+ try:
107
+ broker.acuna(datos, "x", os.path.join(base, "not-a-repo"))
108
+ afirma("minting against a non-repo is refused", False)
109
+ except ValueError:
110
+ afirma("minting against a non-repo is refused", True)
111
+
112
+ proceso, url = arranca(datos, entorno)
113
+
114
+ estado, _ = pide(url, "/v1/push", token)
115
+ comprueba("push on the default branch is refused with 422", estado, 422)
116
+ estado, _ = pide(url, "/v1/pr", token, {"title": "x"})
117
+ comprueba("a PR from the default branch is refused too", estado, 422)
118
+
119
+ _git(repo, "checkout", "-qb", "feature")
120
+ with open(os.path.join(repo, "f.txt"), "w") as f:
121
+ f.write("b\n")
122
+ _git(repo, "commit", "-qam", "change")
123
+
124
+ estado, cuerpo = pide(url, "/v1/push", token)
125
+ comprueba("push on a feature branch succeeds", estado, 200)
126
+ afirma("the bare origin actually received the branch",
127
+ _git(origen, "rev-parse", "feature").returncode == 0)
128
+ comprueba("the response names the branch it pushed", cuerpo.get("branch"), "feature")
129
+
130
+ estado, cuerpo = pide(url, "/v1/pr", token, {"title": "Add b", "body": "why"})
131
+ comprueba("gh runs and the PR URL comes back", estado, 200)
132
+ comprueba("the URL is gh's answer, verbatim",
133
+ cuerpo.get("url"), "https://github.com/x/y/pull/7")
134
+ registrado = open(os.path.join(base, "gh.log")).read()
135
+ afirma("gh received the title the window asked for", "Add b" in registrado, registrado)
136
+
137
+ estado, _ = pide(url, "/v1/pr", token, {"title": ""})
138
+ comprueba("a PR without a title is refused", estado, 422)
139
+ estado, _ = pide(url, "/v1/push", "")
140
+ comprueba("no token at all is a 401", estado, 401)
141
+ estado, _ = pide(url, "/v1/push", "cb_" + "0" * 48)
142
+ comprueba("a wrong token is a 403", estado, 403)
143
+ estado, _ = pide(url, "/v1/nothing", token)
144
+ comprueba("an unknown verb is a 404", estado, 404)
145
+
146
+ registro = os.path.join(broker.estado_de(datos), "audit.log")
147
+ lineas = open(registro).read().splitlines()
148
+ afirma("every request, refused ones included, landed in the audit log",
149
+ sum(1 for l in lineas if '"/v1/' in l) >= 7, f"{len(lineas)} lines")
150
+ comprueba("the audit chain verifies intact", broker.verifica(broker.estado_de(datos)), 0)
151
+ manipuladas = list(lineas)
152
+ falseada = json.loads(manipuladas[2])
153
+ falseada["ok"] = not falseada["ok"]
154
+ manipuladas[2] = json.dumps(falseada, sort_keys=True)
155
+ with open(registro, "w") as f:
156
+ f.write("\n".join(manipuladas) + "\n")
157
+ afirma("one rewritten entry breaks the chain at the next line",
158
+ broker.verifica(broker.estado_de(datos)) == 4,
159
+ f"broken at {broker.verifica(broker.estado_de(datos))}")
160
+
161
+ afirma("stop finds the pid it published and signals it", broker.para(datos))
162
+ try:
163
+ proceso.wait(timeout=5)
164
+ afirma("the broker is gone, no orphan left", detiene_proceso(proceso.pid))
165
+ except subprocess.TimeoutExpired:
166
+ afirma("the broker is gone, no orphan left", False)
167
+ proceso = None
168
+ comprueba("a stopped broker publishes no url", broker.url_de(datos), "")
169
+ finally:
170
+ if proceso:
171
+ proceso.kill()
172
+ shutil.rmtree(base, ignore_errors=True)
173
+ os.environ.pop("AGENTS_CITY_HOME", None)
174
+
175
+
176
+ def secretos_y_evidencia():
177
+ from evidencia import Evidencia
178
+
179
+ base = tempfile.mkdtemp(prefix="agents-city-broker-")
180
+ os.environ["AGENTS_CITY_HOME"] = os.path.join(base, "app")
181
+ try:
182
+ datos = os.path.join(base, "city")
183
+ os.makedirs(datos)
184
+ estado = broker.estado_de(datos)
185
+
186
+ # Binding rules: a host is required, and wildcards/ports are refused.
187
+ for malo in ("*.example.com", "api.example.com:443", "a/b"):
188
+ try:
189
+ broker.guarda_secreto(datos, "K", "v", [malo])
190
+ afirma(f"host '{malo}' is refused", False)
191
+ except ValueError:
192
+ afirma(f"host '{malo}' is refused", True)
193
+ try:
194
+ broker.guarda_secreto(datos, "K", "v", [])
195
+ afirma("a secret with no host is refused", False)
196
+ except ValueError:
197
+ afirma("a secret with no host is refused", True)
198
+
199
+ broker.guarda_secreto(datos, "OPENAI", "sk-REAL-SECRET", ["api.openai.com"])
200
+ valor, motivo = broker._host_permitido(estado, "OPENAI", "api.openai.com")
201
+ comprueba("a bound host resolves the value", valor, "sk-REAL-SECRET")
202
+ _, motivo = broker._host_permitido(estado, "OPENAI", "evil.example.com")
203
+ afirma("an unbound host is refused with a reason", motivo is not None)
204
+ _, motivo = broker._host_permitido(estado, "NOPE", "api.openai.com")
205
+ afirma("an unknown secret name is refused", motivo is not None)
206
+
207
+ quien = {"actor": "web", "repo": base}
208
+ code, resp = broker.accion_fetch(quien, {"secret": "OPENAI"}, estado)
209
+ comprueba("fetch without a host is 422", code, 422)
210
+ code, resp = broker.accion_fetch(
211
+ quien, {"secret": "OPENAI", "host": "evil.example.com", "path": "/v1/x"}, estado)
212
+ comprueba("fetch to an unbound host is 403", code, 403)
213
+ afirma("the 403 never echoes the secret value", "sk-REAL-SECRET" not in str(resp))
214
+ code, resp = broker.accion_fetch(
215
+ quien, {"secret": "OPENAI", "host": "api.openai.com", "method": "DELETE"}, estado)
216
+ comprueba("an unsupported method is 422", code, 422)
217
+
218
+ # The success path: capture the request without a socket, prove the
219
+ # secret is injected into the declared header only for the bound host.
220
+ capturado = {}
221
+
222
+ class _Resp:
223
+ status = 200
224
+
225
+ def __enter__(self):
226
+ return self
227
+
228
+ def __exit__(self, *a):
229
+ return False
230
+
231
+ def read(self, *_):
232
+ return b'{"ok":true}'
233
+
234
+ def _fake_open(req, timeout=0):
235
+ capturado["url"] = req.full_url
236
+ capturado["auth"] = req.headers.get("X-api-key") or req.headers.get("Authorization")
237
+ return _Resp()
238
+
239
+ # Patch the no-redirect opener the action actually uses.
240
+ original = broker._SIN_REDIRECCIONES.open
241
+ broker._SIN_REDIRECCIONES.open = _fake_open
242
+ try:
243
+ code, resp = broker.accion_fetch(
244
+ quien,
245
+ {"secret": "OPENAI", "host": "api.openai.com", "path": "/v1/models",
246
+ "header": "X-Api-Key", "template": "{secret}"},
247
+ estado)
248
+ # Control characters in a window-controlled field are refused as 422,
249
+ # not left to raise deep inside urllib as a 500.
250
+ code_ctl, _ = broker.accion_fetch(
251
+ quien,
252
+ {"secret": "OPENAI", "host": "api.openai.com", "path": "/x",
253
+ "header": "X-Api-Key", "template": "{secret}\r\nX-Injected: 1"},
254
+ estado)
255
+ finally:
256
+ broker._SIN_REDIRECCIONES.open = original
257
+ comprueba("a bound fetch returns 200", code, 200)
258
+ comprueba("it hit the exact bound host over https",
259
+ capturado["url"], "https://api.openai.com/v1/models")
260
+ comprueba("the secret was injected into the declared header",
261
+ capturado["auth"], "sk-REAL-SECRET")
262
+ afirma("and the broker's own response never carries the secret",
263
+ "sk-REAL-SECRET" not in str(resp))
264
+ comprueba("a CRLF-injected header is refused with 422", code_ctl, 422)
265
+ afirma("the redirect handler refuses to follow any 3xx (secret stays on host)",
266
+ broker._NoSigue().redirect_request(None, None, None, None, None) is None)
267
+
268
+ # Every audit line carries an evidence state, and the chain holds.
269
+ lineas = open(os.path.join(estado, "audit.log")).read().splitlines()
270
+ estados = {json.loads(x).get("evidence") for x in lineas}
271
+ afirma("audit lines carry evidence states",
272
+ str(Evidencia.IMPUESTO) in estados)
273
+ afirma("no audit line ever claims 'unknown means ok'",
274
+ all(not (json.loads(x).get("ok") and json.loads(x).get("evidence") ==
275
+ str(Evidencia.DESCONOCIDO)) for x in lineas))
276
+ comprueba("the chain verifies intact after secret ops",
277
+ broker.verifica(estado), 0)
278
+ finally:
279
+ shutil.rmtree(base, ignore_errors=True)
280
+ os.environ.pop("AGENTS_CITY_HOME", None)
281
+
282
+
283
+ def arranca_sin_dns_inversa():
284
+ """A loopback broker must start without waiting on the network.
285
+
286
+ The stdlib's HTTPServer.server_bind calls socket.getfqdn() for a cosmetic
287
+ name — a reverse DNS lookup. On a resolver that never answers (a CI macOS
288
+ runner, an airplane, a locked-down office LAN) it blocks for tens of
289
+ seconds, and the broker's endpoint appears only after it returns. This is
290
+ that hang's tombstone: getfqdn is sabotaged to take forever, and the
291
+ broker must still be serving.
292
+ """
293
+ import socket
294
+ import threading
295
+
296
+ base = tempfile.mkdtemp(prefix="agents-city-broker-dns-")
297
+ os.environ["AGENTS_CITY_HOME"] = os.path.join(base, "app")
298
+ datos = os.path.join(base, "city")
299
+ os.makedirs(datos)
300
+ original = socket.getfqdn
301
+ socket.getfqdn = lambda *a: time.sleep(60) or "nunca"
302
+ servidor = None
303
+ try:
304
+ arrancado = threading.Event()
305
+
306
+ def sirve_en_hilo():
307
+ nonlocal servidor
308
+ servidor = broker.Servidor(("127.0.0.1", 0), broker.Manejador)
309
+ arrancado.set()
310
+ servidor.serve_forever()
311
+
312
+ broker.Manejador.estado = broker.estado_de(datos)
313
+ hilo = threading.Thread(target=sirve_en_hilo, daemon=True)
314
+ comienzo = time.monotonic()
315
+ hilo.start()
316
+ listo = arrancado.wait(timeout=5)
317
+ afirma(
318
+ "the broker binds without waiting on reverse DNS",
319
+ listo and time.monotonic() - comienzo < 5,
320
+ f"{round(time.monotonic() - comienzo, 2)}s",
321
+ )
322
+ finally:
323
+ socket.getfqdn = original
324
+ if servidor is not None:
325
+ servidor.shutdown()
326
+ servidor.server_close()
327
+ shutil.rmtree(base, ignore_errors=True)
328
+ os.environ.pop("AGENTS_CITY_HOME", None)
329
+
330
+
331
+ todo()
332
+ secretos_y_evidencia()
333
+ arranca_sin_dns_inversa()
334
+ sys.exit(resumen("broker"))
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env python3
2
+ """The cage: profile text everywhere, the real kernel where there is one.
3
+
4
+ The profile checks are pure and run on every platform, CI's Linux included.
5
+ The live checks exec `sandbox-exec` against a throwaway HOME with planted
6
+ fake secrets — they prove the four properties everything else relies on:
7
+ secrets unreadable, foreign writes refused, repo writes intact, grandchildren
8
+ still caged. On a machine without seatbelt they skip, loudly.
9
+ """
10
+
11
+ import os
12
+ import shutil
13
+ import subprocess
14
+ import sys
15
+ import tempfile
16
+
17
+ AQUI = os.path.dirname(os.path.abspath(__file__))
18
+ RAIZ = os.path.dirname(AQUI)
19
+ sys.path.insert(0, os.path.join(RAIZ, "plugin", "scripts"))
20
+ sys.path.insert(0, AQUI)
21
+
22
+ import cage # noqa: E402
23
+ from testlib import afirma, comprueba, resumen # noqa: E402
24
+
25
+
26
+ def entorno_falso():
27
+ base = tempfile.mkdtemp(prefix="agents-city-cage-")
28
+ casa = os.path.join(base, "home")
29
+ repo = os.path.join(base, "repo")
30
+ os.makedirs(os.path.join(casa, ".ssh"))
31
+ os.makedirs(repo)
32
+ with open(os.path.join(casa, ".ssh", "id_ed25519"), "w") as f:
33
+ f.write("FAKE-PRIVATE-KEY\n")
34
+ with open(os.path.join(casa, ".git-credentials"), "w") as f:
35
+ f.write("https://user:FAKE-TOKEN@github.com\n")
36
+ return base, casa, repo
37
+
38
+
39
+ def texto_del_perfil():
40
+ base, casa, repo = entorno_falso()
41
+ try:
42
+ p = cage.perfil(repo, casa=casa)
43
+ afirma("the profile starts with the SBPL version header",
44
+ p.startswith("(version 1)"), p.splitlines()[0])
45
+ afirma("the profile allows by default and denies writes globally",
46
+ "(allow default)" in p and "(deny file-write*)" in p)
47
+ for secreto in (".ssh", ".aws", ".config/gcloud", ".config/gh"):
48
+ afirma(f"the profile seals {secreto} for reads and writes",
49
+ os.path.join(casa, *secreto.split("/")) in p, secreto)
50
+ afirma("the profile seals ~/.git-credentials",
51
+ os.path.join(casa, ".git-credentials") in p)
52
+ afirma("the repo itself is writable",
53
+ f'(subpath "{os.path.realpath(repo)}")' in p)
54
+ afirma("remote road .env files are sealed even inside ~/.claude",
55
+ "channels" in p and "\\.env" in p)
56
+ afirma("the broker state dir is sealed",
57
+ os.path.join(casa, ".agents-city", ".runtime", "broker") in p)
58
+ afirma("~/.npmrc stays readable on purpose (a broken npm protects nobody)",
59
+ os.path.join(casa, ".npmrc") not in p)
60
+ finally:
61
+ shutil.rmtree(base, ignore_errors=True)
62
+
63
+
64
+ def excepciones_y_errores():
65
+ base, casa, repo = entorno_falso()
66
+ try:
67
+ ficha = os.path.join(base, "win.token")
68
+ with open(ficha, "w") as f:
69
+ f.write("cb_x\n")
70
+ p = cage.perfil(repo, casa=casa, fichero_token=ficha)
71
+ afirma("the window's own broker token file is re-allowed read-only",
72
+ f'(allow file-read* (literal "{os.path.realpath(ficha)}"))' in p)
73
+ try:
74
+ cage.perfil(os.path.join(base, "no-such-dir"), casa=casa)
75
+ afirma("a nonexistent repo is refused", False)
76
+ except ValueError:
77
+ afirma("a nonexistent repo is refused", True)
78
+ previo = os.environ.get("CITY_CAGE")
79
+ os.environ["CITY_CAGE"] = "0"
80
+ comprueba("CITY_CAGE=0 launches uncaged, exactly the old behaviour",
81
+ cage.linea(repo, "w", casa=casa), "")
82
+ if previo is None:
83
+ os.environ.pop("CITY_CAGE")
84
+ else:
85
+ os.environ["CITY_CAGE"] = previo
86
+ os.environ["CITY_CAGE_DENY"] = os.path.join(casa, ".npmrc")
87
+ try:
88
+ afirma("CITY_CAGE_DENY adds an owner-chosen path to the seal",
89
+ os.path.join(casa, ".npmrc") in cage.perfil(repo, casa=casa))
90
+ finally:
91
+ os.environ.pop("CITY_CAGE_DENY")
92
+ # A repo that IS a sealed root (or covers one) is refused outright:
93
+ # there is no safe cage for a window rooted on the credential store.
94
+ try:
95
+ cage.perfil(os.path.join(casa, ".ssh"), casa=casa)
96
+ afirma("a repo path that is a sealed root is refused", False)
97
+ except ValueError as e:
98
+ afirma("a repo path that is a sealed root is refused", "unsafe" in str(e))
99
+ # An owner CITY_CAGE_ALLOW_WRITE that lands inside a sealed root is
100
+ # dropped (dead under the final deny) rather than emitted.
101
+ os.environ["CITY_CAGE_ALLOW_WRITE"] = os.path.join(casa, ".ssh", "sub")
102
+ try:
103
+ afirma("a write-allow inside a sealed root is not emitted",
104
+ os.path.join(casa, ".ssh", "sub") not in cage.perfil(repo, casa=casa))
105
+ finally:
106
+ os.environ.pop("CITY_CAGE_ALLOW_WRITE")
107
+ # A broad system root that merely COVERS a sealed root stays allowed:
108
+ # the final deny re-seals the secret, so writability is not sacrificed.
109
+ afirma("a covering system root is still allowed (ordering re-seals)",
110
+ '(subpath "/private/tmp")' in cage.perfil(repo, casa=casa))
111
+ # A relocated AGENTS_CITY_HOME must move the broker seal with it — the
112
+ # broker writes tokens there, so the cage must seal the same root.
113
+ relocado = os.path.join(base, "elsewhere-home")
114
+ os.makedirs(relocado)
115
+ previo_ac = os.environ.get("AGENTS_CITY_HOME")
116
+ os.environ["AGENTS_CITY_HOME"] = relocado
117
+ try:
118
+ p = cage.perfil(repo, casa=casa)
119
+ afirma("the broker seal follows a relocated AGENTS_CITY_HOME",
120
+ os.path.join(os.path.realpath(relocado), ".runtime", "broker") in p)
121
+ afirma("and the relocated home stays writable",
122
+ f'(subpath "{os.path.realpath(relocado)}")' in p)
123
+ finally:
124
+ if previo_ac is None:
125
+ os.environ.pop("AGENTS_CITY_HOME")
126
+ else:
127
+ os.environ["AGENTS_CITY_HOME"] = previo_ac
128
+ finally:
129
+ shutil.rmtree(base, ignore_errors=True)
130
+
131
+
132
+ def mounts_agente_primero():
133
+ # Agent-first: a workspace with a mount to a document folder outside it.
134
+ # The mount's resolved target must be writable, and secrets still sealed.
135
+ base, casa, _repo = entorno_falso()
136
+ try:
137
+ ws = os.path.join(base, "workspace")
138
+ docs = os.path.join(base, "elsewhere-docs")
139
+ os.makedirs(ws)
140
+ os.makedirs(docs)
141
+ p = cage.perfil(ws, casa=casa, extra_escritura=[docs])
142
+ afirma("a mount target is added as a writable root",
143
+ f'(subpath "{os.path.realpath(docs)}")' in p)
144
+ afirma("the workspace itself is writable",
145
+ f'(subpath "{os.path.realpath(ws)}")' in p)
146
+ # A mount that resolves inside a sealed root is refused, not honoured.
147
+ p2 = cage.perfil(ws, casa=casa, extra_escritura=[os.path.join(casa, ".ssh")])
148
+ afirma("a mount inside a sealed root is not made writable",
149
+ f'(subpath "{os.path.join(os.path.realpath(casa), ".ssh")}")'
150
+ not in p2.split("(deny file-read")[0])
151
+ finally:
152
+ shutil.rmtree(base, ignore_errors=True)
153
+
154
+
155
+ def _enjaulado(perfil_ruta, *orden):
156
+ return subprocess.run(["sandbox-exec", "-f", perfil_ruta, *orden],
157
+ capture_output=True, text=True)
158
+
159
+
160
+ def jaula_viva():
161
+ if sys.platform != "darwin" or not shutil.which("sandbox-exec"):
162
+ print(" (live seatbelt checks skipped: no sandbox-exec on this machine)")
163
+ return
164
+ base, casa, repo = entorno_falso()
165
+ try:
166
+ ruta = os.path.join(base, "cage.sb")
167
+ with open(ruta, "w") as f:
168
+ f.write(cage.perfil(repo, casa=casa))
169
+ r = _enjaulado(ruta, "/bin/cat", os.path.join(casa, ".ssh", "id_ed25519"))
170
+ afirma("live: the planted SSH key is unreadable inside the cage",
171
+ r.returncode != 0 and "FAKE-PRIVATE-KEY" not in r.stdout, r.stdout or r.stderr)
172
+ r = _enjaulado(ruta, "/bin/cat", os.path.join(casa, ".git-credentials"))
173
+ afirma("live: the planted git credentials are unreadable inside the cage",
174
+ r.returncode != 0 and "FAKE-TOKEN" not in r.stdout)
175
+ r = _enjaulado(ruta, "/bin/zsh", "-c",
176
+ f"echo ok > {repo}/build.txt && cat {repo}/build.txt")
177
+ afirma("live: the window still writes freely in its own repo",
178
+ r.returncode == 0 and r.stdout.strip() == "ok", r.stderr)
179
+ # The throwaway HOME sits under the system TMPDIR, which the cage keeps
180
+ # writable on purpose — so the write-refusal proof targets a sealed dir.
181
+ r = _enjaulado(ruta, "/bin/zsh", "-c", f"echo leak > {casa}/.ssh/stolen.txt")
182
+ afirma("live: a write into a sealed directory is refused by the kernel",
183
+ r.returncode != 0
184
+ and not os.path.exists(os.path.join(casa, ".ssh", "stolen.txt")))
185
+ r = _enjaulado(ruta, "/bin/zsh", "-c",
186
+ f"/bin/zsh -c 'cat {casa}/.ssh/id_ed25519'")
187
+ afirma("live: a grandchild process inherits the cage",
188
+ r.returncode != 0 and "FAKE-PRIVATE-KEY" not in r.stdout)
189
+ linea = cage.linea(repo, "win one", casa=casa)
190
+ afirma("live: linea() hands back a sandbox-exec prefix",
191
+ linea.startswith("sandbox-exec -f "), linea)
192
+ perfil_generado = linea.split(" -f ", 1)[1].strip()
193
+ esperado = os.path.join(os.path.realpath(casa), ".agents-city", ".runtime", "cage")
194
+ afirma("live: the generated profile lands under the runtime cage dir with 0600",
195
+ perfil_generado.startswith(esperado)
196
+ and oct(os.stat(perfil_generado).st_mode & 0o777) == "0o600",
197
+ perfil_generado)
198
+ finally:
199
+ shutil.rmtree(base, ignore_errors=True)
200
+
201
+
202
+ texto_del_perfil()
203
+ excepciones_y_errores()
204
+ mounts_agente_primero()
205
+ jaula_viva()
206
+ sys.exit(resumen("cage"))