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.
- package/.claude-plugin/marketplace.json +16 -0
- package/LICENSE +202 -0
- package/NOTICE +5 -0
- package/README.es.md +2021 -0
- package/README.md +1999 -0
- package/benchmarks/committee/README.md +39 -0
- package/benchmarks/committee/metrics.py +51 -0
- package/benchmarks/committee/run.py +64 -0
- package/benchmarks/committee/traces/01-single.json +8 -0
- package/benchmarks/committee/traces/02-mesh.json +16 -0
- package/benchmarks/committee/traces/03-chair.json +15 -0
- package/benchmarks/latency/README.md +35 -0
- package/benchmarks/latency/fake-claude-cli.mjs +119 -0
- package/benchmarks/latency/fake-native-server.mjs +340 -0
- package/benchmarks/latency/fake-tui.py +91 -0
- package/benchmarks/latency/live.py +380 -0
- package/benchmarks/stress/README.md +20 -0
- package/benchmarks/stress/run.py +487 -0
- package/bin/agents +4 -0
- package/bin/agents-city.js +116 -0
- package/bin/benchmark +17 -0
- package/bin/bus +6 -0
- package/bin/cities +3 -0
- package/bin/city +86 -0
- package/bin/committee +6 -0
- package/bin/demo +200 -0
- package/bin/exit +5 -0
- package/bin/hall +73 -0
- package/bin/hall.html +426 -0
- package/bin/logs +3 -0
- package/bin/report.py +14 -0
- package/bin/reset +3 -0
- package/bin/road +3 -0
- package/bin/seat +10 -0
- package/bin/serve.py +1428 -0
- package/bin/setup.py +753 -0
- package/bin/skills +3 -0
- package/bin/test +131 -0
- package/bin/test-adapter.py +183 -0
- package/bin/test-admision.py +99 -0
- package/bin/test-avatar.py +58 -0
- package/bin/test-benchmark.py +40 -0
- package/bin/test-broker.py +334 -0
- package/bin/test-cage.py +206 -0
- package/bin/test-card.py +402 -0
- package/bin/test-channel.py +379 -0
- package/bin/test-cities.py +453 -0
- package/bin/test-claude-runtime.py +252 -0
- package/bin/test-committee.py +340 -0
- package/bin/test-contracts.py +544 -0
- package/bin/test-crecimiento.py +164 -0
- package/bin/test-demo.py +308 -0
- package/bin/test-doctor.py +106 -0
- package/bin/test-domains.py +151 -0
- package/bin/test-evidencia.py +50 -0
- package/bin/test-exit.py +128 -0
- package/bin/test-hall-protocol.py +93 -0
- package/bin/test-launch.py +169 -0
- package/bin/test-live-feed.py +508 -0
- package/bin/test-pairing.py +99 -0
- package/bin/test-parcels.py +283 -0
- package/bin/test-runtime-failures.py +252 -0
- package/bin/test-runtime-ui.py +488 -0
- package/bin/test-runtime.py +268 -0
- package/bin/test-rutas.py +80 -0
- package/bin/test-seat.py +1386 -0
- package/bin/test-security.py +150 -0
- package/bin/test-serve.py +1057 -0
- package/bin/test-stress.py +64 -0
- package/bin/test-widgets.py +143 -0
- package/bin/test-workspace.py +188 -0
- package/bin/testlib.py +95 -0
- package/bin/tokens.py +14 -0
- package/bus/scripts/deploy.sh +60 -0
- package/bus/scripts/mint-token.sh +68 -0
- package/bus/scripts/setup-dev.sh +79 -0
- package/bus/scripts/test-channel.ts +8 -0
- package/bus/scripts/test-hub.ts +266 -0
- package/bus/scripts/test-local.sh +40 -0
- package/bus/scripts/test-queue.ts +132 -0
- package/bus/worker/package-lock.json +1971 -0
- package/bus/worker/package.json +17 -0
- package/bus/worker/src/index.ts +485 -0
- package/bus/worker/tsconfig.json +13 -0
- package/bus/worker/wrangler.toml +32 -0
- package/city/oven/README.md +59 -0
- package/city/oven/collect.py +43 -0
- package/city/oven/oven.html +4 -0
- package/city/scripts/history.py +120 -0
- package/city/scripts/seed.py +442 -0
- package/city/web/assets/modelos/License.txt +28 -0
- package/city/web/assets/modelos/PROCEDENCIA.txt +3 -0
- package/city/web/assets/modelos/building-a.glb +0 -0
- package/city/web/assets/modelos/building-b.glb +0 -0
- package/city/web/assets/modelos/building-c.glb +0 -0
- package/city/web/assets/modelos/building-d.glb +0 -0
- package/city/web/assets/modelos/building-e.glb +0 -0
- package/city/web/assets/modelos/building-f.glb +0 -0
- package/city/web/assets/modelos/building-g.glb +0 -0
- package/city/web/assets/modelos/building-h.glb +0 -0
- package/city/web/assets/sprites/building-a.png +0 -0
- package/city/web/assets/sprites/building-b.png +0 -0
- package/city/web/assets/sprites/building-c.png +0 -0
- package/city/web/assets/sprites/building-d.png +0 -0
- package/city/web/assets/sprites/building-e.png +0 -0
- package/city/web/assets/sprites/building-f.png +0 -0
- package/city/web/assets/sprites/building-g.png +0 -0
- package/city/web/assets/sprites/building-h.png +0 -0
- package/city/web/assets/sprites/building-i.png +0 -0
- package/city/web/assets/sprites/building-j.png +0 -0
- package/city/web/assets/sprites/building-k.png +0 -0
- package/city/web/assets/sprites/building-l.png +0 -0
- package/city/web/assets/sprites/building-m.png +0 -0
- package/city/web/assets/sprites/building-n.png +0 -0
- package/city/web/assets/sprites/building-skyscraper-a.png +0 -0
- package/city/web/assets/sprites/building-skyscraper-b.png +0 -0
- package/city/web/assets/sprites/building-skyscraper-c.png +0 -0
- package/city/web/assets/sprites/building-skyscraper-d.png +0 -0
- package/city/web/assets/sprites/building-skyscraper-e.png +0 -0
- package/city/web/assets/sprites/catalogo.json +47 -0
- package/city/web/assets/sprites/detail-awning-wide.png +0 -0
- package/city/web/assets/sprites/detail-awning.png +0 -0
- package/city/web/assets/sprites/detail-overhang-wide.png +0 -0
- package/city/web/assets/sprites/detail-overhang.png +0 -0
- package/city/web/assets/sprites/detail-parasol-a.png +0 -0
- package/city/web/assets/sprites/detail-parasol-b.png +0 -0
- package/city/web/assets/sprites/low-detail-building-a.png +0 -0
- package/city/web/assets/sprites/low-detail-building-b.png +0 -0
- package/city/web/assets/sprites/low-detail-building-c.png +0 -0
- package/city/web/assets/sprites/low-detail-building-d.png +0 -0
- package/city/web/assets/sprites/low-detail-building-e.png +0 -0
- package/city/web/assets/sprites/low-detail-building-f.png +0 -0
- package/city/web/assets/sprites/low-detail-building-g.png +0 -0
- package/city/web/assets/sprites/low-detail-building-h.png +0 -0
- package/city/web/assets/sprites/low-detail-building-i.png +0 -0
- package/city/web/assets/sprites/low-detail-building-j.png +0 -0
- package/city/web/assets/sprites/low-detail-building-k.png +0 -0
- package/city/web/assets/sprites/low-detail-building-l.png +0 -0
- package/city/web/assets/sprites/low-detail-building-m.png +0 -0
- package/city/web/assets/sprites/low-detail-building-n.png +0 -0
- package/city/web/assets/sprites/low-detail-building-wide-a.png +0 -0
- package/city/web/assets/sprites/low-detail-building-wide-b.png +0 -0
- package/city/web/assets/sprites/medidas.json +248 -0
- package/city/web/dist/city.js +939 -0
- package/city/web/dist/index.html +294 -0
- package/city/web/dist/modelos/License.txt +28 -0
- package/city/web/dist/modelos/PROCEDENCIA.txt +3 -0
- package/city/web/dist/modelos/building-a.glb +0 -0
- package/city/web/dist/modelos/building-b.glb +0 -0
- package/city/web/dist/modelos/building-c.glb +0 -0
- package/city/web/dist/modelos/building-d.glb +0 -0
- package/city/web/dist/modelos/building-e.glb +0 -0
- package/city/web/dist/modelos/building-f.glb +0 -0
- package/city/web/dist/modelos/building-g.glb +0 -0
- package/city/web/dist/modelos/building-h.glb +0 -0
- package/city/web/dist/sprites/building-a.png +0 -0
- package/city/web/dist/sprites/building-b.png +0 -0
- package/city/web/dist/sprites/building-c.png +0 -0
- package/city/web/dist/sprites/building-d.png +0 -0
- package/city/web/dist/sprites/building-e.png +0 -0
- package/city/web/dist/sprites/building-f.png +0 -0
- package/city/web/dist/sprites/building-g.png +0 -0
- package/city/web/dist/sprites/building-h.png +0 -0
- package/city/web/dist/sprites/building-i.png +0 -0
- package/city/web/dist/sprites/building-j.png +0 -0
- package/city/web/dist/sprites/building-k.png +0 -0
- package/city/web/dist/sprites/building-l.png +0 -0
- package/city/web/dist/sprites/building-m.png +0 -0
- package/city/web/dist/sprites/building-n.png +0 -0
- package/city/web/dist/sprites/building-skyscraper-a.png +0 -0
- package/city/web/dist/sprites/building-skyscraper-b.png +0 -0
- package/city/web/dist/sprites/building-skyscraper-c.png +0 -0
- package/city/web/dist/sprites/building-skyscraper-d.png +0 -0
- package/city/web/dist/sprites/building-skyscraper-e.png +0 -0
- package/city/web/dist/sprites/catalogo.json +47 -0
- package/city/web/dist/sprites/detail-awning-wide.png +0 -0
- package/city/web/dist/sprites/detail-awning.png +0 -0
- package/city/web/dist/sprites/detail-overhang-wide.png +0 -0
- package/city/web/dist/sprites/detail-overhang.png +0 -0
- package/city/web/dist/sprites/detail-parasol-a.png +0 -0
- package/city/web/dist/sprites/detail-parasol-b.png +0 -0
- package/city/web/dist/sprites/low-detail-building-a.png +0 -0
- package/city/web/dist/sprites/low-detail-building-b.png +0 -0
- package/city/web/dist/sprites/low-detail-building-c.png +0 -0
- package/city/web/dist/sprites/low-detail-building-d.png +0 -0
- package/city/web/dist/sprites/low-detail-building-e.png +0 -0
- package/city/web/dist/sprites/low-detail-building-f.png +0 -0
- package/city/web/dist/sprites/low-detail-building-g.png +0 -0
- package/city/web/dist/sprites/low-detail-building-h.png +0 -0
- package/city/web/dist/sprites/low-detail-building-i.png +0 -0
- package/city/web/dist/sprites/low-detail-building-j.png +0 -0
- package/city/web/dist/sprites/low-detail-building-k.png +0 -0
- package/city/web/dist/sprites/low-detail-building-l.png +0 -0
- package/city/web/dist/sprites/low-detail-building-m.png +0 -0
- package/city/web/dist/sprites/low-detail-building-n.png +0 -0
- package/city/web/dist/sprites/low-detail-building-wide-a.png +0 -0
- package/city/web/dist/sprites/low-detail-building-wide-b.png +0 -0
- package/city/web/dist/sprites/medidas.json +248 -0
- package/city/web/dist-hall/hall.js +264 -0
- package/city/web/index.html +294 -0
- package/city/web/package.json +18 -0
- package/city/web/sella.py +21 -0
- package/city/web/src/activity-actors.ts +47 -0
- package/city/web/src/activity.ts +172 -0
- package/city/web/src/ayuntamiento.ts +580 -0
- package/city/web/src/draw.ts +640 -0
- package/city/web/src/game-speech.ts +79 -0
- package/city/web/src/hall.ts +1867 -0
- package/city/web/src/main.ts +2225 -0
- package/city/web/src/oven.ts +124 -0
- package/city/web/src/people.ts +244 -0
- package/city/web/src/presencia.ts +193 -0
- package/city/web/src/puertas.ts +180 -0
- package/city/web/src/repo-roles.ts +59 -0
- package/city/worker/package.json +16 -0
- package/city/worker/schema.sql +117 -0
- package/city/worker/src/index.ts +702 -0
- package/city/worker/src/square.ts +218 -0
- package/city/worker/wrangler.toml +58 -0
- package/demo/ada.md +50 -0
- package/demo/bruno.md +62 -0
- package/demo/camila.md +56 -0
- package/demo/city/ada.md +40 -0
- package/demo/city/city.yml +8 -0
- package/demo/city/parcels.yml +15 -0
- package/demo/city/roads.json +4 -0
- package/demo/city/units.yml +10 -0
- package/demo/clinica/city.yml +8 -0
- package/demo/clinica/parcels.yml +15 -0
- package/demo/clinica/roads.json +4 -0
- package/demo/clinica/units.yml +9 -0
- package/demo/clinica/vera.md +46 -0
- package/demo/dante.md +54 -0
- package/demo/despacho/city.yml +8 -0
- package/demo/despacho/marta.md +46 -0
- package/demo/despacho/parcels.yml +15 -0
- package/demo/despacho/roads.json +4 -0
- package/demo/despacho/units.yml +9 -0
- package/demo/elsa.md +50 -0
- package/demo/farid.md +52 -0
- package/demo/greta.md +50 -0
- package/demo/hugo.md +49 -0
- package/demo/iris.md +49 -0
- package/demo/jonas.md +51 -0
- package/demo/kira.md +51 -0
- package/demo/luca.md +49 -0
- package/demo/parcels.yml +92 -0
- package/demo/seed.py +20 -0
- package/demo/show.py +161 -0
- package/demo/stories.py +824 -0
- package/demo/units.yml +33 -0
- package/docs/agents-first.md +99 -0
- package/docs/glossary.md +55 -0
- package/docs/map-live-layers.md +73 -0
- package/docs/security.md +231 -0
- package/docs/self-host.md +151 -0
- package/docs/testing.md +205 -0
- package/package.json +73 -0
- package/plugin/.claude-plugin/plugin.json +62 -0
- package/plugin/.mcp.json +15 -0
- package/plugin/channel/activity-cli.ts +16 -0
- package/plugin/channel/adapter-prompts.ts +113 -0
- package/plugin/channel/adapter.js +4292 -0
- package/plugin/channel/adapter.ts +133 -0
- package/plugin/channel/bus.js +20188 -0
- package/plugin/channel/bus.ts +131 -0
- package/plugin/channel/city-config.ts +125 -0
- package/plugin/channel/claude-channel.ts +58 -0
- package/plugin/channel/cli-args.ts +75 -0
- package/plugin/channel/client.js +4200 -0
- package/plugin/channel/client.ts +38 -0
- package/plugin/channel/committee/activity.ts +256 -0
- package/plugin/channel/committee/collection.ts +154 -0
- package/plugin/channel/committee/decision.ts +175 -0
- package/plugin/channel/committee/floor.ts +135 -0
- package/plugin/channel/committee/guards.ts +30 -0
- package/plugin/channel/committee/history.ts +38 -0
- package/plugin/channel/committee/render.ts +102 -0
- package/plugin/channel/committee/service.ts +72 -0
- package/plugin/channel/committee/storage.ts +92 -0
- package/plugin/channel/committee/types.ts +163 -0
- package/plugin/channel/committee/view.ts +63 -0
- package/plugin/channel/committee-cli.ts +109 -0
- package/plugin/channel/delivery-metrics.ts +61 -0
- package/plugin/channel/delivery-queue.ts +118 -0
- package/plugin/channel/hub/activity-controller.ts +72 -0
- package/plugin/channel/hub/activity-feed.ts +169 -0
- package/plugin/channel/hub/committee-controller.ts +60 -0
- package/plugin/channel/hub/connections.ts +48 -0
- package/plugin/channel/hub/diagnostics.ts +67 -0
- package/plugin/channel/hub/envelope-validity.ts +109 -0
- package/plugin/channel/hub/envelopes.ts +66 -0
- package/plugin/channel/hub/lifecycle.ts +91 -0
- package/plugin/channel/hub/local-roads.ts +89 -0
- package/plugin/channel/hub/remote-roads.ts +165 -0
- package/plugin/channel/hub/road-controller.ts +99 -0
- package/plugin/channel/hub-client.ts +149 -0
- package/plugin/channel/local-hub.js +5995 -0
- package/plugin/channel/local-hub.ts +349 -0
- package/plugin/channel/map-reporter.ts +48 -0
- package/plugin/channel/package-lock.json +1731 -0
- package/plugin/channel/package.json +19 -0
- package/plugin/channel/protocol.ts +98 -0
- package/plugin/channel/road-cli.ts +18 -0
- package/plugin/channel/run.sh +15 -0
- package/plugin/channel/runtime/claude.ts +458 -0
- package/plugin/channel/runtime/codex-config.ts +77 -0
- package/plugin/channel/runtime/codex.ts +669 -0
- package/plugin/channel/runtime/command.ts +62 -0
- package/plugin/channel/runtime/factory.ts +15 -0
- package/plugin/channel/runtime/json-rpc.ts +154 -0
- package/plugin/channel/runtime/kimi.ts +277 -0
- package/plugin/channel/runtime/opencode.ts +248 -0
- package/plugin/channel/runtime/process.ts +84 -0
- package/plugin/channel/runtime/types.ts +45 -0
- package/plugin/channel/runtime-files.ts +73 -0
- package/plugin/channel/runtime-gateway.js +6396 -0
- package/plugin/channel/runtime-gateway.ts +358 -0
- package/plugin/channel/runtime-metrics.ts +52 -0
- package/plugin/channel/runtime-subscription.ts +180 -0
- package/plugin/channel/terminal-delivery.ts +131 -0
- package/plugin/channel/untrusted.ts +60 -0
- package/plugin/commands/committee.md +31 -0
- package/plugin/commands/exit.md +32 -0
- package/plugin/commands/goals.md +20 -0
- package/plugin/commands/join.md +18 -0
- package/plugin/commands/notice.md +20 -0
- package/plugin/commands/propose.md +19 -0
- package/plugin/commands/round.md +17 -0
- package/plugin/commands/session.md +22 -0
- package/plugin/commands/settings.md +25 -0
- package/plugin/commands/setup.md +21 -0
- package/plugin/commands/team.md +23 -0
- package/plugin/domains/custom.md +27 -0
- package/plugin/domains/finance.md +31 -0
- package/plugin/domains/healthcare.md +34 -0
- package/plugin/domains/legal.md +31 -0
- package/plugin/domains/marketing.md +33 -0
- package/plugin/domains/operations.md +31 -0
- package/plugin/domains/research.md +32 -0
- package/plugin/domains/sales.md +30 -0
- package/plugin/domains/software.md +37 -0
- package/plugin/hooks/activity.sh +6 -0
- package/plugin/hooks/digging.sh +68 -0
- package/plugin/hooks/growth.sh +42 -0
- package/plugin/hooks/hooks.json +106 -0
- package/plugin/hooks/notice-on-pr.sh +35 -0
- package/plugin/hooks/notice-on-stop.sh +80 -0
- package/plugin/hooks/notice-pending.sh +58 -0
- package/plugin/hooks/solo-en-ciudad.sh +23 -0
- package/plugin/hooks/tokens.sh +47 -0
- package/plugin/roles/examples/account-executive.md +25 -0
- package/plugin/roles/examples/ai-manager.md +34 -0
- package/plugin/roles/examples/associate.md +24 -0
- package/plugin/roles/examples/brand-lead.md +20 -0
- package/plugin/roles/examples/cfo.md +20 -0
- package/plugin/roles/examples/city-lead.md +25 -0
- package/plugin/roles/examples/clinical-director.md +26 -0
- package/plugin/roles/examples/clinical-ops.md +25 -0
- package/plugin/roles/examples/clinician.md +25 -0
- package/plugin/roles/examples/compliance.md +27 -0
- package/plugin/roles/examples/content.md +24 -0
- package/plugin/roles/examples/controller.md +27 -0
- package/plugin/roles/examples/cpto.md +41 -0
- package/plugin/roles/examples/customer-success.md +25 -0
- package/plugin/roles/examples/data-engineer.md +37 -0
- package/plugin/roles/examples/data.md +60 -0
- package/plugin/roles/examples/dev.md +37 -0
- package/plugin/roles/examples/devops.md +39 -0
- package/plugin/roles/examples/enablement.md +24 -0
- package/plugin/roles/examples/ethics.md +24 -0
- package/plugin/roles/examples/fin-analytics.md +27 -0
- package/plugin/roles/examples/health-compliance.md +25 -0
- package/plugin/roles/examples/health-data.md +26 -0
- package/plugin/roles/examples/knowledge.md +25 -0
- package/plugin/roles/examples/lifecycle.md +26 -0
- package/plugin/roles/examples/llm-engineer.md +34 -0
- package/plugin/roles/examples/managing-partner.md +20 -0
- package/plugin/roles/examples/methods.md +23 -0
- package/plugin/roles/examples/operations-lead.md +23 -0
- package/plugin/roles/examples/ops.md +24 -0
- package/plugin/roles/examples/patient-safety.md +26 -0
- package/plugin/roles/examples/performance.md +27 -0
- package/plugin/roles/examples/po.md +36 -0
- package/plugin/roles/examples/process-owner.md +23 -0
- package/plugin/roles/examples/product-design.md +39 -0
- package/plugin/roles/examples/program-manager.md +24 -0
- package/plugin/roles/examples/quality.md +25 -0
- package/plugin/roles/examples/research-director.md +24 -0
- package/plugin/roles/examples/research-ops.md +25 -0
- package/plugin/roles/examples/researcher.md +25 -0
- package/plugin/roles/examples/revenue-lead.md +25 -0
- package/plugin/roles/examples/revops.md +26 -0
- package/plugin/roles/examples/seo.md +27 -0
- package/plugin/roles/examples/specialist.md +24 -0
- package/plugin/scripts/admision.py +165 -0
- package/plugin/scripts/apaga.py +232 -0
- package/plugin/scripts/avatar.py +211 -0
- package/plugin/scripts/broker.py +531 -0
- package/plugin/scripts/cage.py +252 -0
- package/plugin/scripts/capabilities.py +197 -0
- package/plugin/scripts/card.py +312 -0
- package/plugin/scripts/cities.py +520 -0
- package/plugin/scripts/city-env.sh +90 -0
- package/plugin/scripts/city-runtime.sh +87 -0
- package/plugin/scripts/city-session.sh +526 -0
- package/plugin/scripts/city_env.py +67 -0
- package/plugin/scripts/crecimiento.py +140 -0
- package/plugin/scripts/deliberations.py +48 -0
- package/plugin/scripts/doctor.py +172 -0
- package/plugin/scripts/domains.py +201 -0
- package/plugin/scripts/evidencia.py +64 -0
- package/plugin/scripts/find-repos.sh +111 -0
- package/plugin/scripts/gh.py +159 -0
- package/plugin/scripts/hall_protocol.py +147 -0
- package/plugin/scripts/hook_activity.py +139 -0
- package/plugin/scripts/launch.py +94 -0
- package/plugin/scripts/logs.py +98 -0
- package/plugin/scripts/pairing.py +185 -0
- package/plugin/scripts/parcels.py +132 -0
- package/plugin/scripts/read-card.py +51 -0
- package/plugin/scripts/report.py +201 -0
- package/plugin/scripts/reset.py +180 -0
- package/plugin/scripts/roads.py +230 -0
- package/plugin/scripts/roles.py +274 -0
- package/plugin/scripts/runtime_log.py +69 -0
- package/plugin/scripts/runtime_processes.py +132 -0
- package/plugin/scripts/rutas.py +83 -0
- package/plugin/scripts/seat.py +1349 -0
- package/plugin/scripts/tokens.py +194 -0
- package/plugin/scripts/trust-repos.py +59 -0
- package/plugin/scripts/ui.py +280 -0
- package/plugin/scripts/units.py +80 -0
- package/plugin/scripts/workspace.py +364 -0
- package/plugin/skills/city/SKILL.md +191 -0
- package/templates/blank.md +39 -0
- package/templates/finance.md +44 -0
- package/templates/legal.md +45 -0
- package/templates/marketing.md +48 -0
- 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"))
|
package/bin/test-cage.py
ADDED
|
@@ -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"))
|