@wardby/cli 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/.env.example +131 -0
- package/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +282 -0
- package/bin/wardby.js +3 -0
- package/deploy/local/docker-compose.yml +19 -0
- package/dist/claude-coding-worker/driver.d.ts +70 -0
- package/dist/claude-coding-worker/driver.js +128 -0
- package/dist/claude-coding-worker/main.d.ts +2 -0
- package/dist/claude-coding-worker/main.js +41 -0
- package/dist/claude-coding-worker/sdk.d.ts +4 -0
- package/dist/claude-coding-worker/sdk.js +43 -0
- package/dist/claude-coding-worker/tool-relay.d.ts +1 -0
- package/dist/claude-coding-worker/tool-relay.js +9 -0
- package/dist/cli-help.d.ts +1 -0
- package/dist/cli-help.js +20 -0
- package/dist/cli.d.ts +18 -0
- package/dist/cli.js +629 -0
- package/dist/coding/observability.d.ts +59 -0
- package/dist/coding/observability.js +77 -0
- package/dist/coding/profile.d.ts +88 -0
- package/dist/coding/profile.js +137 -0
- package/dist/coding/protocol.d.ts +345 -0
- package/dist/coding/protocol.js +445 -0
- package/dist/coding/provider.d.ts +4 -0
- package/dist/coding/provider.js +18 -0
- package/dist/coding-proxy/main.d.ts +1 -0
- package/dist/coding-proxy/main.js +51 -0
- package/dist/coding-worker/artifact.d.ts +16 -0
- package/dist/coding-worker/artifact.js +37 -0
- package/dist/coding-worker/driver.d.ts +46 -0
- package/dist/coding-worker/driver.js +158 -0
- package/dist/coding-worker/errors.d.ts +1 -0
- package/dist/coding-worker/errors.js +23 -0
- package/dist/coding-worker/isolation-probe.d.ts +1 -0
- package/dist/coding-worker/isolation-probe.js +146 -0
- package/dist/coding-worker/keeper.d.ts +4 -0
- package/dist/coding-worker/keeper.js +29 -0
- package/dist/coding-worker/main.d.ts +2 -0
- package/dist/coding-worker/main.js +43 -0
- package/dist/coding-worker/sdk.d.ts +26 -0
- package/dist/coding-worker/sdk.js +28 -0
- package/dist/coding-worker/types.d.ts +64 -0
- package/dist/coding-worker/types.js +1 -0
- package/dist/config/providers.d.ts +158 -0
- package/dist/config/providers.js +162 -0
- package/dist/core/budget-groups.d.ts +72 -0
- package/dist/core/budget-groups.js +149 -0
- package/dist/core/budget.d.ts +63 -0
- package/dist/core/budget.js +69 -0
- package/dist/core/coding-queue.d.ts +32 -0
- package/dist/core/coding-queue.js +54 -0
- package/dist/core/cron.d.ts +31 -0
- package/dist/core/cron.js +43 -0
- package/dist/core/datastores.d.ts +41 -0
- package/dist/core/datastores.js +105 -0
- package/dist/core/db.d.ts +2 -0
- package/dist/core/db.js +2 -0
- package/dist/core/dispatch.d.ts +81 -0
- package/dist/core/dispatch.js +184 -0
- package/dist/core/engine-native.d.ts +29 -0
- package/dist/core/engine-native.js +259 -0
- package/dist/core/http-runtime.d.ts +7 -0
- package/dist/core/http-runtime.js +101 -0
- package/dist/core/lease.d.ts +13 -0
- package/dist/core/lease.js +22 -0
- package/dist/core/logger.d.ts +13 -0
- package/dist/core/logger.js +45 -0
- package/dist/core/memory-tools.d.ts +11 -0
- package/dist/core/memory-tools.js +122 -0
- package/dist/core/reconciler.d.ts +45 -0
- package/dist/core/reconciler.js +152 -0
- package/dist/core/runner.d.ts +87 -0
- package/dist/core/runner.js +529 -0
- package/dist/core/scheduler.d.ts +47 -0
- package/dist/core/scheduler.js +121 -0
- package/dist/core/secrets.d.ts +46 -0
- package/dist/core/secrets.js +100 -0
- package/dist/core/subagent-memory-tools.d.ts +18 -0
- package/dist/core/subagent-memory-tools.js +121 -0
- package/dist/core/timing.d.ts +7 -0
- package/dist/core/timing.js +7 -0
- package/dist/core/webhooks.d.ts +23 -0
- package/dist/core/webhooks.js +113 -0
- package/dist/env.d.ts +11 -0
- package/dist/env.js +13 -0
- package/dist/import/budgets.d.ts +16 -0
- package/dist/import/budgets.js +21 -0
- package/dist/import/bundle.d.ts +16 -0
- package/dist/import/bundle.js +61 -0
- package/dist/import/cli-args.d.ts +2 -0
- package/dist/import/cli-args.js +44 -0
- package/dist/import/create.d.ts +34 -0
- package/dist/import/create.js +363 -0
- package/dist/import/index.d.ts +25 -0
- package/dist/import/index.js +130 -0
- package/dist/import/model-gate.d.ts +1 -0
- package/dist/import/model-gate.js +3 -0
- package/dist/import/neutral-schema.d.ts +369 -0
- package/dist/import/neutral-schema.js +109 -0
- package/dist/import/preflight.d.ts +41 -0
- package/dist/import/preflight.js +71 -0
- package/dist/import/report.d.ts +5 -0
- package/dist/import/report.js +98 -0
- package/dist/import/tool-scan.d.ts +8 -0
- package/dist/import/tool-scan.js +20 -0
- package/dist/mcp/auth/ownership.d.ts +113 -0
- package/dist/mcp/auth/ownership.js +87 -0
- package/dist/mcp/auth/principal.d.ts +9 -0
- package/dist/mcp/auth/principal.js +9 -0
- package/dist/mcp/auth/resource-server.d.ts +47 -0
- package/dist/mcp/auth/resource-server.js +82 -0
- package/dist/mcp/auth/self-hosted/browser.d.ts +3 -0
- package/dist/mcp/auth/self-hosted/browser.js +273 -0
- package/dist/mcp/auth/self-hosted/cli.d.ts +2 -0
- package/dist/mcp/auth/self-hosted/cli.js +39 -0
- package/dist/mcp/auth/self-hosted/credentials.d.ts +58 -0
- package/dist/mcp/auth/self-hosted/credentials.js +106 -0
- package/dist/mcp/auth/self-hosted/rate-limit.d.ts +13 -0
- package/dist/mcp/auth/self-hosted/rate-limit.js +20 -0
- package/dist/mcp/auth/self-hosted/session.d.ts +36 -0
- package/dist/mcp/auth/self-hosted/session.js +110 -0
- package/dist/mcp/capabilities.d.ts +11 -0
- package/dist/mcp/capabilities.js +10 -0
- package/dist/mcp/context.d.ts +46 -0
- package/dist/mcp/context.js +1 -0
- package/dist/mcp/errors.d.ts +13 -0
- package/dist/mcp/errors.js +22 -0
- package/dist/mcp/index.d.ts +32 -0
- package/dist/mcp/index.js +187 -0
- package/dist/mcp/jsonrpc.d.ts +8 -0
- package/dist/mcp/jsonrpc.js +1 -0
- package/dist/mcp/server.d.ts +107 -0
- package/dist/mcp/server.js +203 -0
- package/dist/mcp/tasks/manager.d.ts +63 -0
- package/dist/mcp/tasks/manager.js +103 -0
- package/dist/mcp/tools/agents.d.ts +2 -0
- package/dist/mcp/tools/agents.js +375 -0
- package/dist/mcp/tools/budget-groups.d.ts +2 -0
- package/dist/mcp/tools/budget-groups.js +136 -0
- package/dist/mcp/tools/datastore.d.ts +2 -0
- package/dist/mcp/tools/datastore.js +217 -0
- package/dist/mcp/tools/memory.d.ts +3 -0
- package/dist/mcp/tools/memory.js +60 -0
- package/dist/mcp/tools/models.d.ts +8 -0
- package/dist/mcp/tools/models.js +15 -0
- package/dist/mcp/tools/runs.d.ts +2 -0
- package/dist/mcp/tools/runs.js +61 -0
- package/dist/mcp/tools/scheduling.d.ts +2 -0
- package/dist/mcp/tools/scheduling.js +48 -0
- package/dist/mcp/tools/secret-elicitation-form.d.ts +22 -0
- package/dist/mcp/tools/secret-elicitation-form.js +93 -0
- package/dist/mcp/tools/secret-elicitation-server.d.ts +5 -0
- package/dist/mcp/tools/secret-elicitation-server.js +57 -0
- package/dist/mcp/tools/secret-elicitation.d.ts +38 -0
- package/dist/mcp/tools/secret-elicitation.js +52 -0
- package/dist/mcp/tools/secrets.d.ts +13 -0
- package/dist/mcp/tools/secrets.js +124 -0
- package/dist/mcp/tools/subagents.d.ts +2 -0
- package/dist/mcp/tools/subagents.js +133 -0
- package/dist/mcp/tools/text-result.d.ts +6 -0
- package/dist/mcp/tools/text-result.js +3 -0
- package/dist/mcp/tools/tools.d.ts +2 -0
- package/dist/mcp/tools/tools.js +211 -0
- package/dist/mcp/tools/trigger.d.ts +2 -0
- package/dist/mcp/tools/trigger.js +120 -0
- package/dist/mcp/tools/webhooks.d.ts +2 -0
- package/dist/mcp/tools/webhooks.js +34 -0
- package/dist/mcp/transport/http-limits.d.ts +17 -0
- package/dist/mcp/transport/http-limits.js +92 -0
- package/dist/mcp/transport/stdio.d.ts +23 -0
- package/dist/mcp/transport/stdio.js +6 -0
- package/dist/mcp/transport/streamable-http.d.ts +34 -0
- package/dist/mcp/transport/streamable-http.js +158 -0
- package/dist/mcp/unattended-schedules.d.ts +15 -0
- package/dist/mcp/unattended-schedules.js +21 -0
- package/dist/mcp/webhooks/ingress.d.ts +17 -0
- package/dist/mcp/webhooks/ingress.js +33 -0
- package/dist/observability/config.d.ts +6 -0
- package/dist/observability/config.js +20 -0
- package/dist/observability/metrics-server.d.ts +11 -0
- package/dist/observability/metrics-server.js +47 -0
- package/dist/observability/metrics.d.ts +36 -0
- package/dist/observability/metrics.js +158 -0
- package/dist/providers/auth/authorization-server.d.ts +41 -0
- package/dist/providers/auth/authorization-server.js +1 -0
- package/dist/providers/auth/delegating.d.ts +44 -0
- package/dist/providers/auth/delegating.js +105 -0
- package/dist/providers/auth/index.d.ts +11 -0
- package/dist/providers/auth/index.js +20 -0
- package/dist/providers/auth/release-gate.d.ts +1 -0
- package/dist/providers/auth/release-gate.js +4 -0
- package/dist/providers/auth/self-hosted.d.ts +78 -0
- package/dist/providers/auth/self-hosted.js +377 -0
- package/dist/providers/auth/subject.d.ts +1 -0
- package/dist/providers/auth/subject.js +6 -0
- package/dist/providers/auth/types.d.ts +63 -0
- package/dist/providers/auth/types.js +22 -0
- package/dist/providers/coding-proxy/deny-port.d.ts +5 -0
- package/dist/providers/coding-proxy/deny-port.js +45 -0
- package/dist/providers/coding-proxy/environment-credentials.d.ts +7 -0
- package/dist/providers/coding-proxy/environment-credentials.js +16 -0
- package/dist/providers/coding-proxy/index.d.ts +7 -0
- package/dist/providers/coding-proxy/index.js +7 -0
- package/dist/providers/coding-proxy/memory-ledger.d.ts +17 -0
- package/dist/providers/coding-proxy/memory-ledger.js +103 -0
- package/dist/providers/coding-proxy/metering.d.ts +20 -0
- package/dist/providers/coding-proxy/metering.js +161 -0
- package/dist/providers/coding-proxy/prisma-ledger.d.ts +15 -0
- package/dist/providers/coding-proxy/prisma-ledger.js +213 -0
- package/dist/providers/coding-proxy/proxy.d.ts +75 -0
- package/dist/providers/coding-proxy/proxy.js +721 -0
- package/dist/providers/coding-proxy/runtime.d.ts +18 -0
- package/dist/providers/coding-proxy/runtime.js +39 -0
- package/dist/providers/coding-proxy/secure-fetch.d.ts +9 -0
- package/dist/providers/coding-proxy/secure-fetch.js +72 -0
- package/dist/providers/coding-proxy/server.d.ts +17 -0
- package/dist/providers/coding-proxy/server.js +139 -0
- package/dist/providers/coding-proxy/types.d.ts +99 -0
- package/dist/providers/coding-proxy/types.js +1 -0
- package/dist/providers/datastore/index.d.ts +2 -0
- package/dist/providers/datastore/index.js +2 -0
- package/dist/providers/datastore/postgres.d.ts +20 -0
- package/dist/providers/datastore/postgres.js +130 -0
- package/dist/providers/datastore/scoped.d.ts +10 -0
- package/dist/providers/datastore/scoped.js +46 -0
- package/dist/providers/datastore/types.d.ts +31 -0
- package/dist/providers/datastore/types.js +7 -0
- package/dist/providers/email/types.d.ts +46 -0
- package/dist/providers/email/types.js +8 -0
- package/dist/providers/engine/index.d.ts +1 -0
- package/dist/providers/engine/index.js +1 -0
- package/dist/providers/engine/types.d.ts +67 -0
- package/dist/providers/engine/types.js +16 -0
- package/dist/providers/executor/build.d.ts +6 -0
- package/dist/providers/executor/build.js +15 -0
- package/dist/providers/executor/composition.d.ts +14 -0
- package/dist/providers/executor/composition.js +123 -0
- package/dist/providers/executor/container.d.ts +184 -0
- package/dist/providers/executor/container.js +936 -0
- package/dist/providers/executor/dbos-status.d.ts +50 -0
- package/dist/providers/executor/dbos-status.js +64 -0
- package/dist/providers/executor/dbos.d.ts +53 -0
- package/dist/providers/executor/dbos.js +269 -0
- package/dist/providers/executor/in-process.d.ts +18 -0
- package/dist/providers/executor/in-process.js +38 -0
- package/dist/providers/executor/index.d.ts +8 -0
- package/dist/providers/executor/index.js +8 -0
- package/dist/providers/executor/routing.d.ts +48 -0
- package/dist/providers/executor/routing.js +70 -0
- package/dist/providers/executor/types.d.ts +55 -0
- package/dist/providers/executor/types.js +1 -0
- package/dist/providers/index.d.ts +43 -0
- package/dist/providers/index.js +18 -0
- package/dist/providers/jobs/docker-isolation.d.ts +166 -0
- package/dist/providers/jobs/docker-isolation.js +724 -0
- package/dist/providers/jobs/docker.d.ts +127 -0
- package/dist/providers/jobs/docker.js +1027 -0
- package/dist/providers/jobs/fake-kubernetes-api.d.ts +98 -0
- package/dist/providers/jobs/fake-kubernetes-api.js +142 -0
- package/dist/providers/jobs/fake.d.ts +23 -0
- package/dist/providers/jobs/fake.js +132 -0
- package/dist/providers/jobs/kubernetes-api.d.ts +60 -0
- package/dist/providers/jobs/kubernetes-api.js +20 -0
- package/dist/providers/jobs/kubernetes-client.d.ts +36 -0
- package/dist/providers/jobs/kubernetes-client.js +171 -0
- package/dist/providers/jobs/kubernetes-dry-run-fixture.d.ts +49 -0
- package/dist/providers/jobs/kubernetes-dry-run-fixture.js +141 -0
- package/dist/providers/jobs/kubernetes-isolation.d.ts +147 -0
- package/dist/providers/jobs/kubernetes-isolation.js +607 -0
- package/dist/providers/jobs/kubernetes-platform.d.ts +177 -0
- package/dist/providers/jobs/kubernetes-platform.js +280 -0
- package/dist/providers/jobs/kubernetes-preflight.d.ts +59 -0
- package/dist/providers/jobs/kubernetes-preflight.js +339 -0
- package/dist/providers/jobs/kubernetes-witness.d.ts +10 -0
- package/dist/providers/jobs/kubernetes-witness.js +193 -0
- package/dist/providers/jobs/kubernetes.d.ts +127 -0
- package/dist/providers/jobs/kubernetes.js +874 -0
- package/dist/providers/jobs/safe-extract.d.ts +9 -0
- package/dist/providers/jobs/safe-extract.js +325 -0
- package/dist/providers/jobs/types.d.ts +78 -0
- package/dist/providers/jobs/types.js +6 -0
- package/dist/providers/jobs/workspace-swap.d.ts +2 -0
- package/dist/providers/jobs/workspace-swap.js +47 -0
- package/dist/providers/llm/anthropic.d.ts +13 -0
- package/dist/providers/llm/anthropic.js +21 -0
- package/dist/providers/llm/bedrock.d.ts +19 -0
- package/dist/providers/llm/bedrock.js +33 -0
- package/dist/providers/llm/claude-messages.d.ts +106 -0
- package/dist/providers/llm/claude-messages.js +157 -0
- package/dist/providers/llm/claude-provider.d.ts +40 -0
- package/dist/providers/llm/claude-provider.js +31 -0
- package/dist/providers/llm/index.d.ts +10 -0
- package/dist/providers/llm/index.js +10 -0
- package/dist/providers/llm/openai.d.ts +23 -0
- package/dist/providers/llm/openai.js +156 -0
- package/dist/providers/llm/pricing-anthropic.d.ts +4 -0
- package/dist/providers/llm/pricing-anthropic.js +30 -0
- package/dist/providers/llm/pricing-bedrock-claude.d.ts +12 -0
- package/dist/providers/llm/pricing-bedrock-claude.js +37 -0
- package/dist/providers/llm/pricing-core.d.ts +26 -0
- package/dist/providers/llm/pricing-core.js +17 -0
- package/dist/providers/llm/pricing.d.ts +30 -0
- package/dist/providers/llm/pricing.js +74 -0
- package/dist/providers/llm/registration.d.ts +8 -0
- package/dist/providers/llm/registration.js +39 -0
- package/dist/providers/llm/routing.d.ts +25 -0
- package/dist/providers/llm/routing.js +32 -0
- package/dist/providers/llm/types.d.ts +85 -0
- package/dist/providers/llm/types.js +12 -0
- package/dist/providers/memory/index.d.ts +2 -0
- package/dist/providers/memory/index.js +2 -0
- package/dist/providers/memory/postgres.d.ts +20 -0
- package/dist/providers/memory/postgres.js +72 -0
- package/dist/providers/memory/types.d.ts +32 -0
- package/dist/providers/memory/types.js +17 -0
- package/dist/providers/secrets/app-key.d.ts +8 -0
- package/dist/providers/secrets/app-key.js +50 -0
- package/dist/providers/secrets/index.d.ts +10 -0
- package/dist/providers/secrets/index.js +13 -0
- package/dist/providers/secrets/transfer-envelope.d.ts +12 -0
- package/dist/providers/secrets/transfer-envelope.js +35 -0
- package/dist/providers/secrets/types.d.ts +18 -0
- package/dist/providers/secrets/types.js +12 -0
- package/dist/providers/storage/types.d.ts +18 -0
- package/dist/providers/storage/types.js +7 -0
- package/dist/providers/vcs/git.d.ts +105 -0
- package/dist/providers/vcs/git.js +710 -0
- package/dist/providers/vcs/github.d.ts +105 -0
- package/dist/providers/vcs/github.js +394 -0
- package/dist/providers/vcs/index.d.ts +6 -0
- package/dist/providers/vcs/index.js +26 -0
- package/dist/providers/vcs/types.d.ts +118 -0
- package/dist/providers/vcs/types.js +1 -0
- package/dist/sandbox/bounded-json.d.ts +2 -0
- package/dist/sandbox/bounded-json.js +36 -0
- package/dist/sandbox/bridge.d.ts +15 -0
- package/dist/sandbox/bridge.js +78 -0
- package/dist/sandbox/eval-core.d.ts +37 -0
- package/dist/sandbox/eval-core.js +159 -0
- package/dist/sandbox/fetch-policy.d.ts +24 -0
- package/dist/sandbox/fetch-policy.js +96 -0
- package/dist/sandbox/generated/zod-to-json-schema.bundle.js +5 -0
- package/dist/sandbox/generated/zod.bundle.js +1 -0
- package/dist/sandbox/host-functions.d.ts +29 -0
- package/dist/sandbox/host-functions.js +168 -0
- package/dist/sandbox/limits.d.ts +39 -0
- package/dist/sandbox/limits.js +39 -0
- package/dist/sandbox/parser-worker/__fixtures__/crash.d.ts +1 -0
- package/dist/sandbox/parser-worker/__fixtures__/crash.js +6 -0
- package/dist/sandbox/parser-worker/__fixtures__/echo.d.ts +1 -0
- package/dist/sandbox/parser-worker/__fixtures__/echo.js +5 -0
- package/dist/sandbox/parser-worker/__fixtures__/reject.d.ts +1 -0
- package/dist/sandbox/parser-worker/__fixtures__/reject.js +5 -0
- package/dist/sandbox/parser-worker/__fixtures__/spin-forever.d.ts +1 -0
- package/dist/sandbox/parser-worker/__fixtures__/spin-forever.js +11 -0
- package/dist/sandbox/parser-worker/pool.d.ts +13 -0
- package/dist/sandbox/parser-worker/pool.js +111 -0
- package/dist/sandbox/parser-worker/worker.d.ts +14 -0
- package/dist/sandbox/parser-worker/worker.js +63 -0
- package/dist/sandbox/pii-redaction.d.ts +1 -0
- package/dist/sandbox/pii-redaction.js +29 -0
- package/dist/sandbox/prelude.d.ts +26 -0
- package/dist/sandbox/prelude.js +408 -0
- package/dist/sandbox/quickjs-module.d.ts +13 -0
- package/dist/sandbox/quickjs-module.js +19 -0
- package/dist/sandbox/run-in-sandbox.d.ts +34 -0
- package/dist/sandbox/run-in-sandbox.js +53 -0
- package/dist/sandbox/safe-fetch.d.ts +26 -0
- package/dist/sandbox/safe-fetch.js +140 -0
- package/dist/sandbox/tool-capabilities.d.ts +66 -0
- package/dist/sandbox/tool-capabilities.js +103 -0
- package/dist/sandbox/zod-params.d.ts +22 -0
- package/dist/sandbox/zod-params.js +60 -0
- package/dist/serve.d.ts +14 -0
- package/dist/serve.js +54 -0
- package/dist/wardby-bin.d.ts +2 -0
- package/dist/wardby-bin.js +20 -0
- package/package.json +123 -0
- package/prisma/migrations/20260905000000_init/migration.sql +34 -0
- package/prisma/migrations/20260905010000_scheduler_durability/migration.sql +31 -0
- package/prisma/migrations/20260905020000_tools_multiturn/migration.sql +47 -0
- package/prisma/migrations/20260905030000_cache_tool_json_schema/migration.sql +13 -0
- package/prisma/migrations/20260906000000_mcp_phase4/migration.sql +115 -0
- package/prisma/migrations/20260906010000_run_final_text/migration.sql +7 -0
- package/prisma/migrations/20260906020000_run_turns/migration.sql +6 -0
- package/prisma/migrations/20260906030000_task_principal_id/migration.sql +5 -0
- package/prisma/migrations/20260906040000_secure_self_hosted_oauth/migration.sql +67 -0
- package/prisma/migrations/20260906050000_phase5_coding_agents/migration.sql +52 -0
- package/prisma/migrations/20260906060000_tool_capability_scoping/migration.sql +22 -0
- package/prisma/migrations/20260906070000_datastore_pii_flag/migration.sql +6 -0
- package/prisma/migrations/20260906080000_secret_elicitation_outcomes/migration.sql +13 -0
- package/prisma/migrations/20260907010000_coding_proxy_ledger/migration.sql +57 -0
- package/prisma/migrations/20260907020000_budget_groups/migration.sql +24 -0
- package/prisma/migrations/20260907030000_coding_webhook_task_override/migration.sql +4 -0
- package/prisma/migrations/20260908010000_run_execution_backend/migration.sql +2 -0
- package/prisma/migrations/20260908020000_coding_run_audit_metadata/migration.sql +7 -0
- package/prisma/migrations/20260910010000_agent_secret_bound_name/migration.sql +16 -0
- package/prisma/migrations/20260911010000_coding_worker_toolchains/migration.sql +5 -0
- package/prisma/migrations/20260912010000_coding_provider_contract/migration.sql +24 -0
- package/prisma/migrations/20260912020000_coding_proxy_protocol/migration.sql +10 -0
- package/prisma/migrations/20260912030000_agent_memory/migration.sql +18 -0
- package/prisma/migrations/20260912040000_named_shared_datastores/migration.sql +68 -0
- package/prisma/migrations/20260913000000_run_trigger_webhook/migration.sql +2 -0
- package/prisma/migrations/20260913010000_agent_sub_agent/migration.sql +32 -0
- package/prisma/migrations/20260913020000_run_task_override/migration.sql +5 -0
- package/prisma/migrations/20260913030000_coding_run_root/migration.sql +11 -0
- package/prisma/migrations/20260922010000_coding_run_queued_at/migration.sql +8 -0
- package/prisma/migrations/20260922020000_coding_workspace_disk/migration.sql +8 -0
- package/prisma/migrations/migration_lock.toml +3 -0
- package/prisma/schema.prisma +723 -0
- package/scripts/git-askpass.sh +11 -0
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical Kubernetes isolation policy for one Codex coding run, mirroring
|
|
3
|
+
* docker-isolation.ts. Nothing else constructs run pods or policies; the
|
|
4
|
+
* launcher reads every object back and attests it against these builders
|
|
5
|
+
* before the worker is allowed to start (the seeded-marker gate).
|
|
6
|
+
*/
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { isIP } from "node:net";
|
|
9
|
+
import { CODING_PROXY_ALIAS, CODING_PROXY_DENY_PORT, CODING_PROXY_PORT, CODING_WORKER_GID, CODING_WORKER_UID, WORKER_STOP_GRACE_SECONDS, isRepositoryDigest, } from "./docker-isolation.js";
|
|
10
|
+
import { GVISOR_RUNTIME_CLASS, KubernetesPlatformError, STORAGE_INIT_EPHEMERAL_MIB, WORKER_EPHEMERAL_MIB, conformResources, describeMib, normalizePlatformMetadata, platformProfile, podEphemeralStorageMib, } from "./kubernetes-platform.js";
|
|
11
|
+
export const KUBERNETES_ISOLATION_ERROR = "kubernetes_isolation_unsupported";
|
|
12
|
+
export const KUBERNETES_PROVIDER_UNSUPPORTED = "kubernetes_provider_unsupported";
|
|
13
|
+
/** Extra seconds past timeoutSec before Kubernetes kills the pod (keeper included). */
|
|
14
|
+
export const POD_DEADLINE_GRACE_SECONDS = 300;
|
|
15
|
+
export const KEEPER_CONTAINER = "keeper";
|
|
16
|
+
export const STORAGE_INIT_CONTAINER = "storage-init";
|
|
17
|
+
export const WORKER_CONTAINER = "worker";
|
|
18
|
+
export const STORAGE_ROOT = "/run/wardby/storage";
|
|
19
|
+
export const KEEPER_SEEDED_MARKER = `${STORAGE_ROOT}/input/.seeded`;
|
|
20
|
+
export const PROXY_POD_LABEL = { "app.kubernetes.io/name": "wardby-coding-proxy" };
|
|
21
|
+
/**
|
|
22
|
+
* What every run pod carries and what the proxy's own ingress rule must admit on the deny port.
|
|
23
|
+
* Single source: `runLabels` stamps it, `readProxyWitness` checks the proxy admits it.
|
|
24
|
+
*/
|
|
25
|
+
export const RUN_COMPONENT_LABEL = { "wardby.io/component": "coding-run" };
|
|
26
|
+
/** The probe proved enforcement: the proxy port connected and the deny port was blocked. */
|
|
27
|
+
export const ENFORCEMENT_PROBE_PROVEN = 0;
|
|
28
|
+
/** The deny port was reachable: no policy is blocking it, or the policy is not port-scoped. */
|
|
29
|
+
export const ENFORCEMENT_PROBE_DENY_REACHABLE = 3;
|
|
30
|
+
/** The proxy port itself was unreachable: nothing could be witnessed (proxy down, or only the namespace default-deny is programmed). */
|
|
31
|
+
export const ENFORCEMENT_PROBE_PROXY_UNREACHABLE = 4;
|
|
32
|
+
/**
|
|
33
|
+
* The deny port answered with an RST: the SYN reached the destination host, so nothing is
|
|
34
|
+
* blocking the path — the port is reachable-but-unserved and witnesses nothing.
|
|
35
|
+
*/
|
|
36
|
+
export const ENFORCEMENT_PROBE_DENY_REFUSED = 5;
|
|
37
|
+
const WORKER_SERVICE_ACCOUNT = "wardby-coding-worker";
|
|
38
|
+
const RUN_ID = /^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,199}$/;
|
|
39
|
+
/** The worker waits for the launcher's seeded marker, then runs the image's normal entrypoint. */
|
|
40
|
+
const WORKER_GATE = [
|
|
41
|
+
'const fs = require("node:fs");',
|
|
42
|
+
'const marker = "/run/wardby/input/.seeded";',
|
|
43
|
+
"(function wait() {",
|
|
44
|
+
" if (fs.existsSync(marker)) {",
|
|
45
|
+
' import("/opt/wardby/coding-worker/main.js").catch(() => { process.exitCode = 1; });',
|
|
46
|
+
" } else {",
|
|
47
|
+
" setTimeout(wait, 250);",
|
|
48
|
+
" }",
|
|
49
|
+
"})();",
|
|
50
|
+
].join("\n");
|
|
51
|
+
/**
|
|
52
|
+
* Creates the worker's subPath mount sources, owned by the run uid, before any regular container
|
|
53
|
+
* starts. Otherwise the kubelet creates them root-owned while setting up the worker's subPath mounts,
|
|
54
|
+
* and the keeper (uid 10001, no capabilities) can't chmod them. Idempotent; no shell.
|
|
55
|
+
*/
|
|
56
|
+
const STORAGE_INIT_SCRIPT = [
|
|
57
|
+
'const fs = require("node:fs");',
|
|
58
|
+
'for (const name of ["workspace", "input", "output"]) {',
|
|
59
|
+
` const path = ${JSON.stringify(STORAGE_ROOT)} + "/" + name;`,
|
|
60
|
+
" fs.mkdirSync(path, { recursive: true, mode: 0o700 });",
|
|
61
|
+
" fs.chmodSync(path, 0o700);",
|
|
62
|
+
"}",
|
|
63
|
+
].join("\n");
|
|
64
|
+
function isolationError() {
|
|
65
|
+
return new Error(KUBERNETES_ISOLATION_ERROR);
|
|
66
|
+
}
|
|
67
|
+
function sha256(value) {
|
|
68
|
+
return createHash("sha256").update(value).digest("hex");
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The per-run object names for a run token. The only place the naming scheme lives: the launcher
|
|
72
|
+
* resolves a handle's token through this, so names can never drift between creation and cleanup.
|
|
73
|
+
*/
|
|
74
|
+
export function kubernetesRunNamesForToken(token) {
|
|
75
|
+
const base = `wardby-run-${token}`;
|
|
76
|
+
return { token, pod: base, policy: base, record: base, secret: `${base}-cap` };
|
|
77
|
+
}
|
|
78
|
+
export function kubernetesRunNames(runId) {
|
|
79
|
+
if (!RUN_ID.test(runId))
|
|
80
|
+
throw isolationError();
|
|
81
|
+
const digest = sha256(runId);
|
|
82
|
+
return { ...kubernetesRunNamesForToken(digest.slice(0, 20)), runSha: digest.slice(0, 40) };
|
|
83
|
+
}
|
|
84
|
+
export function runLabels(runId) {
|
|
85
|
+
return {
|
|
86
|
+
"app.kubernetes.io/managed-by": "wardby",
|
|
87
|
+
...RUN_COMPONENT_LABEL,
|
|
88
|
+
"wardby.io/run-sha256": kubernetesRunNames(runId).runSha,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/** Same grammar as Docker's repository digests; a cluster cannot pull a bare local image ID. */
|
|
92
|
+
export function isRegistryDigest(image) {
|
|
93
|
+
return isRepositoryDigest(image);
|
|
94
|
+
}
|
|
95
|
+
function inRange(value, min, max, integer) {
|
|
96
|
+
return Number.isFinite(value) && value >= min && value <= max && (!integer || Number.isInteger(value));
|
|
97
|
+
}
|
|
98
|
+
/** Whether `value` is within floating-point rounding error of an integer. */
|
|
99
|
+
function isNearInteger(value, tolerance = 1e-9) {
|
|
100
|
+
return Math.abs(Math.round(value) - value) < tolerance;
|
|
101
|
+
}
|
|
102
|
+
/** Kubernetes CPU requests/limits are always whole millicores; a fractional millicore can't be expressed. */
|
|
103
|
+
function isWholeMillicores(cpus) {
|
|
104
|
+
return isNearInteger(cpus * 1000);
|
|
105
|
+
}
|
|
106
|
+
export function validateKubernetesSpec(spec) {
|
|
107
|
+
if (spec.kind !== "coding-agent" || !RUN_ID.test(spec.runId))
|
|
108
|
+
throw isolationError();
|
|
109
|
+
if (spec.provider === "claude-code")
|
|
110
|
+
throw new Error(KUBERNETES_PROVIDER_UNSUPPORTED);
|
|
111
|
+
if (spec.provider !== undefined && spec.provider !== "codex")
|
|
112
|
+
throw isolationError();
|
|
113
|
+
if (spec.toolImage !== undefined || !isRegistryDigest(spec.image))
|
|
114
|
+
throw isolationError();
|
|
115
|
+
const { cpus, memoryMb, pids, diskMb } = spec.limits;
|
|
116
|
+
if (!inRange(cpus, 0.1, 32, false) ||
|
|
117
|
+
!isWholeMillicores(cpus) ||
|
|
118
|
+
!inRange(memoryMb, 128, 65_536, true) ||
|
|
119
|
+
!inRange(pids, 16, 4_096, true) ||
|
|
120
|
+
!inRange(diskMb, 64, 32_768, true) ||
|
|
121
|
+
!inRange(spec.timeoutSec, 1, 86_400, true)) {
|
|
122
|
+
throw isolationError();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function containerSecurity() {
|
|
126
|
+
return {
|
|
127
|
+
allowPrivilegeEscalation: false,
|
|
128
|
+
privileged: false,
|
|
129
|
+
readOnlyRootFilesystem: true,
|
|
130
|
+
runAsNonRoot: true,
|
|
131
|
+
capabilities: { drop: ["ALL"] },
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
export function buildRunPod(spec, options) {
|
|
135
|
+
validateKubernetesSpec(spec);
|
|
136
|
+
const profile = platformProfile(options.platform ?? "generic");
|
|
137
|
+
// Symmetric with the ceiling check below: the builder is the one place that actually emits the
|
|
138
|
+
// pod, so a platform that requires gVisor must refuse to build one without it here too, not rely
|
|
139
|
+
// solely on composition/preflight having already checked. Otherwise buildRunPod(spec, { platform:
|
|
140
|
+
// "gke-autopilot" }) with no runtimeClassName would silently emit runtimeClassName: undefined.
|
|
141
|
+
if (profile.requiresGvisor && options.runtimeClassName !== GVISOR_RUNTIME_CLASS) {
|
|
142
|
+
throw new KubernetesPlatformError(`platform ${profile.name} requires runtimeClassName=${GVISOR_RUNTIME_CLASS} (found ${options.runtimeClassName ?? "unset"})`);
|
|
143
|
+
}
|
|
144
|
+
// conformResources range-checks one container at a time; only this function sees every container,
|
|
145
|
+
// so the SUMMED pod total is checked here — before submission, so an over-large workspace fails
|
|
146
|
+
// closed rather than being rewritten by the platform (which attestation would then reject anyway).
|
|
147
|
+
const ceiling = profile.resources.ephemeralStorageCeilingMib;
|
|
148
|
+
const podEphemeral = podEphemeralStorageMib(spec.limits.diskMb);
|
|
149
|
+
if (ceiling !== undefined && podEphemeral > ceiling) {
|
|
150
|
+
throw new KubernetesPlatformError(`a ${spec.limits.diskMb} MiB workspace needs ${podEphemeral} MiB of pod ephemeral storage, over the ${describeMib(ceiling)} ceiling of platform ${profile.name}`);
|
|
151
|
+
}
|
|
152
|
+
const names = kubernetesRunNames(spec.runId);
|
|
153
|
+
const scratchMb = Math.max(16, Math.min(64, Math.floor(spec.limits.memoryMb / 8)));
|
|
154
|
+
const sidecarCpuMillicores = 250;
|
|
155
|
+
const sidecarMemoryMib = 128;
|
|
156
|
+
const storageInit = {
|
|
157
|
+
name: STORAGE_INIT_CONTAINER,
|
|
158
|
+
image: spec.image,
|
|
159
|
+
command: ["node", "-e", STORAGE_INIT_SCRIPT],
|
|
160
|
+
securityContext: containerSecurity(),
|
|
161
|
+
resources: conformResources(profile, {
|
|
162
|
+
cpuMillicores: sidecarCpuMillicores,
|
|
163
|
+
memoryMib: sidecarMemoryMib,
|
|
164
|
+
ephemeralStorageMib: STORAGE_INIT_EPHEMERAL_MIB,
|
|
165
|
+
}),
|
|
166
|
+
volumeMounts: [{ name: "storage", mountPath: STORAGE_ROOT }],
|
|
167
|
+
};
|
|
168
|
+
const keeper = {
|
|
169
|
+
name: KEEPER_CONTAINER,
|
|
170
|
+
image: spec.image,
|
|
171
|
+
command: ["node", "/opt/wardby/coding-worker/keeper.js"],
|
|
172
|
+
securityContext: containerSecurity(),
|
|
173
|
+
resources: conformResources(profile, {
|
|
174
|
+
cpuMillicores: sidecarCpuMillicores,
|
|
175
|
+
memoryMib: sidecarMemoryMib,
|
|
176
|
+
// The keeper owns the storage volume: seeding and collection stream through it.
|
|
177
|
+
ephemeralStorageMib: spec.limits.diskMb,
|
|
178
|
+
}),
|
|
179
|
+
volumeMounts: [{ name: "storage", mountPath: STORAGE_ROOT }],
|
|
180
|
+
readinessProbe: { exec: { command: ["test", "-d", `${STORAGE_ROOT}/output`] }, periodSeconds: 1 },
|
|
181
|
+
};
|
|
182
|
+
const worker = {
|
|
183
|
+
name: WORKER_CONTAINER,
|
|
184
|
+
image: spec.image,
|
|
185
|
+
command: ["node", "-e", WORKER_GATE],
|
|
186
|
+
env: [
|
|
187
|
+
{ name: "WARDBY_PROXY_URL", value: `http://${CODING_PROXY_ALIAS}:${CODING_PROXY_PORT}` },
|
|
188
|
+
{ name: "WARDBY_RUN_CAPABILITY", valueFrom: { secretKeyRef: { name: names.secret, key: "capability" } } },
|
|
189
|
+
],
|
|
190
|
+
securityContext: containerSecurity(),
|
|
191
|
+
resources: conformResources(profile, {
|
|
192
|
+
cpuMillicores: Math.round(spec.limits.cpus * 1000),
|
|
193
|
+
memoryMib: spec.limits.memoryMb,
|
|
194
|
+
ephemeralStorageMib: WORKER_EPHEMERAL_MIB,
|
|
195
|
+
}),
|
|
196
|
+
volumeMounts: [
|
|
197
|
+
{ name: "storage", mountPath: "/workspace", subPath: "workspace" },
|
|
198
|
+
{ name: "storage", mountPath: "/run/wardby/input", subPath: "input", readOnly: true },
|
|
199
|
+
{ name: "storage", mountPath: "/run/wardby/output", subPath: "output" },
|
|
200
|
+
{ name: "tmp", mountPath: "/tmp" },
|
|
201
|
+
{ name: "home", mountPath: "/home/wardby" },
|
|
202
|
+
],
|
|
203
|
+
};
|
|
204
|
+
return {
|
|
205
|
+
apiVersion: "v1",
|
|
206
|
+
kind: "Pod",
|
|
207
|
+
metadata: {
|
|
208
|
+
name: names.pod,
|
|
209
|
+
namespace: options.namespace,
|
|
210
|
+
labels: runLabels(spec.runId),
|
|
211
|
+
annotations: { "wardby.io/run-id": spec.runId },
|
|
212
|
+
},
|
|
213
|
+
spec: {
|
|
214
|
+
restartPolicy: "Never",
|
|
215
|
+
automountServiceAccountToken: false,
|
|
216
|
+
serviceAccountName: WORKER_SERVICE_ACCOUNT,
|
|
217
|
+
enableServiceLinks: false,
|
|
218
|
+
hostNetwork: false,
|
|
219
|
+
hostPID: false,
|
|
220
|
+
hostIPC: false,
|
|
221
|
+
shareProcessNamespace: false,
|
|
222
|
+
// Backstop only: the launcher enforces the wall-clock deadline; the grace leaves a collection window.
|
|
223
|
+
activeDeadlineSeconds: spec.timeoutSec + POD_DEADLINE_GRACE_SECONDS,
|
|
224
|
+
terminationGracePeriodSeconds: WORKER_STOP_GRACE_SECONDS,
|
|
225
|
+
dnsPolicy: "None",
|
|
226
|
+
dnsConfig: { nameservers: ["127.0.0.1"] },
|
|
227
|
+
hostAliases: [{ ip: options.proxyIp, hostnames: [CODING_PROXY_ALIAS] }],
|
|
228
|
+
...(options.runtimeClassName ? { runtimeClassName: options.runtimeClassName } : {}),
|
|
229
|
+
securityContext: {
|
|
230
|
+
runAsNonRoot: true,
|
|
231
|
+
runAsUser: CODING_WORKER_UID,
|
|
232
|
+
runAsGroup: CODING_WORKER_GID,
|
|
233
|
+
fsGroup: CODING_WORKER_GID,
|
|
234
|
+
seccompProfile: { type: "RuntimeDefault" },
|
|
235
|
+
},
|
|
236
|
+
volumes: [
|
|
237
|
+
{ name: "storage", emptyDir: { sizeLimit: `${spec.limits.diskMb}Mi` } },
|
|
238
|
+
{ name: "tmp", emptyDir: { medium: "Memory", sizeLimit: `${scratchMb}Mi` } },
|
|
239
|
+
{ name: "home", emptyDir: { medium: "Memory", sizeLimit: `${scratchMb}Mi` } },
|
|
240
|
+
],
|
|
241
|
+
initContainers: [storageInit],
|
|
242
|
+
containers: [keeper, worker],
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
export function buildRunNetworkPolicy(spec, namespace) {
|
|
247
|
+
const names = kubernetesRunNames(spec.runId);
|
|
248
|
+
return {
|
|
249
|
+
apiVersion: "networking.k8s.io/v1",
|
|
250
|
+
kind: "NetworkPolicy",
|
|
251
|
+
metadata: { name: names.policy, namespace, labels: runLabels(spec.runId) },
|
|
252
|
+
spec: {
|
|
253
|
+
podSelector: { matchLabels: runLabels(spec.runId) },
|
|
254
|
+
policyTypes: ["Ingress", "Egress"],
|
|
255
|
+
ingress: [],
|
|
256
|
+
egress: [
|
|
257
|
+
{
|
|
258
|
+
to: [{ podSelector: { matchLabels: { ...PROXY_POD_LABEL } } }],
|
|
259
|
+
ports: [{ protocol: "TCP", port: CODING_PROXY_PORT }],
|
|
260
|
+
},
|
|
261
|
+
],
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
export function buildCapabilitySecret(spec, namespace, capability) {
|
|
266
|
+
const names = kubernetesRunNames(spec.runId);
|
|
267
|
+
return {
|
|
268
|
+
apiVersion: "v1",
|
|
269
|
+
kind: "Secret",
|
|
270
|
+
type: "Opaque",
|
|
271
|
+
metadata: { name: names.secret, namespace, labels: runLabels(spec.runId) },
|
|
272
|
+
stringData: { capability },
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Deny-by-default attestation. Rather than allowlisting the fields we expect
|
|
277
|
+
* to see (which silently accepts anything the allowlist forgot — lifecycle
|
|
278
|
+
* hooks, probes that reach the node's link-local metadata endpoint,
|
|
279
|
+
* seLinuxOptions/appArmorProfile/procMount escapes, extra tolerations,
|
|
280
|
+
* stray annotations, ...), we deep-compare the *entire* spec, labels, and
|
|
281
|
+
* annotations, and only normalize the exact fields the Kubernetes API
|
|
282
|
+
* server itself is known to default or reorder on read-back. Every other
|
|
283
|
+
* field — every security-relevant one included — must match exactly.
|
|
284
|
+
*/
|
|
285
|
+
/**
|
|
286
|
+
* CPU quantities are always whole millicores; normalize "1", "1.0", and
|
|
287
|
+
* "1000m" to the same string. Rounding only absorbs float noise (the
|
|
288
|
+
* `isNearInteger` tolerance) — a value that's genuinely fractional at the
|
|
289
|
+
* millicore scale (e.g. "1000.4m", "0.9996") is not a legitimate Kubernetes
|
|
290
|
+
* quantity for something we build, so it's mapped to a sentinel that can
|
|
291
|
+
* never equal a real builder value, making the comparison fail closed
|
|
292
|
+
* instead of silently rounding two different resource requests together.
|
|
293
|
+
*/
|
|
294
|
+
function cpuMillicores(value) {
|
|
295
|
+
const text = String(value).trim();
|
|
296
|
+
const milli = /^([0-9]*\.?[0-9]+)m$/.exec(text);
|
|
297
|
+
if (milli) {
|
|
298
|
+
const millis = Number(milli[1]);
|
|
299
|
+
return isNearInteger(millis) ? `${Math.round(millis)}m` : `invalid:${text}`;
|
|
300
|
+
}
|
|
301
|
+
const plain = /^([0-9]*\.?[0-9]+)$/.exec(text);
|
|
302
|
+
if (plain) {
|
|
303
|
+
const millis = Number(plain[1]) * 1000;
|
|
304
|
+
return isNearInteger(millis) ? `${Math.round(millis)}m` : `invalid:${text}`;
|
|
305
|
+
}
|
|
306
|
+
return text;
|
|
307
|
+
}
|
|
308
|
+
const MEMORY_BINARY_UNITS = {
|
|
309
|
+
Ki: 1024,
|
|
310
|
+
Mi: 1024 ** 2,
|
|
311
|
+
Gi: 1024 ** 3,
|
|
312
|
+
Ti: 1024 ** 4,
|
|
313
|
+
Pi: 1024 ** 5,
|
|
314
|
+
Ei: 1024 ** 6,
|
|
315
|
+
};
|
|
316
|
+
const MEMORY_DECIMAL_UNITS = { K: 1e3, M: 1e6, G: 1e9, T: 1e12, P: 1e15, E: 1e18 };
|
|
317
|
+
/**
|
|
318
|
+
* Byte-denominated quantities (memory and ephemeral-storage) can use binary or
|
|
319
|
+
* decimal suffixes; normalize every form to a byte count, failing closed on a
|
|
320
|
+
* non-integer byte count (see `cpuMillicores`).
|
|
321
|
+
*/
|
|
322
|
+
function memoryBytes(value) {
|
|
323
|
+
const text = String(value).trim();
|
|
324
|
+
const match = /^([0-9]*\.?[0-9]+)(Ki|Mi|Gi|Ti|Pi|Ei|K|M|G|T|P|E)?$/.exec(text);
|
|
325
|
+
if (!match)
|
|
326
|
+
return text;
|
|
327
|
+
const amount = Number(match[1]);
|
|
328
|
+
const unit = match[2];
|
|
329
|
+
let bytes;
|
|
330
|
+
if (unit && unit in MEMORY_BINARY_UNITS)
|
|
331
|
+
bytes = amount * MEMORY_BINARY_UNITS[unit];
|
|
332
|
+
else if (unit && unit in MEMORY_DECIMAL_UNITS)
|
|
333
|
+
bytes = amount * MEMORY_DECIMAL_UNITS[unit];
|
|
334
|
+
else
|
|
335
|
+
bytes = amount;
|
|
336
|
+
return isNearInteger(bytes) ? String(Math.round(bytes)) : `invalid:${text}`;
|
|
337
|
+
}
|
|
338
|
+
/** The two node-health tolerations every pod gets by admission-time default; anything else must match exactly. */
|
|
339
|
+
const DEFAULT_TOLERATIONS = [
|
|
340
|
+
{ key: "node.kubernetes.io/not-ready", operator: "Exists", effect: "NoExecute", tolerationSeconds: 300 },
|
|
341
|
+
{ key: "node.kubernetes.io/unreachable", operator: "Exists", effect: "NoExecute", tolerationSeconds: 300 },
|
|
342
|
+
];
|
|
343
|
+
function isDefaultToleration(t) {
|
|
344
|
+
return DEFAULT_TOLERATIONS.some((d) => t.key === d.key &&
|
|
345
|
+
t.operator === d.operator &&
|
|
346
|
+
t.effect === d.effect &&
|
|
347
|
+
t.tolerationSeconds === d.tolerationSeconds);
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Normalizes only the *rendering* of a quantity, never its value: two spellings
|
|
351
|
+
* of the same number compare equal, two different numbers never do.
|
|
352
|
+
*
|
|
353
|
+
* `ephemeral-storage` is canonicalized for the same reason memory is, and the
|
|
354
|
+
* reason is not cosmetic. Go's `resource.Quantity` keeps the string it was
|
|
355
|
+
* parsed from and re-serializes it verbatim — but only while that cached string
|
|
356
|
+
* survives. Any mutation of the resource block drops it, and the value is then
|
|
357
|
+
* re-rendered in canonical binary form, so the `1024Mi` we submit comes back as
|
|
358
|
+
* `1Gi`. A platform whose admission controller rewrites resources by design
|
|
359
|
+
* (Autopilot's warden) would therefore fail attestation on a quantity that never
|
|
360
|
+
* actually changed, with nothing but `kubernetes_isolation_unsupported` to go on.
|
|
361
|
+
* Comparing byte counts removes that failure mode without losing any strictness:
|
|
362
|
+
* a genuinely different reservation is still a different number.
|
|
363
|
+
*/
|
|
364
|
+
function normalizeResources(r) {
|
|
365
|
+
if (!r)
|
|
366
|
+
return;
|
|
367
|
+
for (const bag of [r.requests, r.limits]) {
|
|
368
|
+
if (!bag)
|
|
369
|
+
continue;
|
|
370
|
+
if (bag.cpu !== undefined)
|
|
371
|
+
bag.cpu = cpuMillicores(bag.cpu);
|
|
372
|
+
if (bag.memory !== undefined)
|
|
373
|
+
bag.memory = memoryBytes(bag.memory);
|
|
374
|
+
if (bag["ephemeral-storage"] !== undefined)
|
|
375
|
+
bag["ephemeral-storage"] = memoryBytes(bag["ephemeral-storage"]);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
function normalizeProbe(p) {
|
|
379
|
+
if (!p)
|
|
380
|
+
return;
|
|
381
|
+
p.timeoutSeconds ??= 1;
|
|
382
|
+
p.successThreshold ??= 1;
|
|
383
|
+
p.failureThreshold ??= 3;
|
|
384
|
+
p.periodSeconds ??= 10;
|
|
385
|
+
}
|
|
386
|
+
function normalizeContainer(c) {
|
|
387
|
+
delete c.terminationMessagePath;
|
|
388
|
+
delete c.terminationMessagePolicy;
|
|
389
|
+
delete c.imagePullPolicy;
|
|
390
|
+
normalizeResources(c.resources);
|
|
391
|
+
normalizeProbe(c.readinessProbe);
|
|
392
|
+
normalizeProbe(c.livenessProbe);
|
|
393
|
+
normalizeProbe(c.startupProbe);
|
|
394
|
+
for (const mount of c.volumeMounts ?? []) {
|
|
395
|
+
if (mount.mountPropagation === "None")
|
|
396
|
+
delete mount.mountPropagation;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
function normalizeVolume(v) {
|
|
400
|
+
if (v.emptyDir) {
|
|
401
|
+
if (v.emptyDir.medium === "")
|
|
402
|
+
delete v.emptyDir.medium;
|
|
403
|
+
if (v.emptyDir.sizeLimit !== undefined)
|
|
404
|
+
v.emptyDir.sizeLimit = memoryBytes(v.emptyDir.sizeLimit);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Applies only the normalizations the Kubernetes API server itself performs
|
|
409
|
+
* (defaulting or aliasing fields on write/read). Everything else in the
|
|
410
|
+
* spec — including every security-relevant field — is left untouched so
|
|
411
|
+
* the caller's deep-equality check sees it.
|
|
412
|
+
*/
|
|
413
|
+
function normalizeSpec(spec) {
|
|
414
|
+
if (!Array.isArray(spec.containers))
|
|
415
|
+
throw isolationError();
|
|
416
|
+
delete spec.schedulerName;
|
|
417
|
+
delete spec.nodeName;
|
|
418
|
+
delete spec.priority;
|
|
419
|
+
delete spec.preemptionPolicy;
|
|
420
|
+
// Go's `omitempty` drops a plain bool at its zero value (false) on serialization, so a
|
|
421
|
+
// genuine API read-back never has these fields when they're false — only when true.
|
|
422
|
+
if (spec.hostNetwork === false)
|
|
423
|
+
delete spec.hostNetwork;
|
|
424
|
+
if (spec.hostPID === false)
|
|
425
|
+
delete spec.hostPID;
|
|
426
|
+
if (spec.hostIPC === false)
|
|
427
|
+
delete spec.hostIPC;
|
|
428
|
+
if (spec.serviceAccount !== undefined) {
|
|
429
|
+
if (spec.serviceAccount !== spec.serviceAccountName)
|
|
430
|
+
throw isolationError();
|
|
431
|
+
delete spec.serviceAccount;
|
|
432
|
+
}
|
|
433
|
+
if (spec.tolerations) {
|
|
434
|
+
const remaining = spec.tolerations.filter((t) => !isDefaultToleration(t));
|
|
435
|
+
if (remaining.length === 0)
|
|
436
|
+
delete spec.tolerations;
|
|
437
|
+
else
|
|
438
|
+
spec.tolerations = remaining;
|
|
439
|
+
}
|
|
440
|
+
for (const container of spec.containers)
|
|
441
|
+
normalizeContainer(container);
|
|
442
|
+
for (const container of spec.initContainers ?? [])
|
|
443
|
+
normalizeContainer(container);
|
|
444
|
+
for (const volume of spec.volumes ?? [])
|
|
445
|
+
normalizeVolume(volume);
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* A copy of `pod` with ONLY the API server's own defaulting and aliasing undone
|
|
449
|
+
* — the same `normalizeSpec` pass `assertRunPodMatches` runs, and nothing else.
|
|
450
|
+
*
|
|
451
|
+
* Exported for src/tools/capture-fixture.ts, which runs it over both the
|
|
452
|
+
* submitted and the returned pod before diffing them. Without it the diff is
|
|
453
|
+
* dominated by fields every API server fills in on every create
|
|
454
|
+
* (schedulerName, priority, terminationMessagePath on each container, probe
|
|
455
|
+
* defaults, `2048Mi` requantized to `2Gi`, ...); because the differ replaces
|
|
456
|
+
* arrays wholesale those collapse into opaque `replace /spec/containers`
|
|
457
|
+
* blobs, and a genuine platform rewrite *inside* a container would be
|
|
458
|
+
* indistinguishable from the noise.
|
|
459
|
+
*
|
|
460
|
+
* It deliberately does NOT apply `normalizePlatformMetadata`: that deletes
|
|
461
|
+
* exactly the platform-injected keys a capture exists to record. Fails closed
|
|
462
|
+
* (throws) on a spec the normalizer cannot make sense of.
|
|
463
|
+
*/
|
|
464
|
+
export function undoApiServerDefaults(pod) {
|
|
465
|
+
if (!pod.spec)
|
|
466
|
+
throw isolationError();
|
|
467
|
+
const spec = structuredClone(pod.spec);
|
|
468
|
+
normalizeSpec(spec);
|
|
469
|
+
return { ...structuredClone(pod), spec };
|
|
470
|
+
}
|
|
471
|
+
function normalizePod(pod, profile) {
|
|
472
|
+
if (!pod.spec)
|
|
473
|
+
throw isolationError();
|
|
474
|
+
// Every field the normalizer may touch must already be a copy: normalizePlatformMetadata
|
|
475
|
+
// mutates `view.spec` in place, and replaces the two bags with copies of its own.
|
|
476
|
+
const spec = structuredClone(pod.spec);
|
|
477
|
+
normalizeSpec(spec);
|
|
478
|
+
const view = {
|
|
479
|
+
labels: structuredClone(pod.metadata?.labels ?? {}),
|
|
480
|
+
annotations: structuredClone(pod.metadata?.annotations ?? {}),
|
|
481
|
+
spec,
|
|
482
|
+
};
|
|
483
|
+
// Applied to BOTH operands, symmetrically: deletes only the keys the profile names,
|
|
484
|
+
// and the profile for "generic" names none. It never skips a field the comparison
|
|
485
|
+
// would otherwise see, so everything left is still deep-compared below.
|
|
486
|
+
normalizePlatformMetadata(profile, view);
|
|
487
|
+
return view;
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Recursively sorts object keys and drops `undefined` values so key order and API-omitted
|
|
491
|
+
* fields never matter; array order is preserved.
|
|
492
|
+
*
|
|
493
|
+
* Exported for kubernetes-dry-run-fixture.ts's `diffMutations`: a captured pod's nested objects
|
|
494
|
+
* (container resource requests, volume definitions, host aliases, ...) come back from a real
|
|
495
|
+
* cluster with different key insertion order than `buildRunPod`'s own object literals, even when
|
|
496
|
+
* every value is identical. Without canonicalizing first, `diffMutations`' array branch treats
|
|
497
|
+
* that key-order difference as a genuine change and replaces the WHOLE array (every container,
|
|
498
|
+
* every volume) with an opaque blob — exactly the failure mode its own doc comment warns a real
|
|
499
|
+
* platform rewrite could hide inside. `assertRunPodMatches` below is unaffected by this (it
|
|
500
|
+
* already canonicalizes before comparing); only the capture tool's mutation list was at risk.
|
|
501
|
+
*/
|
|
502
|
+
export function canonical(value) {
|
|
503
|
+
if (Array.isArray(value))
|
|
504
|
+
return value.map((item) => canonical(item));
|
|
505
|
+
if (value !== null && typeof value === "object") {
|
|
506
|
+
const entries = Object.entries(value)
|
|
507
|
+
.filter(([, v]) => v !== undefined)
|
|
508
|
+
.map(([key, v]) => [key, canonical(v)])
|
|
509
|
+
.sort(([a], [b]) => a.localeCompare(b));
|
|
510
|
+
return Object.fromEntries(entries);
|
|
511
|
+
}
|
|
512
|
+
return value;
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Deep-compares the pod the API server read back against the pod wardby built.
|
|
516
|
+
* ANY surviving difference fails the run closed.
|
|
517
|
+
*
|
|
518
|
+
* `platform` defaults to "generic" on purpose: a caller that forgets it gets the
|
|
519
|
+
* strictest behaviour, never the most permissive. A profile can only narrow what
|
|
520
|
+
* counts as an expected difference (by deleting named keys from both operands);
|
|
521
|
+
* it can never disable or short-circuit the comparison.
|
|
522
|
+
*/
|
|
523
|
+
export function assertRunPodMatches(actual, expected, platform = "generic") {
|
|
524
|
+
const profile = platformProfile(platform);
|
|
525
|
+
const a = canonical(normalizePod(actual, profile));
|
|
526
|
+
const e = canonical(normalizePod(expected, profile));
|
|
527
|
+
if (JSON.stringify(a) !== JSON.stringify(e))
|
|
528
|
+
throw isolationError();
|
|
529
|
+
}
|
|
530
|
+
/** Go's `omitempty` drops an empty slice on serialization, so a genuine API read-back omits `ingress` when it's `[]` — only a populated ingress rule list survives. */
|
|
531
|
+
function normalizeNetworkPolicySpec(spec) {
|
|
532
|
+
if (Array.isArray(spec.ingress) && spec.ingress.length === 0)
|
|
533
|
+
delete spec.ingress;
|
|
534
|
+
}
|
|
535
|
+
export function assertRunNetworkPolicyMatches(actual, expected) {
|
|
536
|
+
const view = (p) => {
|
|
537
|
+
const spec = structuredClone(p.spec ?? {});
|
|
538
|
+
normalizeNetworkPolicySpec(spec);
|
|
539
|
+
return { labels: p.metadata?.labels ?? {}, spec };
|
|
540
|
+
};
|
|
541
|
+
if (JSON.stringify(canonical(view(actual))) !== JSON.stringify(canonical(view(expected))))
|
|
542
|
+
throw isolationError();
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* A `node -e` script (argv only, never a shell) that measures BOTH of the proxy's
|
|
546
|
+
* ports in one pass, with a 3 s connect timeout each (above Linux's 1 s initial
|
|
547
|
+
* SYN retransmission, so one dropped SYN on an allowed path still connects).
|
|
548
|
+
*
|
|
549
|
+
* Measuring both is what makes the result decisive. A NetworkPolicy denial drops
|
|
550
|
+
* the packet rather than rejecting it — GKE Dataplane V2, which Autopilot runs,
|
|
551
|
+
* always drops — so "the deny port did not answer" is equally consistent with
|
|
552
|
+
* "the proxy is gone and no policy exists at all". Requiring the proxy port to
|
|
553
|
+
* connect in the *same* probe turns "something is listening" from a control-plane
|
|
554
|
+
* inference into a fact this pod just observed.
|
|
555
|
+
*
|
|
556
|
+
* That pairing alone is still not enough, and this was reproduced on a live
|
|
557
|
+
* cluster: in a namespace with NO NetworkPolicy at all, against a pod listening
|
|
558
|
+
* on 8787 and serving nothing on 8788, a probe that collapsed `error` and
|
|
559
|
+
* `timeout` into one "not reachable" exited PROVEN while it had full internet
|
|
560
|
+
* egress. The pairing only rules out "the whole proxy pod is dead"; whenever the
|
|
561
|
+
* deny *listener specifically* is unserved, "blocked" and "nothing there" are
|
|
562
|
+
* the same observation. No control-plane read fixes this — Endpoints subset
|
|
563
|
+
* ports come from the Service's numeric targetPort, not from anything actually
|
|
564
|
+
* binding — so the distinction has to be made in the dataplane, here:
|
|
565
|
+
*
|
|
566
|
+
* - deny port **times out** → the packet was dropped somewhere on the path → the only outcome
|
|
567
|
+
* that can prove a policy. Note what it does NOT prove on its own: *which* hop dropped it. That
|
|
568
|
+
* it was the run pod's own egress policy follows from the proxy admitting run pods on the deny
|
|
569
|
+
* port at its own ingress, leaving no other hop that would drop it — a precondition the
|
|
570
|
+
* preflight's `proxy-service` check now verifies (readProxyWitness), after a live cluster
|
|
571
|
+
* falsified the assumption: with the drop moved to the destination, a prober holding no policy
|
|
572
|
+
* at all and with full internet egress read PROVEN.
|
|
573
|
+
* - deny port **refused** (RST / ECONNREFUSED) → the SYN reached the destination host, so
|
|
574
|
+
* nothing blocked the path. This holds on every dataplane, drop-based ones included, because
|
|
575
|
+
* a drop cannot produce an RST. Reported as ENFORCEMENT_PROBE_DENY_REFUSED, never as proven.
|
|
576
|
+
* - proxy port must still CONNECT for anything to count at all.
|
|
577
|
+
*
|
|
578
|
+
* Consequence, intended: on a **reject-style** CNI a genuine policy denial also arrives as an
|
|
579
|
+
* RST, so such a cluster now fails closed here rather than passing vacuously. Failing closed on
|
|
580
|
+
* a cluster whose refusals are ambiguous is the correct direction — a witness that cannot tell
|
|
581
|
+
* "denied" from "unserved" is not a witness — and such a cluster needs a different one.
|
|
582
|
+
*
|
|
583
|
+
* Exit codes are ENFORCEMENT_PROBE_PROVEN / _DENY_REACHABLE / _PROXY_UNREACHABLE / _DENY_REFUSED.
|
|
584
|
+
* The IP is validated and embedded as a JSON string literal.
|
|
585
|
+
*/
|
|
586
|
+
export function enforcementProbeScript(proxyIp) {
|
|
587
|
+
if (isIP(proxyIp) === 0)
|
|
588
|
+
throw isolationError();
|
|
589
|
+
return [
|
|
590
|
+
'const net = require("node:net");',
|
|
591
|
+
"const tcp = (port) =>",
|
|
592
|
+
" new Promise((done) => {",
|
|
593
|
+
" const socket = net.connect({ host: " + JSON.stringify(proxyIp) + ", port, timeout: 3000 });",
|
|
594
|
+
' socket.once("connect", () => { socket.destroy(); done("connect"); });',
|
|
595
|
+
' socket.once("timeout", () => { socket.destroy(); done("timeout"); });',
|
|
596
|
+
' socket.once("error", () => done("error"));',
|
|
597
|
+
" });",
|
|
598
|
+
"(async () => {",
|
|
599
|
+
` const allowed = await tcp(${CODING_PROXY_PORT});`,
|
|
600
|
+
` const denied = await tcp(${CODING_PROXY_DENY_PORT});`,
|
|
601
|
+
` if (allowed !== "connect") process.exit(${ENFORCEMENT_PROBE_PROXY_UNREACHABLE});`,
|
|
602
|
+
` if (denied === "connect") process.exit(${ENFORCEMENT_PROBE_DENY_REACHABLE});`,
|
|
603
|
+
` if (denied === "error") process.exit(${ENFORCEMENT_PROBE_DENY_REFUSED});`,
|
|
604
|
+
` process.exit(${ENFORCEMENT_PROBE_PROVEN});`,
|
|
605
|
+
"})();",
|
|
606
|
+
].join("\n");
|
|
607
|
+
}
|