agents-city 0.1.0
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 +2226 -0
- package/README.md +2196 -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 +119 -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/doctor +3 -0
- package/bin/exit +5 -0
- package/bin/hall +73 -0
- package/bin/hall.html +498 -0
- package/bin/logs +3 -0
- package/bin/navegador.mjs +243 -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 +1579 -0
- package/bin/setup.py +753 -0
- package/bin/shortcut +3 -0
- package/bin/skills +3 -0
- package/bin/test +133 -0
- package/bin/test-actualiza.py +130 -0
- package/bin/test-adapter.py +183 -0
- package/bin/test-admision.py +99 -0
- package/bin/test-atajos.py +301 -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 +374 -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 +551 -0
- package/bin/test-crecimiento.py +164 -0
- package/bin/test-demo.py +308 -0
- package/bin/test-doctor.py +139 -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-navegador.py +140 -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 +1598 -0
- package/bin/test-security.py +150 -0
- package/bin/test-serve.py +1251 -0
- package/bin/test-stress.py +64 -0
- package/bin/test-widgets.py +143 -0
- package/bin/test-workspace.py +251 -0
- package/bin/testlib.py +118 -0
- package/bin/tokens.py +14 -0
- package/bin/update +3 -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 +1363 -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 +1959 -0
- package/city/web/src/main.ts +2252 -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 +106 -0
- package/docs/glossary.md +55 -0
- package/docs/map-live-layers.md +73 -0
- package/docs/security.md +265 -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/actualiza.py +198 -0
- package/plugin/scripts/admision.py +165 -0
- package/plugin/scripts/apaga.py +232 -0
- package/plugin/scripts/atajos.py +506 -0
- package/plugin/scripts/avatar.py +211 -0
- package/plugin/scripts/broker.py +531 -0
- package/plugin/scripts/cage.py +492 -0
- package/plugin/scripts/capabilities.py +210 -0
- package/plugin/scripts/card.py +322 -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 +552 -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 +294 -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 +55 -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 +1696 -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 +561 -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,492 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The cage: one kernel-enforced confinement per agent window.
|
|
3
|
+
|
|
4
|
+
macOS gets a generated seatbelt profile; Linux gets a bubblewrap mount
|
|
5
|
+
namespace. Two mechanisms, one meaning — and the meaning is what this module
|
|
6
|
+
owns, so the launcher asks for a prefix and never learns which kernel it is on.
|
|
7
|
+
|
|
8
|
+
The yolo flag stays — a committee cannot work if every bus command needs a
|
|
9
|
+
human. What changes is what the kernel lets the window touch. An agent inside
|
|
10
|
+
the cage is never asked anything: writes land only in its own repo and the
|
|
11
|
+
runtime state it legitimately owns, and the files that turn a prompt injection
|
|
12
|
+
into a credential theft (`~/.ssh`, `~/.git-credentials`, cloud configs, the
|
|
13
|
+
`gh` token) simply do not exist for it.
|
|
14
|
+
|
|
15
|
+
Seatbelt semantics, verified on a real machine before this was written:
|
|
16
|
+
the LAST matching rule wins, children and grandchildren inherit the profile,
|
|
17
|
+
and the network stays open unless denied. So the profile reads top to bottom
|
|
18
|
+
as: allow everything, forbid all writes, re-allow the working set, and finally
|
|
19
|
+
seal the secrets — reads and writes both.
|
|
20
|
+
|
|
21
|
+
Honest limits, documented rather than hidden: credentials held in the macOS
|
|
22
|
+
keychain travel over IPC, not file reads, so a jailed `git push` still works
|
|
23
|
+
when the owner keeps GitHub credentials there; outbound network stays open;
|
|
24
|
+
and every window still runs as the same OS user. The cage narrows what a
|
|
25
|
+
compromised window can steal — it is not hostile-process isolation.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import argparse
|
|
29
|
+
import functools
|
|
30
|
+
import os
|
|
31
|
+
import re
|
|
32
|
+
import shlex
|
|
33
|
+
import shutil
|
|
34
|
+
import subprocess
|
|
35
|
+
import sys
|
|
36
|
+
|
|
37
|
+
import rutas
|
|
38
|
+
|
|
39
|
+
# Read+write denied inside the cage: directories that exist to hold credentials.
|
|
40
|
+
SECRETOS_DIR = (
|
|
41
|
+
'.ssh', '.aws', '.kube', '.gnupg',
|
|
42
|
+
os.path.join('.config', 'gcloud'),
|
|
43
|
+
os.path.join('.config', 'gh'),
|
|
44
|
+
os.path.join('.docker'),
|
|
45
|
+
)
|
|
46
|
+
# Single files with the same job. `~/.npmrc` is deliberately NOT here: denying
|
|
47
|
+
# it breaks every `npm install` for owners who configure a registry, and a
|
|
48
|
+
# broken product protects nobody. Owners who keep tokens there add it through
|
|
49
|
+
# CITY_CAGE_DENY.
|
|
50
|
+
SECRETOS_FICHERO = (
|
|
51
|
+
'.git-credentials', '.netrc', '.pgpass',
|
|
52
|
+
# The one this product created itself. Outside macOS there is no Keychain
|
|
53
|
+
# and Claude Code writes its OAuth access and refresh tokens here as plain
|
|
54
|
+
# JSON — inside a directory the cage keeps WRITABLE for runtime state. Every
|
|
55
|
+
# third-party credential store was sealed and ours was not, which made the
|
|
56
|
+
# cage's promise false exactly where it mattered most.
|
|
57
|
+
os.path.join('.claude', '.credentials.json'),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Writable beyond the repo: the runtime state and build caches a working agent
|
|
61
|
+
# legitimately owns. Everything here is cache or agent state, never a secret —
|
|
62
|
+
# with the exceptions sealed again further down.
|
|
63
|
+
ESCRITURA_CASA = (
|
|
64
|
+
'.claude', '.agents-city', '.codex', '.opencode', '.kimi',
|
|
65
|
+
'.npm', '.cache', '.cargo', '.rustup', '.gradle', '.m2',
|
|
66
|
+
'.pnpm-store', '.bun',
|
|
67
|
+
)
|
|
68
|
+
#: Where each kernel keeps the rest of that state. Splitting the system paths
|
|
69
|
+
#: and leaving these shared was an oversight with teeth: `Library/pnpm` is
|
|
70
|
+
#: pnpm's macOS home and its Linux twin `~/.local/share/pnpm` was missing, so a
|
|
71
|
+
#: caged `pnpm add -g`, `pip install --user`, `pipx` or `go install` all failed
|
|
72
|
+
#: with a read-only filesystem for reasons nobody would connect to the cage.
|
|
73
|
+
ESCRITURA_CASA_MAC = (
|
|
74
|
+
os.path.join('Library', 'Caches'),
|
|
75
|
+
os.path.join('Library', 'Logs'),
|
|
76
|
+
os.path.join('Library', 'pnpm'),
|
|
77
|
+
os.path.join('Library', 'Developer'),
|
|
78
|
+
)
|
|
79
|
+
ESCRITURA_CASA_LINUX = (
|
|
80
|
+
os.path.join('.local', 'share'),
|
|
81
|
+
os.path.join('.local', 'state'),
|
|
82
|
+
os.path.join('.local', 'bin'),
|
|
83
|
+
'.config',
|
|
84
|
+
'go',
|
|
85
|
+
)
|
|
86
|
+
#: Temporary and device paths a working agent legitimately writes, per kernel.
|
|
87
|
+
#: Keyed by platform because "where /tmp really is" is the one fact the two
|
|
88
|
+
#: cages genuinely disagree on — everything else below is shared.
|
|
89
|
+
ESCRITURA_SISTEMA = ('/dev', '/private/tmp', '/private/var/tmp', '/private/var/folders')
|
|
90
|
+
#: `/dev` is deliberately absent: the argv already gives the sandbox its own
|
|
91
|
+
#: device tree, and re-binding the host's over it would undo that.
|
|
92
|
+
ESCRITURA_SISTEMA_LINUX = ('/tmp', '/var/tmp', '/run/user')
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def escritura_sistema():
|
|
96
|
+
"""The temp and device paths of the kernel we are RUNNING on.
|
|
97
|
+
|
|
98
|
+
Only for callers that have no cage of their own to name. Each renderer
|
|
99
|
+
passes its own tuple instead: the bubblewrap argv is the Linux cage
|
|
100
|
+
wherever it is built, so a macOS machine writing one — a test, a review —
|
|
101
|
+
must still produce Linux paths, or the thing under test is not the thing
|
|
102
|
+
that ships.
|
|
103
|
+
"""
|
|
104
|
+
return ESCRITURA_SISTEMA_LINUX if sys.platform.startswith('linux') else ESCRITURA_SISTEMA
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _sbpl(ruta):
|
|
108
|
+
"""Quote one path for SBPL: backslashes and double quotes escaped."""
|
|
109
|
+
return '"' + ruta.replace('\\', '\\\\').replace('"', '\\"') + '"'
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def agents_home(casa):
|
|
113
|
+
"""The Agents City state root, honouring AGENTS_CITY_HOME like the broker.
|
|
114
|
+
|
|
115
|
+
The broker writes its token store under this root (`broker.estado_de`), so
|
|
116
|
+
the cage must seal the *same* root it uses — not a hardcoded `~/.agents-city`
|
|
117
|
+
that silently misses the broker dir when the owner relocated the home.
|
|
118
|
+
"""
|
|
119
|
+
hogar = os.environ.get('AGENTS_CITY_HOME') or os.path.join(casa, '.agents-city')
|
|
120
|
+
return rutas.canonicaliza(hogar)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def sellados_por_tipo(casa):
|
|
124
|
+
"""The sealed roots, each with what it IS: a file or a directory.
|
|
125
|
+
|
|
126
|
+
Decided by DECLARATION first and by the disk second. It matters because the
|
|
127
|
+
two cages render the two kinds differently — a directory becomes an empty
|
|
128
|
+
tmpfs, a file becomes /dev/null — and a credential file that does not exist
|
|
129
|
+
yet must still be sealed as a file, or the cage mounts a directory in its
|
|
130
|
+
place and the runtime can never create it.
|
|
131
|
+
"""
|
|
132
|
+
ac = agents_home(casa)
|
|
133
|
+
fuera = {}
|
|
134
|
+
for d in SECRETOS_DIR:
|
|
135
|
+
fuera[rutas.canonicaliza(os.path.join(casa, d))] = 'dir'
|
|
136
|
+
for f in SECRETOS_FICHERO:
|
|
137
|
+
fuera[rutas.canonicaliza(os.path.join(casa, f))] = 'file'
|
|
138
|
+
fuera[rutas.canonicaliza(os.path.join(ac, '.runtime', 'broker'))] = 'dir'
|
|
139
|
+
for f in ('credentials', 'credentials.toml'):
|
|
140
|
+
fuera[rutas.canonicaliza(os.path.join(casa, '.cargo', f))] = 'file'
|
|
141
|
+
# An owner's own denies: nothing declares their kind, so ask the disk and
|
|
142
|
+
# treat anything else as a directory, which seals more rather than less.
|
|
143
|
+
for p in os.environ.get('CITY_CAGE_DENY', '').split(':'):
|
|
144
|
+
if not p:
|
|
145
|
+
continue
|
|
146
|
+
real = rutas.canonicaliza(os.path.expanduser(p))
|
|
147
|
+
fuera[real] = 'file' if os.path.isfile(real) else 'dir'
|
|
148
|
+
return fuera
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def sellados(casa):
|
|
152
|
+
"""The canonical set of read+write-sealed roots.
|
|
153
|
+
|
|
154
|
+
The single source of truth for "must never be reachable": the deny lines
|
|
155
|
+
emit it, and the write-allow filter refuses any owner path that re-enters
|
|
156
|
+
it. Extra owner denies (CITY_CAGE_DENY) join it, so an owner can widen the
|
|
157
|
+
seal but the allow-list can never outrun it.
|
|
158
|
+
"""
|
|
159
|
+
return list(sellados_por_tipo(casa))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _permitidas_escritura(repo, casa, bloqueados, extra_escritura=(), sistema=None):
|
|
163
|
+
"""Canonical write-allow roots, given the already-computed sealed set.
|
|
164
|
+
|
|
165
|
+
Covering roots are intentionally kept: the sealed-secret block is emitted
|
|
166
|
+
last and, under seatbelt's last-match-wins rule, re-denies any secret that
|
|
167
|
+
a broad writable ancestor would otherwise expose. So the ordering — not a
|
|
168
|
+
filter here — is the guarantee, and `bin/test-cage.py` proves it against the
|
|
169
|
+
live kernel. What this pass does add is a loud drop of an owner extra that
|
|
170
|
+
resolves strictly *inside* a sealed root: that write is already dead under
|
|
171
|
+
the final deny, so honouring it silently would only mislead. The relocated
|
|
172
|
+
state home is added explicitly so a custom AGENTS_CITY_HOME stays writable.
|
|
173
|
+
|
|
174
|
+
`extra_escritura` is where the agent-first model plugs in: the resolved
|
|
175
|
+
targets of the agent's workspace mounts (symlinks already followed to their
|
|
176
|
+
real path). They pass through the same sealed-root check as any owner extra.
|
|
177
|
+
"""
|
|
178
|
+
extra = [os.path.expanduser(p)
|
|
179
|
+
for p in os.environ.get('CITY_CAGE_ALLOW_WRITE', '').split(':') if p]
|
|
180
|
+
extra += list(extra_escritura)
|
|
181
|
+
linux = sistema is ESCRITURA_SISTEMA_LINUX or (
|
|
182
|
+
sistema is None and sys.platform.startswith('linux'))
|
|
183
|
+
fijas = [repo, agents_home(casa), *(sistema if sistema is not None else escritura_sistema())]
|
|
184
|
+
fijas += [os.path.join(casa, p) for p in ESCRITURA_CASA]
|
|
185
|
+
fijas += [os.path.join(casa, p)
|
|
186
|
+
for p in (ESCRITURA_CASA_LINUX if linux else ESCRITURA_CASA_MAC)]
|
|
187
|
+
aceptadas = [rutas.canonicaliza(c) for c in fijas]
|
|
188
|
+
for c in extra:
|
|
189
|
+
if any(rutas.dentro_de(c, raiz) for raiz in bloqueados):
|
|
190
|
+
sys.stderr.write(f'cage: ignoring write-allow {c}: inside a sealed root\n')
|
|
191
|
+
continue
|
|
192
|
+
aceptadas.append(rutas.canonicaliza(c))
|
|
193
|
+
return aceptadas
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _lineas_permite_escritura(repo, casa, bloqueados, extra_escritura=()):
|
|
197
|
+
permitidas = _permitidas_escritura(repo, casa, bloqueados, extra_escritura,
|
|
198
|
+
sistema=ESCRITURA_SISTEMA)
|
|
199
|
+
lineas = [f' (subpath {_sbpl(r)})' for r in permitidas]
|
|
200
|
+
# Claude keeps `~/.claude.json` (and its backups) at the HOME root.
|
|
201
|
+
lineas.append(f' (regex #"^{re.escape(os.path.join(casa, ".claude.json"))}")')
|
|
202
|
+
return lineas
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def carreteras_raiz(casa):
|
|
206
|
+
"""Where a seat's remote-road channels live. One spelling, two readers."""
|
|
207
|
+
return os.path.join(casa, '.claude', 'channels')
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def env_de_carreteras(casa):
|
|
211
|
+
"""The remote-road token files: `~/.claude/channels/<seat>/.env`.
|
|
212
|
+
|
|
213
|
+
One definition of "these are secrets too", because the two cages render it
|
|
214
|
+
differently — macOS as a regex over a path that may not exist yet, Linux as
|
|
215
|
+
one mount per file that does. A rule written twice is a rule that will be
|
|
216
|
+
sealed on one kernel and open on the other.
|
|
217
|
+
"""
|
|
218
|
+
canales = carreteras_raiz(casa)
|
|
219
|
+
try:
|
|
220
|
+
entradas = sorted(os.listdir(canales))
|
|
221
|
+
except OSError:
|
|
222
|
+
return []
|
|
223
|
+
fuera = []
|
|
224
|
+
for entrada in entradas:
|
|
225
|
+
env = os.path.join(canales, entrada, '.env')
|
|
226
|
+
if os.path.isfile(env):
|
|
227
|
+
fuera.append(env)
|
|
228
|
+
return fuera
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _lineas_secretos(casa, bloqueados):
|
|
232
|
+
tipos = sellados_por_tipo(casa)
|
|
233
|
+
lineas = []
|
|
234
|
+
for raiz in bloqueados:
|
|
235
|
+
# A file target uses `literal`; a directory uses `subpath` so everything
|
|
236
|
+
# beneath it is sealed too. The kind comes from the declaration, so a
|
|
237
|
+
# credential file that does not exist yet is still sealed as a file.
|
|
238
|
+
if tipos.get(raiz, 'dir') == 'file':
|
|
239
|
+
lineas.append(f' (literal {_sbpl(raiz)})')
|
|
240
|
+
else:
|
|
241
|
+
lineas.append(f' (subpath {_sbpl(raiz)})')
|
|
242
|
+
# Remote road tokens configured for the seat: repo windows must never read
|
|
243
|
+
# them, even though the rest of `~/.claude` stays open for the runtime.
|
|
244
|
+
canal = re.escape(carreteras_raiz(casa))
|
|
245
|
+
lineas.append(f' (regex #"^{canal}/[^/]+/\\.env$")')
|
|
246
|
+
return lineas
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def perfil(repo, casa=None, fichero_token=None, extra_escritura=()):
|
|
250
|
+
"""The SBPL profile for one window, as text. Pure: no filesystem writes.
|
|
251
|
+
|
|
252
|
+
`repo` is the window's working directory (a repo, or an agent workspace).
|
|
253
|
+
`extra_escritura` are additional writable roots — the resolved targets of an
|
|
254
|
+
agent's workspace mounts — sealed-root-checked like any other write-allow.
|
|
255
|
+
"""
|
|
256
|
+
casa = rutas.canonicaliza(casa or '~')
|
|
257
|
+
repo = rutas.canonicaliza(repo)
|
|
258
|
+
if not os.path.isdir(repo):
|
|
259
|
+
raise ValueError(f'the cage needs an existing working directory, got: {repo}')
|
|
260
|
+
# Compute the sealed set once and thread it through every helper below.
|
|
261
|
+
bloqueados = sellados(casa)
|
|
262
|
+
motivo = rutas.motivo_bloqueo(repo, bloqueados)
|
|
263
|
+
if motivo:
|
|
264
|
+
raise ValueError(f'the working directory is unsafe to cage: {motivo}')
|
|
265
|
+
partes = [
|
|
266
|
+
'(version 1)',
|
|
267
|
+
'; Agents City cage — generated, one per window. Do not edit.',
|
|
268
|
+
'(allow default)',
|
|
269
|
+
'(deny file-write*)',
|
|
270
|
+
'(allow file-write*',
|
|
271
|
+
*_lineas_permite_escritura(repo, casa, bloqueados, extra_escritura),
|
|
272
|
+
')',
|
|
273
|
+
'(deny file-read* file-write*',
|
|
274
|
+
*_lineas_secretos(casa, bloqueados),
|
|
275
|
+
')',
|
|
276
|
+
]
|
|
277
|
+
if fichero_token:
|
|
278
|
+
# This window's own broker token, and only this window's: the broker
|
|
279
|
+
# directory deny above stays in force for every other file in it.
|
|
280
|
+
partes.append(f'(allow file-read* (literal {_sbpl(rutas.canonicaliza(fichero_token))}))')
|
|
281
|
+
return '\n'.join(partes) + '\n'
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# ── Linux: the same seal, built out of mounts instead of a profile ───────────
|
|
285
|
+
#
|
|
286
|
+
# Bubblewrap applies its binds in order and the last one wins, so the argv obeys
|
|
287
|
+
# the same ordering guarantee the profile above states — and a sealed path is
|
|
288
|
+
# not refused here, it is simply not mounted.
|
|
289
|
+
|
|
290
|
+
#: The base every caged launch starts from — and the exact same flags the
|
|
291
|
+
#: availability probe runs, so "bwrap works here" cannot mean a different
|
|
292
|
+
#: sandbox than the one an agent gets.
|
|
293
|
+
BASE_BWRAP = ['bwrap', '--ro-bind', '/', '/', '--dev', '/dev']
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@functools.lru_cache(maxsize=None)
|
|
297
|
+
def _prueba_bwrap():
|
|
298
|
+
"""Build one real namespace and remember whether it worked."""
|
|
299
|
+
if not shutil.which('bwrap'):
|
|
300
|
+
return False
|
|
301
|
+
try:
|
|
302
|
+
hecho = subprocess.run(
|
|
303
|
+
# It either builds a namespace in milliseconds or it is refused;
|
|
304
|
+
# nothing in between, so a long timeout only buys dead launch time.
|
|
305
|
+
[*BASE_BWRAP, 'true'],
|
|
306
|
+
capture_output=True,
|
|
307
|
+
timeout=5,
|
|
308
|
+
)
|
|
309
|
+
return hecho.returncode == 0
|
|
310
|
+
except (OSError, subprocess.SubprocessError):
|
|
311
|
+
return False
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def bwrap_sirve():
|
|
315
|
+
"""Whether bubblewrap can actually build a namespace here — not merely
|
|
316
|
+
whether the binary exists.
|
|
317
|
+
|
|
318
|
+
Ubuntu 24 restricts unprivileged user namespaces through AppArmor, and a
|
|
319
|
+
hardened kernel can refuse them outright. A prefix that fails at launch
|
|
320
|
+
would take the agent's window down with it, so this asks bwrap to do the
|
|
321
|
+
real thing rather than trusting `which`.
|
|
322
|
+
|
|
323
|
+
The launcher runs this module as a fresh process PER WINDOW, so the answer
|
|
324
|
+
is also read from and published to the environment: a city of eight agents
|
|
325
|
+
forks one probe, not eight. `CITY_CAGE_BWRAP=1|0` is that channel, and an
|
|
326
|
+
owner can set it by hand to skip the probe entirely.
|
|
327
|
+
"""
|
|
328
|
+
# The environment outranks the memo on purpose: it is how the launcher hands
|
|
329
|
+
# one probe's answer to every window, and how an owner skips probing at all.
|
|
330
|
+
dicho = os.environ.get('CITY_CAGE_BWRAP')
|
|
331
|
+
if dicho == '0':
|
|
332
|
+
return False
|
|
333
|
+
if dicho == '1':
|
|
334
|
+
# Trusted, but not blindly: tmux windows inherit the server's whole
|
|
335
|
+
# environment, so a value set on another machine (or a stale one) would
|
|
336
|
+
# otherwise build a prefix that exits 127 on every single window.
|
|
337
|
+
return shutil.which('bwrap') is not None
|
|
338
|
+
return _prueba_bwrap()
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def argv_bwrap(repo, casa=None, fichero_token=None, extra_escritura=()):
|
|
342
|
+
"""The bubblewrap argv that cages one window on Linux. Pure: no writes.
|
|
343
|
+
|
|
344
|
+
Returns the prefix only — the caller appends the command to run.
|
|
345
|
+
"""
|
|
346
|
+
casa = rutas.canonicaliza(casa or '~')
|
|
347
|
+
repo = rutas.canonicaliza(repo)
|
|
348
|
+
if not os.path.isdir(repo):
|
|
349
|
+
raise ValueError(f'the cage needs an existing working directory, got: {repo}')
|
|
350
|
+
bloqueados = sellados(casa)
|
|
351
|
+
motivo = rutas.motivo_bloqueo(repo, bloqueados)
|
|
352
|
+
if motivo:
|
|
353
|
+
raise ValueError(f'the working directory is unsafe to cage: {motivo}')
|
|
354
|
+
|
|
355
|
+
# Deliberately NOT here:
|
|
356
|
+
# --proc needs a PID namespace to be worth anything, and a PID
|
|
357
|
+
# namespace would make the runtime gateway write a
|
|
358
|
+
# namespace-local pid into <runtime>/gateways/<actor>.pid —
|
|
359
|
+
# which `agents-city exit` then signals on the host, killing
|
|
360
|
+
# an unrelated process. /proc arrives with the read-only bind
|
|
361
|
+
# of / anyway.
|
|
362
|
+
# --die-with-parent
|
|
363
|
+
# the bus hub is started detached ON PURPOSE so it outlives
|
|
364
|
+
# the window that happened to start it; tying the namespace's
|
|
365
|
+
# life to one pane would take the whole city's bus down with
|
|
366
|
+
# that pane. macOS has no equivalent flag either.
|
|
367
|
+
argv = BASE_BWRAP.copy()
|
|
368
|
+
# The working set, re-bound writable over the read-only world. The set is
|
|
369
|
+
# `_permitidas_escritura`'s answer and only that: the same list the SBPL
|
|
370
|
+
# profile allows, so the two cages cannot drift into permitting different
|
|
371
|
+
# things.
|
|
372
|
+
for ruta in _permitidas_escritura(repo, casa, bloqueados, extra_escritura,
|
|
373
|
+
sistema=ESCRITURA_SISTEMA_LINUX):
|
|
374
|
+
# `-try` because a build cache nobody has created yet is not an error,
|
|
375
|
+
# and a cage that refuses to start is a cage nobody keeps switched on.
|
|
376
|
+
argv += ['--bind-try', ruta, ruta]
|
|
377
|
+
# And then the seal. A directory becomes an empty tmpfs; a file becomes
|
|
378
|
+
# /dev/null, which cannot be read through.
|
|
379
|
+
#
|
|
380
|
+
# ONLY what exists is sealed, and that is not a shortcut — it is the
|
|
381
|
+
# difference between a cage and a machine that cannot start. `--tmpfs` has
|
|
382
|
+
# no `-try` form and bwrap creates the mountpoint with a single mkdir: over
|
|
383
|
+
# a read-only `/`, sealing an absent `~/.aws` aborts the launch, and where
|
|
384
|
+
# the parent IS writable it does worse — it leaves a real empty directory
|
|
385
|
+
# on the owner's disk, so a later `cargo login` fails forever with "Is a
|
|
386
|
+
# directory" and nothing points back here.
|
|
387
|
+
#
|
|
388
|
+
# Skipping an absent path costs nothing: `$HOME` itself is never writable
|
|
389
|
+
# inside the cage, so a window cannot create the secret it was not sealed
|
|
390
|
+
# from. What a seal cannot cover is a path created OUTSIDE, mid-session —
|
|
391
|
+
# the same bounded difference the road tokens have, documented in
|
|
392
|
+
# docs/security.md rather than papered over.
|
|
393
|
+
tipos = sellados_por_tipo(casa)
|
|
394
|
+
for raiz in bloqueados:
|
|
395
|
+
if not os.path.exists(raiz):
|
|
396
|
+
continue
|
|
397
|
+
if tipos.get(raiz, 'dir') == 'file':
|
|
398
|
+
argv += ['--ro-bind-try', '/dev/null', raiz]
|
|
399
|
+
else:
|
|
400
|
+
argv += ['--tmpfs', raiz]
|
|
401
|
+
# Claude keeps `~/.claude.json` at the HOME root, and the SBPL profile
|
|
402
|
+
# allows writing it. Without the same line here the Linux cage silently
|
|
403
|
+
# breaks the runtime it is meant to protect.
|
|
404
|
+
conf = rutas.canonicaliza(os.path.join(casa, '.claude.json'))
|
|
405
|
+
argv += ['--bind-try', conf, conf]
|
|
406
|
+
# Remote road tokens: the same rule the profile states as a regex, applied
|
|
407
|
+
# here one file at a time because a mount needs a path, not a pattern.
|
|
408
|
+
for env in env_de_carreteras(casa):
|
|
409
|
+
argv += ['--ro-bind-try', '/dev/null', env]
|
|
410
|
+
if fichero_token:
|
|
411
|
+
# Last word: this window's own token, and only this one.
|
|
412
|
+
real = rutas.canonicaliza(fichero_token)
|
|
413
|
+
argv += ['--ro-bind-try', real, real]
|
|
414
|
+
return argv
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def disponible():
|
|
418
|
+
"""Whether this machine can cage a window at all."""
|
|
419
|
+
if os.environ.get('CITY_CAGE', '1') == '0':
|
|
420
|
+
return False
|
|
421
|
+
if sys.platform == 'darwin':
|
|
422
|
+
return shutil.which('sandbox-exec') is not None
|
|
423
|
+
return sys.platform.startswith('linux') and bwrap_sirve()
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def escribe_perfil(repo, ventana, casa=None, fichero_token=None, extra_escritura=()):
|
|
427
|
+
"""Write the profile under the runtime dir and return its path."""
|
|
428
|
+
casa_real = os.path.realpath(casa or os.path.expanduser('~'))
|
|
429
|
+
directorio = os.path.join(casa_real, '.agents-city', '.runtime', 'cage')
|
|
430
|
+
os.makedirs(directorio, mode=0o700, exist_ok=True)
|
|
431
|
+
segmento = re.sub(r'[^a-zA-Z0-9_-]', '-', ventana) or 'window'
|
|
432
|
+
ruta = os.path.join(directorio, f'{segmento}.sb')
|
|
433
|
+
contenido = perfil(repo, casa=casa_real, fichero_token=fichero_token,
|
|
434
|
+
extra_escritura=extra_escritura)
|
|
435
|
+
with open(ruta, 'w', encoding='utf-8') as f:
|
|
436
|
+
f.write(contenido)
|
|
437
|
+
os.chmod(ruta, 0o600)
|
|
438
|
+
return ruta
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def linea(repo, ventana, casa=None, fichero_token=None, extra_escritura=()):
|
|
442
|
+
"""The launch prefix for one window, ready to prepend to a shell command:
|
|
443
|
+
`sandbox-exec -f <profile> ` on macOS, `bwrap … ` on Linux, or ''.
|
|
444
|
+
|
|
445
|
+
Empty means "launch uncaged": an unsupported platform, no sandboxing tool,
|
|
446
|
+
or the owner set CITY_CAGE=0. The caller prepends the result verbatim, so
|
|
447
|
+
the degraded path is exactly the behaviour the product always had.
|
|
448
|
+
"""
|
|
449
|
+
if not disponible():
|
|
450
|
+
return ''
|
|
451
|
+
if sys.platform == 'darwin':
|
|
452
|
+
ruta = escribe_perfil(repo, ventana, casa=casa, fichero_token=fichero_token,
|
|
453
|
+
extra_escritura=extra_escritura)
|
|
454
|
+
return f'sandbox-exec -f {ruta} '
|
|
455
|
+
argv = argv_bwrap(repo, casa=casa, fichero_token=fichero_token,
|
|
456
|
+
extra_escritura=extra_escritura)
|
|
457
|
+
return shlex.join(argv) + ' '
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def main():
|
|
461
|
+
p = argparse.ArgumentParser(description='One seatbelt cage per window.')
|
|
462
|
+
p.add_argument('orden', choices=['line', 'profile', 'check'])
|
|
463
|
+
p.add_argument('--repo', help='the working directory this window owns')
|
|
464
|
+
p.add_argument('--window', default='window', help='window name, used to name the profile')
|
|
465
|
+
p.add_argument('--home', help='HOME override (tests only)')
|
|
466
|
+
p.add_argument('--token-file', help="this window's broker token file, allowed read-only")
|
|
467
|
+
p.add_argument('--mounts', default='',
|
|
468
|
+
help='colon-separated resolved mount targets to also make writable')
|
|
469
|
+
args = p.parse_args()
|
|
470
|
+
if args.orden == 'check':
|
|
471
|
+
print('cage available' if disponible() else 'cage unavailable on this machine')
|
|
472
|
+
return 0 if disponible() else 1
|
|
473
|
+
if not args.repo:
|
|
474
|
+
print('--repo is required', file=sys.stderr)
|
|
475
|
+
return 2
|
|
476
|
+
extra = tuple(m for m in args.mounts.split(':') if m)
|
|
477
|
+
try:
|
|
478
|
+
if args.orden == 'profile':
|
|
479
|
+
sys.stdout.write(perfil(args.repo, casa=args.home, fichero_token=args.token_file,
|
|
480
|
+
extra_escritura=extra))
|
|
481
|
+
else:
|
|
482
|
+
sys.stdout.write(
|
|
483
|
+
linea(args.repo, args.window, casa=args.home, fichero_token=args.token_file,
|
|
484
|
+
extra_escritura=extra))
|
|
485
|
+
except (OSError, ValueError) as e:
|
|
486
|
+
print(f'{e}', file=sys.stderr)
|
|
487
|
+
return 1
|
|
488
|
+
return 0
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
if __name__ == '__main__':
|
|
492
|
+
sys.exit(main())
|