@wardby/cli 0.7.0 → 0.9.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 +5 -5
- package/README.md +6 -1
- package/deploy/local/docker-compose.quickstart-coding.yml +11 -0
- package/dist/claude-coding-worker/driver.d.ts +12 -0
- package/dist/claude-coding-worker/driver.js +29 -7
- package/dist/claude-coding-worker/main.js +5 -1
- package/dist/claude-coding-worker/sdk.js +17 -1
- package/dist/claude-plugin/command.d.ts +64 -0
- package/dist/claude-plugin/command.js +379 -0
- package/dist/cli-help.d.ts +1 -1
- package/dist/cli-help.js +2 -1
- package/dist/cli.js +71 -3
- package/dist/coding/code-host.d.ts +16 -0
- package/dist/coding/code-host.js +60 -0
- package/dist/coding/mcp-token.d.ts +12 -0
- package/dist/coding/mcp-token.js +26 -0
- package/dist/coding/protocol.js +2 -0
- package/dist/coding/redact.js +6 -1
- package/dist/coding/transcript/recorder.d.ts +5 -4
- package/dist/coding/transcript/recorder.js +3 -9
- package/dist/coding-worker/debug-trace.d.ts +3 -1
- package/dist/coding-worker/debug-trace.js +4 -2
- package/dist/coding-worker/driver.js +8 -1
- package/dist/coding-worker/main.js +5 -1
- package/dist/coding-worker/mcp-config.d.ts +27 -0
- package/dist/coding-worker/mcp-config.js +32 -0
- package/dist/coding-worker/sdk.d.ts +36 -0
- package/dist/coding-worker/sdk.js +28 -1
- package/dist/coding-worker/types.d.ts +4 -0
- package/dist/config/mcp-call-limit.d.ts +8 -0
- package/dist/config/mcp-call-limit.js +18 -0
- package/dist/config/mcp-servers.d.ts +8 -0
- package/dist/config/mcp-servers.js +12 -0
- package/dist/config/test-simulators.d.ts +10 -0
- package/dist/config/test-simulators.js +28 -0
- package/dist/config/tool-timeout.d.ts +1 -0
- package/dist/config/tool-timeout.js +18 -0
- package/dist/core/coding-mcp.d.ts +36 -0
- package/dist/core/coding-mcp.js +253 -0
- package/dist/core/coding-repository.d.ts +46 -0
- package/dist/core/coding-repository.js +92 -0
- package/dist/core/delegation-slots.d.ts +27 -0
- package/dist/core/delegation-slots.js +44 -0
- package/dist/core/dispatch.d.ts +6 -2
- package/dist/core/dispatch.js +39 -11
- package/dist/core/engine-native.d.ts +2 -0
- package/dist/core/engine-native.js +13 -0
- package/dist/core/error-code.js +2 -0
- package/dist/core/grants.d.ts +28 -3
- package/dist/core/grants.js +46 -4
- package/dist/core/host-events.js +7 -3
- package/dist/core/host-status.js +2 -2
- package/dist/core/issue-status.js +4 -3
- package/dist/core/listen-address.d.ts +10 -0
- package/dist/core/listen-address.js +14 -0
- package/dist/core/mark-ready.d.ts +1 -1
- package/dist/core/mark-ready.js +1 -1
- package/dist/core/mcp-client/client.d.ts +47 -0
- package/dist/core/mcp-client/client.js +141 -0
- package/dist/core/mcp-client/jsonrpc.d.ts +15 -0
- package/dist/core/mcp-client/jsonrpc.js +54 -0
- package/dist/core/mcp-client/results.d.ts +13 -0
- package/dist/core/mcp-client/results.js +42 -0
- package/dist/core/mcp-servers.d.ts +90 -0
- package/dist/core/mcp-servers.js +230 -0
- package/dist/core/mcp-tools.d.ts +108 -0
- package/dist/core/mcp-tools.js +287 -0
- package/dist/core/merge-order-check.js +38 -19
- package/dist/core/notification-dispatcher.js +2 -1
- package/dist/core/power-idle.d.ts +8 -2
- package/dist/core/power-idle.js +17 -3
- package/dist/core/power.d.ts +5 -0
- package/dist/core/power.js +5 -0
- package/dist/core/related-pull-requests.js +26 -10
- package/dist/core/retention-days.d.ts +5 -0
- package/dist/core/retention-days.js +14 -0
- package/dist/core/review-fix.js +2 -2
- package/dist/core/review-host-tools.js +3 -1
- package/dist/core/run-steps/config.d.ts +25 -0
- package/dist/core/run-steps/config.js +40 -0
- package/dist/core/run-steps/control-chars.d.ts +16 -0
- package/dist/core/run-steps/control-chars.js +26 -0
- package/dist/core/run-steps/detail.d.ts +32 -0
- package/dist/core/run-steps/detail.js +240 -0
- package/dist/core/run-steps/extract.d.ts +52 -0
- package/dist/core/run-steps/extract.js +467 -0
- package/dist/core/run-steps/ingress.d.ts +51 -0
- package/dist/core/run-steps/ingress.js +160 -0
- package/dist/core/run-steps/last-step.d.ts +15 -0
- package/dist/core/run-steps/last-step.js +35 -0
- package/dist/core/run-steps/native.d.ts +48 -0
- package/dist/core/run-steps/native.js +164 -0
- package/dist/core/run-steps/seen.d.ts +16 -0
- package/dist/core/run-steps/seen.js +35 -0
- package/dist/core/run-steps/summarize.d.ts +57 -0
- package/dist/core/run-steps/summarize.js +304 -0
- package/dist/core/run-steps/sweep.d.ts +21 -0
- package/dist/core/run-steps/sweep.js +41 -0
- package/dist/core/run-steps/types.d.ts +89 -0
- package/dist/core/run-steps/types.js +39 -0
- package/dist/core/run-steps/wire.d.ts +35 -0
- package/dist/core/run-steps/wire.js +47 -0
- package/dist/core/run-steps/writer.d.ts +49 -0
- package/dist/core/run-steps/writer.js +279 -0
- package/dist/core/runner.d.ts +70 -16
- package/dist/core/runner.js +296 -74
- package/dist/core/secret-broker-config.d.ts +2 -0
- package/dist/core/secret-broker-config.js +1 -1
- package/dist/core/secret-guard.d.ts +5 -2
- package/dist/core/secret-guard.js +22 -7
- package/dist/core/secrets.d.ts +11 -1
- package/dist/core/secrets.js +27 -1
- package/dist/core/serializable-retry.d.ts +18 -0
- package/dist/core/serializable-retry.js +43 -0
- package/dist/core/tool-admin.d.ts +5 -1
- package/dist/core/tool-admin.js +7 -2
- package/dist/core/tool-names.d.ts +13 -0
- package/dist/core/tool-names.js +20 -0
- package/dist/core/webhooks.d.ts +4 -0
- package/dist/core/webhooks.js +68 -49
- package/dist/core/workflow-run-events.js +2 -1
- package/dist/generated/prisma/browser.d.ts +39 -0
- package/dist/generated/prisma/client.d.ts +39 -0
- package/dist/generated/prisma/commonInputTypes.d.ts +80 -50
- package/dist/generated/prisma/enums.d.ts +6 -0
- package/dist/generated/prisma/enums.js +5 -0
- package/dist/generated/prisma/internal/class.d.ts +77 -0
- package/dist/generated/prisma/internal/class.js +4 -4
- package/dist/generated/prisma/internal/prismaNamespace.d.ts +632 -1
- package/dist/generated/prisma/internal/prismaNamespace.js +94 -3
- package/dist/generated/prisma/internal/prismaNamespaceBrowser.d.ts +98 -0
- package/dist/generated/prisma/internal/prismaNamespaceBrowser.js +94 -3
- package/dist/generated/prisma/models/Agent.d.ts +372 -1
- package/dist/generated/prisma/models/AgentMcpServer.d.ts +1412 -0
- package/dist/generated/prisma/models/AgentMcpServer.js +1 -0
- package/dist/generated/prisma/models/AgentRepository.d.ts +40 -1
- package/dist/generated/prisma/models/AuthUser.d.ts +1 -1
- package/dist/generated/prisma/models/CodingAgentProfile.d.ts +0 -7
- package/dist/generated/prisma/models/CodingProxySession.d.ts +159 -2
- package/dist/generated/prisma/models/CodingRun.d.ts +76 -1
- package/dist/generated/prisma/models/HostProvider.d.ts +2176 -0
- package/dist/generated/prisma/models/HostProvider.js +1 -0
- package/dist/generated/prisma/models/HostProviderCredential.d.ts +1285 -0
- package/dist/generated/prisma/models/HostProviderCredential.js +1 -0
- package/dist/generated/prisma/models/HostProviderOperation.d.ts +1470 -0
- package/dist/generated/prisma/models/HostProviderOperation.js +1 -0
- package/dist/generated/prisma/models/HostProviderTrustedAccount.d.ts +1145 -0
- package/dist/generated/prisma/models/HostProviderTrustedAccount.js +1 -0
- package/dist/generated/prisma/models/McpServer.d.ts +1500 -0
- package/dist/generated/prisma/models/McpServer.js +1 -0
- package/dist/generated/prisma/models/NativeGatewayCall.d.ts +39 -1
- package/dist/generated/prisma/models/Principal.d.ts +167 -0
- package/dist/generated/prisma/models/ResourceGrant.d.ts +1 -1
- package/dist/generated/prisma/models/Run.d.ts +503 -1
- package/dist/generated/prisma/models/RunStep.d.ts +1453 -0
- package/dist/generated/prisma/models/RunStep.js +1 -0
- package/dist/generated/prisma/models/Secret.d.ts +387 -0
- package/dist/generated/prisma/models/Tool.d.ts +223 -1
- package/dist/generated/prisma/models.d.ts +7 -0
- package/dist/help/search.js +12 -3
- package/dist/help-index.json +812 -52
- package/dist/import/create.js +16 -1
- package/dist/import/neutral-schema.d.ts +1 -0
- package/dist/import/neutral-schema.js +2 -0
- package/dist/mcp/auth/access.d.ts +1 -0
- package/dist/mcp/auth/grants-cli.d.ts +6 -4
- package/dist/mcp/auth/grants-cli.js +42 -3
- package/dist/mcp/auth/resource-server.d.ts +3 -1
- package/dist/mcp/auth/resource-server.js +17 -2
- package/dist/mcp/context.d.ts +6 -0
- package/dist/mcp/index.js +21 -0
- package/dist/mcp/tools/agents.js +135 -20
- package/dist/mcp/tools/grants.js +66 -28
- package/dist/mcp/tools/host-providers.d.ts +2 -0
- package/dist/mcp/tools/host-providers.js +215 -0
- package/dist/mcp/tools/mcp-servers.d.ts +26 -0
- package/dist/mcp/tools/mcp-servers.js +481 -0
- package/dist/mcp/tools/power.js +2 -2
- package/dist/mcp/tools/repositories.js +50 -10
- package/dist/mcp/tools/run-access.d.ts +7 -0
- package/dist/mcp/tools/run-access.js +14 -0
- package/dist/mcp/tools/run-steps.d.ts +16 -0
- package/dist/mcp/tools/run-steps.js +85 -0
- package/dist/mcp/tools/runs.js +186 -18
- package/dist/mcp/tools/secret-elicitation.d.ts +2 -0
- package/dist/mcp/tools/secret-elicitation.js +25 -2
- package/dist/mcp/tools/secrets.js +38 -4
- package/dist/mcp/tools/tools.d.ts +6 -4
- package/dist/mcp/tools/tools.js +40 -8
- package/dist/mcp/tools/trigger.js +28 -6
- package/dist/mcp/transport/http-limits.d.ts +2 -0
- package/dist/mcp/transport/http-limits.js +6 -2
- package/dist/mcp/webhooks/ingress.js +10 -0
- package/dist/native-worker/gateway-server.d.ts +18 -1
- package/dist/native-worker/gateway-server.js +38 -9
- package/dist/native-worker/gateway.d.ts +5 -0
- package/dist/native-worker/gateway.js +13 -1
- package/dist/native-worker/http-server.d.ts +6 -4
- package/dist/native-worker/http-server.js +53 -8
- package/dist/native-worker/ledger.d.ts +6 -2
- package/dist/native-worker/ledger.js +5 -4
- package/dist/native-worker/protocol.d.ts +1 -0
- package/dist/native-worker/protocol.js +4 -1
- package/dist/providers/coding-proxy/capability-hash.d.ts +2 -0
- package/dist/providers/coding-proxy/capability-hash.js +5 -0
- package/dist/providers/coding-proxy/mcp-route.d.ts +34 -0
- package/dist/providers/coding-proxy/mcp-route.js +166 -0
- package/dist/providers/coding-proxy/memory-ledger.d.ts +2 -0
- package/dist/providers/coding-proxy/memory-ledger.js +16 -0
- package/dist/providers/coding-proxy/prisma-ledger.d.ts +1 -0
- package/dist/providers/coding-proxy/prisma-ledger.js +36 -2
- package/dist/providers/coding-proxy/proxy.d.ts +33 -1
- package/dist/providers/coding-proxy/proxy.js +266 -75
- package/dist/providers/coding-proxy/run-step-forwarder.d.ts +42 -0
- package/dist/providers/coding-proxy/run-step-forwarder.js +206 -0
- package/dist/providers/coding-proxy/runtime.d.ts +13 -0
- package/dist/providers/coding-proxy/runtime.js +87 -4
- package/dist/providers/coding-proxy/server.d.ts +3 -0
- package/dist/providers/coding-proxy/server.js +10 -0
- package/dist/providers/coding-proxy/types.d.ts +18 -0
- package/dist/providers/engine/types.d.ts +26 -0
- package/dist/providers/executor/composition.js +5 -0
- package/dist/providers/executor/container.d.ts +47 -1
- package/dist/providers/executor/container.js +84 -8
- package/dist/providers/host-tools/invoke.d.ts +37 -0
- package/dist/providers/host-tools/invoke.js +117 -0
- package/dist/providers/host-tools/operations.d.ts +65 -0
- package/dist/providers/host-tools/operations.js +149 -0
- package/dist/providers/host-tools/registry.d.ts +79 -0
- package/dist/providers/host-tools/registry.js +362 -0
- package/dist/providers/host-tools/request-guard.d.ts +17 -0
- package/dist/providers/host-tools/request-guard.js +99 -0
- package/dist/providers/host-tools/self-test.d.ts +64 -0
- package/dist/providers/host-tools/self-test.js +142 -0
- package/dist/providers/jobs/docker-isolation.d.ts +2 -2
- package/dist/providers/jobs/docker-isolation.js +30 -8
- package/dist/providers/jobs/docker.d.ts +8 -0
- package/dist/providers/jobs/docker.js +33 -9
- package/dist/providers/jobs/kubernetes-isolation.d.ts +3 -1
- package/dist/providers/jobs/kubernetes-isolation.js +15 -2
- package/dist/providers/jobs/kubernetes.d.ts +13 -0
- package/dist/providers/jobs/kubernetes.js +28 -5
- package/dist/providers/jobs/types.d.ts +6 -0
- package/dist/providers/review-host/types.d.ts +6 -4
- package/dist/providers/review-host/types.js +1 -1
- package/dist/providers/vcs/git.d.ts +6 -2
- package/dist/providers/vcs/git.js +15 -4
- package/dist/providers/vcs/github.d.ts +2 -71
- package/dist/providers/vcs/github.js +8 -11
- package/dist/providers/vcs/index.d.ts +1 -1
- package/dist/providers/vcs/index.js +1 -1
- package/dist/providers/vcs/remote.d.ts +4 -0
- package/dist/providers/vcs/remote.js +7 -1
- package/dist/providers/vcs/routing.d.ts +12 -6
- package/dist/providers/vcs/routing.js +21 -6
- package/dist/providers/vcs/types.d.ts +71 -3
- package/dist/quickstart/index.d.ts +5 -0
- package/dist/quickstart/index.js +21 -0
- package/dist/quickstart-images.json +1 -1
- package/dist/sandbox/eval-core.d.ts +12 -2
- package/dist/sandbox/eval-core.js +20 -4
- package/dist/sandbox/host-functions.d.ts +4 -0
- package/dist/sandbox/host-functions.js +7 -2
- package/dist/sandbox/limits.d.ts +4 -1
- package/dist/sandbox/limits.js +4 -1
- package/dist/sandbox/run-in-sandbox.d.ts +6 -0
- package/dist/sandbox/run-in-sandbox.js +14 -2
- package/dist/sandbox/safe-fetch.d.ts +2 -0
- package/dist/sandbox/safe-fetch.js +1 -1
- package/dist/sandbox/secret-broker.d.ts +1 -0
- package/dist/sandbox/secret-broker.js +1 -1
- package/dist/sandbox/tool-timeout.d.ts +32 -0
- package/dist/sandbox/tool-timeout.js +52 -0
- package/dist/sandbox/user-tool.d.ts +8 -1
- package/dist/sandbox/user-tool.js +9 -3
- package/dist/serve.js +26 -18
- package/dist/viewer/api-schema.d.ts +66 -0
- package/dist/viewer/api-schema.js +31 -0
- package/dist/viewer/graph.js +2 -1
- package/dist/viewer/openapi.js +2 -1
- package/dist/viewer/run-detail.d.ts +2 -0
- package/dist/viewer/run-detail.js +26 -1
- package/dist/wardby-bin.js +5 -0
- package/docs/README.md +11 -1
- package/docs/agent-recipes.md +37 -4
- package/docs/architecture-runtime.md +1 -1
- package/docs/claude-code-plugin.md +208 -0
- package/docs/code-review-agents.md +6 -0
- package/docs/coding-agent-setup.md +94 -1
- package/docs/coding-worker-byo-images.md +4 -2
- package/docs/coding-worker-isolation.md +94 -7
- package/docs/debugging-agent-runs.md +354 -0
- package/docs/getting-started-gke.md +33 -11
- package/docs/getting-started-identity-provider.md +43 -37
- package/docs/getting-started.md +8 -0
- package/docs/host-providers.md +294 -0
- package/docs/mcp-servers.md +263 -0
- package/docs/native-sandbox.md +31 -9
- package/docs/security-deployment.md +161 -31
- package/docs/tool-secrets.md +3 -0
- package/docs/viewer-api.md +27 -12
- package/docs/viewer-api.openapi.yml +78 -2
- package/help/admin-viewer.md +5 -0
- package/help/agent-recipes.md +33 -2
- package/help/brokered-secrets.md +3 -0
- package/help/build-claude-code-image.md +1 -1
- package/help/claude-code-run-pane.md +63 -0
- package/help/deploy-gke.md +5 -0
- package/help/errors/coding-mcp-gateway-unavailable.md +46 -0
- package/help/errors/coding-mcp-pins-unavailable.md +35 -0
- package/help/errors/database-auth-invalid.md +1 -1
- package/help/errors/host-provider-error.md +40 -0
- package/help/errors/host-tool-failed.md +36 -0
- package/help/errors/mcp-call-limit.md +22 -0
- package/help/errors/mcp-call-timeout.md +22 -0
- package/help/errors/mcp-server-auth-failed.md +22 -0
- package/help/errors/mcp-server-credentials-unavailable.md +45 -0
- package/help/errors/mcp-server-error.md +28 -0
- package/help/errors/mcp-server-unreachable.md +22 -0
- package/help/errors/mcp-servers-off.md +21 -0
- package/help/errors/mcp-tool-not-allowed.md +26 -0
- package/help/errors/mcp-tool-removed.md +22 -0
- package/help/errors/mcp-unavailable.md +25 -0
- package/help/errors/native-sandbox-capacity.md +1 -1
- package/help/errors/native-sandbox-deadline-exceeded.md +1 -1
- package/help/errors/native-sandbox-docker-failed.md +1 -1
- package/help/errors/native-sandbox-gateway-error.md +1 -1
- package/help/errors/native-sandbox-gateway-unavailable.md +1 -1
- package/help/errors/native-sandbox-image-not-pinned.md +1 -1
- package/help/errors/native-sandbox-isolation-mismatch.md +1 -1
- package/help/errors/native-sandbox-network-unenforced.md +1 -1
- package/help/errors/native-sandbox-requires-catalog.md +1 -1
- package/help/errors/native-sandbox-unavailable.md +1 -1
- package/help/errors/native-sandbox-warm-delivery-failed.md +1 -1
- package/help/errors/native-sandbox-worker-exited.md +1 -1
- package/help/errors/native-sandbox-worker-lost.md +1 -1
- package/help/errors/native-sandbox-worker-unready.md +1 -1
- package/help/errors/repository-not-listed.md +36 -0
- package/help/errors/repository-required.md +36 -0
- package/help/errors/tool-timeout.md +38 -0
- package/help/host-providers.md +40 -0
- package/help/identity-and-access.md +4 -1
- package/help/low-power-mode.md +19 -8
- package/help/mcp-servers.md +73 -0
- package/help/mcp.md +9 -2
- package/help/native-capabilities.md +27 -1
- package/help/native-sandbox.md +1 -1
- package/help/operating-agents.md +16 -0
- package/help/related-pull-requests.md +7 -3
- package/help/repo-instructions-and-skills.md +3 -1
- package/help/resume-runs-locally.md +7 -0
- package/help/run-steps.md +140 -0
- package/help/stop-run.md +1 -1
- package/help/troubleshooting/coding-workers.md +9 -1
- package/help/troubleshooting/debugging-agent-runs.md +29 -0
- package/package.json +2 -1
- package/prisma/migrations/20261102000000_host_providers/migration.sql +56 -0
- package/prisma/migrations/20261104000000_coding_agent_repository_list/migration.sql +5 -0
- package/prisma/migrations/20261106000000_tool_timeout/migration.sql +4 -0
- package/prisma/migrations/20261107000000_mcp_servers/migration.sql +33 -0
- package/prisma/migrations/20261108000000_coding_mcp/migration.sql +5 -0
- package/prisma/migrations/20261109000000_task_run_index/migration.sql +3 -0
- package/prisma/migrations/20261110000000_run_steps/migration.sql +26 -0
- package/prisma/migrations/20261111000000_run_steps_viewer_notify/migration.sql +59 -0
- package/prisma/migrations/20261112000000_run_steps_detail/migration.sql +9 -0
- package/prisma/schema.prisma +211 -14
- package/scripts/git-askpass.sh +1 -1
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registering a tool-backed host provider, approving its tools, and reading it back.
|
|
3
|
+
* Registering copies each tool's code and schema: Wardby only ever runs that copy, so
|
|
4
|
+
* update_tool changes nothing until approved, and a stray edit can't take a provider down.
|
|
5
|
+
* Views carry secret names and metadata, never a value.
|
|
6
|
+
*/
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { isValidHostProviderName } from "../../coding/code-host.js";
|
|
10
|
+
import { isExactHostname, parseSecretBrokerConfig } from "../../core/secret-broker-config.js";
|
|
11
|
+
import { MIN_WEBHOOK_SECRET_LENGTH } from "../../core/secrets.js";
|
|
12
|
+
import { HOST_TOOL_CONTRACT_VERSION, isHostOperation, REQUIRED_OPERATIONS } from "./operations.js";
|
|
13
|
+
export class HostProviderError extends Error {
|
|
14
|
+
code;
|
|
15
|
+
detail;
|
|
16
|
+
constructor(code, detail) {
|
|
17
|
+
super(`${code}: ${detail}`);
|
|
18
|
+
this.code = code;
|
|
19
|
+
this.detail = detail;
|
|
20
|
+
this.name = "HostProviderError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const HTTP_TOKEN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,128}$/;
|
|
24
|
+
const REPOSITORY_PATTERN = /^[a-z0-9][a-z0-9._-]{0,99}\/(?:\*|[a-z0-9][a-z0-9._-]{0,99})$/;
|
|
25
|
+
const SECRET_NAME = z.string().min(1).max(1024);
|
|
26
|
+
const hostname = z
|
|
27
|
+
.string()
|
|
28
|
+
.transform((h) => h.toLowerCase())
|
|
29
|
+
.refine(isExactHostname, "must be an exact hostname, with no scheme, port, wildcard or IP");
|
|
30
|
+
/** Each of `fields` once, and no other `{...}`. */
|
|
31
|
+
function hasExactlyFields(template, fields) {
|
|
32
|
+
const found = [...template.matchAll(/\{([^{}]*)\}/g)].map((m) => m[1]).sort();
|
|
33
|
+
return JSON.stringify(found) === JSON.stringify([...fields].sort());
|
|
34
|
+
}
|
|
35
|
+
const apiPath = (fields) => z
|
|
36
|
+
.string()
|
|
37
|
+
.max(512)
|
|
38
|
+
.startsWith("/")
|
|
39
|
+
.refine((p) => hasExactlyFields(p, fields), `must contain ${fields.map((f) => `{${f}}`).join(" and ")} once each`)
|
|
40
|
+
// eslint-disable-next-line no-control-regex
|
|
41
|
+
.refine((p) => !/[?#\\\x00-\x20\x7f]|%2f|%5c|\.\./i.test(p), "must be a plain path");
|
|
42
|
+
export const RegisterHostProviderSchema = z
|
|
43
|
+
.object({
|
|
44
|
+
provider: z.string().refine(isValidHostProviderName, "reserved or badly formed provider name"),
|
|
45
|
+
gitHost: hostname,
|
|
46
|
+
apiHosts: z.array(hostname).min(1).max(8),
|
|
47
|
+
// Refused: a name git's password prompt would contain, since scripts/git-askpass.sh answers by matching "Username".
|
|
48
|
+
gitUsername: z
|
|
49
|
+
.string()
|
|
50
|
+
.regex(/^[A-Za-z0-9._-]{1,64}$/)
|
|
51
|
+
.refine((u) => !/username/i.test(u), 'must not contain "username"'),
|
|
52
|
+
pullRequestUrlTemplate: z.string().max(512),
|
|
53
|
+
repositoryApiPath: apiPath(["repository"]),
|
|
54
|
+
permissionApiPath: apiPath(["workspace", "repo"]).optional(),
|
|
55
|
+
webhook: z
|
|
56
|
+
.object({
|
|
57
|
+
header: z.string().regex(HTTP_TOKEN),
|
|
58
|
+
algorithm: z.literal("sha256"),
|
|
59
|
+
deliveryHeader: z.string().regex(HTTP_TOKEN),
|
|
60
|
+
eventHeader: z.string().regex(HTTP_TOKEN),
|
|
61
|
+
secret: SECRET_NAME,
|
|
62
|
+
})
|
|
63
|
+
.strict(),
|
|
64
|
+
credentials: z
|
|
65
|
+
.array(z
|
|
66
|
+
.object({
|
|
67
|
+
repositories: z
|
|
68
|
+
.string()
|
|
69
|
+
.transform((r) => r.toLowerCase())
|
|
70
|
+
.refine((r) => REPOSITORY_PATTERN.test(r), 'must be "workspace/repo" or "workspace/*"'),
|
|
71
|
+
secret: SECRET_NAME,
|
|
72
|
+
})
|
|
73
|
+
.strict())
|
|
74
|
+
.min(1)
|
|
75
|
+
.max(32),
|
|
76
|
+
permissionCredential: SECRET_NAME.optional(),
|
|
77
|
+
trustedAccounts: z
|
|
78
|
+
.array(z.string().regex(/^[A-Za-z0-9:{}_-]{1,128}$/))
|
|
79
|
+
.max(256)
|
|
80
|
+
.optional(),
|
|
81
|
+
tools: z.record(z.string(), z.string().min(1).max(200)),
|
|
82
|
+
})
|
|
83
|
+
.strict()
|
|
84
|
+
.superRefine((input, ctx) => {
|
|
85
|
+
let template = null;
|
|
86
|
+
try {
|
|
87
|
+
template = new URL(input.pullRequestUrlTemplate.replaceAll(/[{}]/g, ""));
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// reported below
|
|
91
|
+
}
|
|
92
|
+
if (!template ||
|
|
93
|
+
template.protocol !== "https:" ||
|
|
94
|
+
template.hostname !== input.gitHost ||
|
|
95
|
+
template.username ||
|
|
96
|
+
template.password ||
|
|
97
|
+
template.port ||
|
|
98
|
+
!hasExactlyFields(input.pullRequestUrlTemplate, ["repository", "number"])) {
|
|
99
|
+
ctx.addIssue({
|
|
100
|
+
code: "custom",
|
|
101
|
+
path: ["pullRequestUrlTemplate"],
|
|
102
|
+
message: "must be an https URL on gitHost, with no credentials or port, and with {repository} and {number} once each",
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
if ((input.permissionCredential === undefined) !== (input.permissionApiPath === undefined)) {
|
|
106
|
+
ctx.addIssue({
|
|
107
|
+
code: "custom",
|
|
108
|
+
path: ["permissionApiPath"],
|
|
109
|
+
message: "permissionCredential and permissionApiPath go together",
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
const patterns = input.credentials.map((c) => c.repositories);
|
|
113
|
+
if (new Set(patterns).size !== patterns.length) {
|
|
114
|
+
ctx.addIssue({ code: "custom", path: ["credentials"], message: "a repository pattern is listed twice" });
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
export function findCredential(credentials, repositoryPath) {
|
|
118
|
+
const parts = repositoryPath.split("/");
|
|
119
|
+
if (parts.length !== 2)
|
|
120
|
+
return null;
|
|
121
|
+
return (credentials.find((c) => c.repositoryPattern === repositoryPath) ??
|
|
122
|
+
credentials.find((c) => c.repositoryPattern === `${parts[0]}/*`) ??
|
|
123
|
+
null);
|
|
124
|
+
}
|
|
125
|
+
const sha256 = (text) => createHash("sha256").update(text).digest("hex");
|
|
126
|
+
// A tool's timeoutMs is part of what is approved; a snapshot without one hashes as before.
|
|
127
|
+
const snapshotHash = (s) => sha256(`${s.paramsZod}\u0000${s.code}${s.timeoutMs === null ? "" : `\u0000${s.timeoutMs}`}`);
|
|
128
|
+
async function requireSecret(tx, ownerId, name) {
|
|
129
|
+
const secret = await tx.secret.findUnique({ where: { ownerId_name: { ownerId, name } } });
|
|
130
|
+
if (!secret)
|
|
131
|
+
throw new HostProviderError("host_provider_secret_not_found", `secret "${name}" not found`);
|
|
132
|
+
return secret;
|
|
133
|
+
}
|
|
134
|
+
function requireBroker(name, stored, hosts) {
|
|
135
|
+
if (stored === null || stored === undefined) {
|
|
136
|
+
throw new HostProviderError("host_provider_secret_not_brokered", `secret "${name}" must be brokered`);
|
|
137
|
+
}
|
|
138
|
+
const broker = parseSecretBrokerConfig(stored);
|
|
139
|
+
if (broker.placement.kind !== "header" && broker.placement.kind !== "query") {
|
|
140
|
+
throw new HostProviderError("host_provider_secret_placement", `secret "${name}" must be placed in a header or query parameter`);
|
|
141
|
+
}
|
|
142
|
+
const missing = hosts.filter((h) => !broker.hosts.includes(h));
|
|
143
|
+
if (missing.length > 0) {
|
|
144
|
+
throw new HostProviderError("host_provider_secret_hosts", `secret "${name}" must allow ${missing.join(", ")}`);
|
|
145
|
+
}
|
|
146
|
+
return broker;
|
|
147
|
+
}
|
|
148
|
+
export async function registerHostProvider(db, cipher, principalId, input) {
|
|
149
|
+
const bound = Object.keys(input.tools);
|
|
150
|
+
const unknown = bound.filter((name) => !isHostOperation(name));
|
|
151
|
+
if (unknown.length > 0) {
|
|
152
|
+
throw new HostProviderError("host_provider_operation_not_bound", `unknown operations: ${unknown.join(", ")}`);
|
|
153
|
+
}
|
|
154
|
+
const required = input.permissionCredential
|
|
155
|
+
? [...REQUIRED_OPERATIONS, "getRepositoryPermission"]
|
|
156
|
+
: REQUIRED_OPERATIONS;
|
|
157
|
+
const missing = required.filter((name) => !bound.includes(name));
|
|
158
|
+
if (missing.length > 0) {
|
|
159
|
+
throw new HostProviderError("host_provider_missing_operation", `missing operations: ${missing.join(", ")}`);
|
|
160
|
+
}
|
|
161
|
+
await db.$transaction(async (tx) => {
|
|
162
|
+
const credentials = [];
|
|
163
|
+
for (const credential of input.credentials) {
|
|
164
|
+
const secret = await requireSecret(tx, principalId, credential.secret);
|
|
165
|
+
requireBroker(credential.secret, secret.broker, [...input.apiHosts, input.gitHost]);
|
|
166
|
+
if ((await cipher.decrypt(secret.ciphertext)) === input.gitUsername) {
|
|
167
|
+
throw new HostProviderError("host_provider_username_is_secret", "gitUsername must not be a credential's value");
|
|
168
|
+
}
|
|
169
|
+
credentials.push({ repositoryPattern: credential.repositories, secretId: secret.id });
|
|
170
|
+
}
|
|
171
|
+
let permissionSecretId = null;
|
|
172
|
+
if (input.permissionCredential) {
|
|
173
|
+
const secret = await requireSecret(tx, principalId, input.permissionCredential);
|
|
174
|
+
requireBroker(input.permissionCredential, secret.broker, input.apiHosts);
|
|
175
|
+
permissionSecretId = secret.id;
|
|
176
|
+
if (credentials.some((c) => c.secretId === secret.id) ||
|
|
177
|
+
(await tx.hostProviderCredential.count({
|
|
178
|
+
where: { secretId: secret.id, provider: { not: input.provider } },
|
|
179
|
+
})) > 0) {
|
|
180
|
+
throw new HostProviderError("host_provider_permission_secret_shared", "the permission secret must not also be a credential of any provider, so no other operation can reach it");
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if ((await tx.hostProvider.count({
|
|
184
|
+
where: {
|
|
185
|
+
permissionSecretId: {
|
|
186
|
+
in: [...credentials.map((c) => c.secretId), ...(permissionSecretId ? [permissionSecretId] : [])],
|
|
187
|
+
},
|
|
188
|
+
name: { not: input.provider },
|
|
189
|
+
},
|
|
190
|
+
})) > 0) {
|
|
191
|
+
throw new HostProviderError("host_provider_permission_secret_shared", "another provider's permission secret must not be a credential or permission secret here");
|
|
192
|
+
}
|
|
193
|
+
const webhookSecret = await requireSecret(tx, principalId, input.webhook.secret);
|
|
194
|
+
if (credentials.some((c) => c.secretId === webhookSecret.id) ||
|
|
195
|
+
permissionSecretId === webhookSecret.id ||
|
|
196
|
+
(await tx.agentSecret.count({ where: { secretId: webhookSecret.id } })) > 0 ||
|
|
197
|
+
(await tx.hostProviderCredential.count({
|
|
198
|
+
where: { secretId: webhookSecret.id, provider: { not: input.provider } },
|
|
199
|
+
})) > 0 ||
|
|
200
|
+
(await tx.hostProvider.count({
|
|
201
|
+
where: { permissionSecretId: webhookSecret.id, name: { not: input.provider } },
|
|
202
|
+
})) > 0 ||
|
|
203
|
+
(await tx.hostProvider.count({
|
|
204
|
+
where: {
|
|
205
|
+
webhookSecretId: {
|
|
206
|
+
in: [...credentials.map((c) => c.secretId), ...(permissionSecretId ? [permissionSecretId] : [])],
|
|
207
|
+
},
|
|
208
|
+
name: { not: input.provider },
|
|
209
|
+
},
|
|
210
|
+
})) > 0) {
|
|
211
|
+
throw new HostProviderError("host_provider_webhook_secret_shared", "the webhook secret must not be shared with any other provider's credentials or attached to an agent, so no tool can read it");
|
|
212
|
+
}
|
|
213
|
+
if ((await cipher.decrypt(webhookSecret.ciphertext)).length < MIN_WEBHOOK_SECRET_LENGTH) {
|
|
214
|
+
throw new HostProviderError("host_provider_webhook_secret_short", `the webhook secret needs at least ${MIN_WEBHOOK_SECRET_LENGTH} characters`);
|
|
215
|
+
}
|
|
216
|
+
const operations = [];
|
|
217
|
+
for (const [operation, name] of Object.entries(input.tools)) {
|
|
218
|
+
const tool = await tx.tool.findUnique({ where: { ownerId_name: { ownerId: principalId, name } } });
|
|
219
|
+
if (!tool)
|
|
220
|
+
throw new HostProviderError("host_provider_tool_not_found", `tool "${name}" not found`);
|
|
221
|
+
operations.push({
|
|
222
|
+
operation,
|
|
223
|
+
toolId: tool.id,
|
|
224
|
+
code: tool.code,
|
|
225
|
+
paramsZod: tool.paramsZod,
|
|
226
|
+
timeoutMs: tool.timeoutMs,
|
|
227
|
+
approvedById: principalId,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
const { secret: _webhookSecretName, ...webhook } = input.webhook;
|
|
231
|
+
const fields = {
|
|
232
|
+
contractVersion: HOST_TOOL_CONTRACT_VERSION,
|
|
233
|
+
gitHost: input.gitHost,
|
|
234
|
+
apiHosts: input.apiHosts,
|
|
235
|
+
gitUsername: input.gitUsername,
|
|
236
|
+
pullRequestUrlTemplate: input.pullRequestUrlTemplate,
|
|
237
|
+
repositoryApiPath: input.repositoryApiPath,
|
|
238
|
+
permissionApiPath: input.permissionApiPath ?? null,
|
|
239
|
+
webhook,
|
|
240
|
+
webhookSecretId: webhookSecret.id,
|
|
241
|
+
permissionSecretId,
|
|
242
|
+
createdById: principalId,
|
|
243
|
+
};
|
|
244
|
+
await tx.hostProvider.upsert({
|
|
245
|
+
where: { name: input.provider },
|
|
246
|
+
create: { name: input.provider, ...fields },
|
|
247
|
+
update: fields,
|
|
248
|
+
});
|
|
249
|
+
await tx.hostProviderOperation.deleteMany({ where: { provider: input.provider } });
|
|
250
|
+
await tx.hostProviderCredential.deleteMany({ where: { provider: input.provider } });
|
|
251
|
+
await tx.hostProviderTrustedAccount.deleteMany({ where: { provider: input.provider } });
|
|
252
|
+
await tx.hostProviderOperation.createMany({ data: operations.map((o) => ({ provider: input.provider, ...o })) });
|
|
253
|
+
await tx.hostProviderCredential.createMany({
|
|
254
|
+
data: credentials.map((c) => ({ provider: input.provider, credentialKind: "static", ...c })),
|
|
255
|
+
});
|
|
256
|
+
await tx.hostProviderTrustedAccount.createMany({
|
|
257
|
+
data: [...new Set(input.trustedAccounts ?? [])].map((accountId) => ({
|
|
258
|
+
provider: input.provider,
|
|
259
|
+
accountId,
|
|
260
|
+
addedById: principalId,
|
|
261
|
+
})),
|
|
262
|
+
});
|
|
263
|
+
}, { isolationLevel: "Serializable" });
|
|
264
|
+
return (await getHostProviderView(db, input.provider));
|
|
265
|
+
}
|
|
266
|
+
export async function approveHostProviderTools(db, principalId, provider, operations) {
|
|
267
|
+
await db.$transaction(async (tx) => {
|
|
268
|
+
if (!(await tx.hostProvider.findUnique({ where: { name: provider } }))) {
|
|
269
|
+
throw new HostProviderError("host_provider_not_found", `provider "${provider}" not found`);
|
|
270
|
+
}
|
|
271
|
+
for (const operation of operations) {
|
|
272
|
+
const row = await tx.hostProviderOperation.findUnique({
|
|
273
|
+
where: { provider_operation: { provider, operation } },
|
|
274
|
+
include: { tool: true },
|
|
275
|
+
});
|
|
276
|
+
if (!row)
|
|
277
|
+
throw new HostProviderError("host_provider_operation_not_bound", `"${operation}" isn't bound`);
|
|
278
|
+
if (!row.tool)
|
|
279
|
+
throw new HostProviderError("host_provider_tool_deleted", `"${operation}"'s tool was deleted`);
|
|
280
|
+
await tx.hostProviderOperation.update({
|
|
281
|
+
where: { provider_operation: { provider, operation } },
|
|
282
|
+
data: {
|
|
283
|
+
code: row.tool.code,
|
|
284
|
+
paramsZod: row.tool.paramsZod,
|
|
285
|
+
timeoutMs: row.tool.timeoutMs,
|
|
286
|
+
approvedById: principalId,
|
|
287
|
+
approvedAt: new Date(),
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}, { isolationLevel: "Serializable" });
|
|
292
|
+
return (await getHostProviderView(db, provider));
|
|
293
|
+
}
|
|
294
|
+
export async function getHostProviderView(db, provider, options = {}) {
|
|
295
|
+
const row = await db.hostProvider.findUnique({
|
|
296
|
+
where: { name: provider },
|
|
297
|
+
include: {
|
|
298
|
+
webhookSecret: { select: { name: true } },
|
|
299
|
+
permissionSecret: { select: { name: true } },
|
|
300
|
+
credentials: { include: { secret: { select: { name: true } } }, orderBy: { repositoryPattern: "asc" } },
|
|
301
|
+
trustedAccounts: { orderBy: { accountId: "asc" } },
|
|
302
|
+
operations: { include: { tool: true }, orderBy: { operation: "asc" } },
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
if (!row)
|
|
306
|
+
return null;
|
|
307
|
+
return {
|
|
308
|
+
provider: row.name,
|
|
309
|
+
contractVersion: row.contractVersion,
|
|
310
|
+
gitHost: row.gitHost,
|
|
311
|
+
apiHosts: row.apiHosts,
|
|
312
|
+
gitUsername: row.gitUsername,
|
|
313
|
+
pullRequestUrlTemplate: row.pullRequestUrlTemplate,
|
|
314
|
+
repositoryApiPath: row.repositoryApiPath,
|
|
315
|
+
permissionApiPath: row.permissionApiPath,
|
|
316
|
+
mode: row.permissionSecretId ? "permission" : "allowlist",
|
|
317
|
+
webhook: row.webhook,
|
|
318
|
+
webhookSecret: row.webhookSecret.name,
|
|
319
|
+
permissionSecret: row.permissionSecret?.name ?? null,
|
|
320
|
+
credentials: row.credentials.map((c) => ({
|
|
321
|
+
repositories: c.repositoryPattern,
|
|
322
|
+
secret: c.secret.name,
|
|
323
|
+
kind: c.credentialKind,
|
|
324
|
+
})),
|
|
325
|
+
trustedAccounts: row.trustedAccounts.map((a) => a.accountId),
|
|
326
|
+
operations: row.operations.map((o) => {
|
|
327
|
+
const approvedSha256 = snapshotHash(o);
|
|
328
|
+
const currentSha256 = o.tool ? snapshotHash(o.tool) : null;
|
|
329
|
+
return {
|
|
330
|
+
operation: o.operation,
|
|
331
|
+
toolName: o.tool?.name ?? null,
|
|
332
|
+
status: !o.tool ? "tool_deleted" : currentSha256 === approvedSha256 ? "approved" : "changed",
|
|
333
|
+
approvedAt: o.approvedAt,
|
|
334
|
+
approvedById: o.approvedById,
|
|
335
|
+
approvedSha256,
|
|
336
|
+
currentSha256,
|
|
337
|
+
approvedTimeoutMs: o.timeoutMs,
|
|
338
|
+
currentTimeoutMs: o.tool?.timeoutMs ?? null,
|
|
339
|
+
...(options.includeCode
|
|
340
|
+
? {
|
|
341
|
+
approvedCode: o.code,
|
|
342
|
+
approvedParamsZod: o.paramsZod,
|
|
343
|
+
...(o.tool && currentSha256 !== approvedSha256
|
|
344
|
+
? { currentCode: o.tool.code, currentParamsZod: o.tool.paramsZod }
|
|
345
|
+
: {}),
|
|
346
|
+
}
|
|
347
|
+
: {}),
|
|
348
|
+
};
|
|
349
|
+
}),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
export async function deleteHostProvider(db, provider) {
|
|
353
|
+
await db.$transaction(async (tx) => {
|
|
354
|
+
if (!(await tx.hostProvider.findUnique({ where: { name: provider } }))) {
|
|
355
|
+
throw new HostProviderError("host_provider_not_found", `provider "${provider}" not found`);
|
|
356
|
+
}
|
|
357
|
+
const links = await tx.agentRepository.count({ where: { provider } });
|
|
358
|
+
if (links > 0)
|
|
359
|
+
throw new HostProviderError("host_provider_in_use", `${links} agent link(s) use this provider`);
|
|
360
|
+
await tx.hostProvider.delete({ where: { name: provider } });
|
|
361
|
+
}, { isolationLevel: "Serializable" });
|
|
362
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where one bound tool's request may go: the provider's API hosts, inside the path of
|
|
3
|
+
* the repository this call is for, using the operation's own methods. Wardby picks
|
|
4
|
+
* the repository, so a tool can't point a token at another one. Pure.
|
|
5
|
+
*/
|
|
6
|
+
export declare const MAX_HOST_TOOL_FETCHES = 3;
|
|
7
|
+
export interface HostToolRequestScope {
|
|
8
|
+
apiHosts: readonly string[];
|
|
9
|
+
pathPrefix: string;
|
|
10
|
+
methods: readonly string[];
|
|
11
|
+
}
|
|
12
|
+
export declare class HostToolRequestDenied extends Error {
|
|
13
|
+
constructor();
|
|
14
|
+
}
|
|
15
|
+
export declare function assertHostToolRequest(url: string, method: string, scope: HostToolRequestScope): void;
|
|
16
|
+
/** Request headers a bound tool may set: no override, routing or credential headers. */
|
|
17
|
+
export declare function assertHostToolHeaders(headers: unknown): void;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where one bound tool's request may go: the provider's API hosts, inside the path of
|
|
3
|
+
* the repository this call is for, using the operation's own methods. Wardby picks
|
|
4
|
+
* the repository, so a tool can't point a token at another one. Pure.
|
|
5
|
+
*/
|
|
6
|
+
export const MAX_HOST_TOOL_FETCHES = 3;
|
|
7
|
+
export class HostToolRequestDenied extends Error {
|
|
8
|
+
constructor() {
|
|
9
|
+
super("host_tool_request_denied");
|
|
10
|
+
this.name = "HostToolRequestDenied";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export function assertHostToolRequest(url, method, scope) {
|
|
14
|
+
let parsed;
|
|
15
|
+
try {
|
|
16
|
+
parsed = new URL(url);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
throw new HostToolRequestDenied();
|
|
20
|
+
}
|
|
21
|
+
if (parsed.protocol !== "https:" || parsed.port !== "" || parsed.username || parsed.password) {
|
|
22
|
+
throw new HostToolRequestDenied();
|
|
23
|
+
}
|
|
24
|
+
if (!scope.apiHosts.includes(parsed.hostname.toLowerCase()))
|
|
25
|
+
throw new HostToolRequestDenied();
|
|
26
|
+
if (!scope.methods.includes(method.toUpperCase()))
|
|
27
|
+
throw new HostToolRequestDenied();
|
|
28
|
+
// Some frameworks read the method from these, which would turn a checked GET into a write.
|
|
29
|
+
for (const key of parsed.searchParams.keys()) {
|
|
30
|
+
const name = key.toLowerCase();
|
|
31
|
+
if (name === "_method" || name.startsWith("x-http-method"))
|
|
32
|
+
throw new HostToolRequestDenied();
|
|
33
|
+
}
|
|
34
|
+
const prefix = scope.pathPrefix.toLowerCase();
|
|
35
|
+
if (!prefix.startsWith("/"))
|
|
36
|
+
throw new HostToolRequestDenied();
|
|
37
|
+
// The repository is matched literally on the raw path; decoding is only for the segment rules.
|
|
38
|
+
const raw = parsed.pathname.toLowerCase();
|
|
39
|
+
if (raw !== prefix && !raw.startsWith(`${prefix}/`))
|
|
40
|
+
throw new HostToolRequestDenied();
|
|
41
|
+
assertSafeSegments(parsed.pathname);
|
|
42
|
+
}
|
|
43
|
+
const MAX_DECODE_ROUNDS = 8;
|
|
44
|
+
const ALLOWED_HEADERS = ["accept", "content-type", "if-match", "if-none-match"];
|
|
45
|
+
const utf8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
46
|
+
/** Decodes every run of valid %XX escapes as UTF-8 bytes; a stray % stays literal. */
|
|
47
|
+
function decodeOnce(text) {
|
|
48
|
+
return text.replace(/(?:%[0-9a-f]{2})+/gi, (run) => {
|
|
49
|
+
const bytes = Uint8Array.from(run.match(/%([0-9a-f]{2})/gi) ?? [], (hex) => parseInt(hex.slice(1), 16));
|
|
50
|
+
return utf8.decode(bytes);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The path as a server may finally read it: decoded and NFKC-normalized together to
|
|
55
|
+
* stable (a fullwidth percent sign becomes a real one), then judged per segment. Servers and proxies disagree on what a segment "is" (encoded or
|
|
56
|
+
* Unicode lookalike separators, stripped whitespace or format characters, trailing
|
|
57
|
+
* delimiters), so a segment that is only dots once those are removed is refused, and so
|
|
58
|
+
* is anything that changes the segment count. Ordinary names with odd characters pass.
|
|
59
|
+
*/
|
|
60
|
+
function assertSafeSegments(pathname) {
|
|
61
|
+
let current = pathname;
|
|
62
|
+
for (let round = 0;; round++) {
|
|
63
|
+
if (round > MAX_DECODE_ROUNDS)
|
|
64
|
+
throw new HostToolRequestDenied();
|
|
65
|
+
let next;
|
|
66
|
+
try {
|
|
67
|
+
next = decodeOnce(current).normalize("NFKC");
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
throw new HostToolRequestDenied();
|
|
71
|
+
}
|
|
72
|
+
if (next === current)
|
|
73
|
+
break;
|
|
74
|
+
current = next;
|
|
75
|
+
}
|
|
76
|
+
// eslint-disable-next-line no-control-regex
|
|
77
|
+
if (/[\\\u0000\u2215\u2044]/.test(current))
|
|
78
|
+
throw new HostToolRequestDenied();
|
|
79
|
+
const segments = current.split("/");
|
|
80
|
+
if (segments.length !== pathname.split("/").length)
|
|
81
|
+
throw new HostToolRequestDenied();
|
|
82
|
+
for (const segment of segments) {
|
|
83
|
+
// % is stripped too: a stray % must not turn "..%" into a passing name.
|
|
84
|
+
const bare = segment.replace(/[\p{Z}\p{C}%]/gu, "");
|
|
85
|
+
if (/^\.+(?:[;?#,:].*)?$/.test(bare))
|
|
86
|
+
throw new HostToolRequestDenied();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** Request headers a bound tool may set: no override, routing or credential headers. */
|
|
90
|
+
export function assertHostToolHeaders(headers) {
|
|
91
|
+
if (headers === undefined)
|
|
92
|
+
return;
|
|
93
|
+
if (typeof headers !== "object" || headers === null || Array.isArray(headers))
|
|
94
|
+
throw new HostToolRequestDenied();
|
|
95
|
+
for (const name of Object.keys(headers)) {
|
|
96
|
+
if (!ALLOWED_HEADERS.includes(name.toLowerCase()))
|
|
97
|
+
throw new HostToolRequestDenied();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calling a provider's approved snapshots, and the read-only checks test_host_provider reports.
|
|
3
|
+
* A report holds an operation, its repository, an outcome and a fixed code, so it can be shared
|
|
4
|
+
* from a team whose host we can't reach, carrying nothing else.
|
|
5
|
+
*/
|
|
6
|
+
import type { PrismaClient, Secret } from "#prisma";
|
|
7
|
+
import { safeFetch } from "../../sandbox/safe-fetch.js";
|
|
8
|
+
import type { SecretCipher } from "../secrets/types.js";
|
|
9
|
+
import { type HostToolResult } from "./invoke.js";
|
|
10
|
+
import { type HostOperation } from "./operations.js";
|
|
11
|
+
type StoredSecret = Pick<Secret, "ciphertext" | "broker">;
|
|
12
|
+
export interface LoadedProvider {
|
|
13
|
+
name: string;
|
|
14
|
+
apiHosts: string[];
|
|
15
|
+
repositoryApiPath: string;
|
|
16
|
+
permissionApiPath: string | null;
|
|
17
|
+
permissionSecret: StoredSecret | null;
|
|
18
|
+
credentials: {
|
|
19
|
+
repositoryPattern: string;
|
|
20
|
+
secret: StoredSecret;
|
|
21
|
+
}[];
|
|
22
|
+
operations: {
|
|
23
|
+
operation: string;
|
|
24
|
+
code: string;
|
|
25
|
+
paramsZod: string;
|
|
26
|
+
timeoutMs?: number | null;
|
|
27
|
+
}[];
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The fetch for bound tools: the real one, or under WARDBY_TEST_SIMULATORS the simulator's. Under
|
|
31
|
+
* the switch only `sim-` credentials are sent, as the switch refuses every other real credential.
|
|
32
|
+
*/
|
|
33
|
+
export declare function hostToolFetch(env?: NodeJS.ProcessEnv): {
|
|
34
|
+
fetchImpl?: typeof safeFetch;
|
|
35
|
+
simulated: boolean;
|
|
36
|
+
};
|
|
37
|
+
export declare function loadProviderForCalls(db: PrismaClient, name: string): Promise<LoadedProvider | null>;
|
|
38
|
+
/** The repository is compared in its normalized form, so callers must pass one. */
|
|
39
|
+
export declare function callHostOperation(provider: LoadedProvider, cipher: SecretCipher, repository: string, operation: HostOperation, fields: Record<string, unknown>, options?: {
|
|
40
|
+
fetchImpl?: typeof safeFetch;
|
|
41
|
+
simulated?: boolean;
|
|
42
|
+
}): Promise<HostToolResult>;
|
|
43
|
+
export interface SelfTestInput {
|
|
44
|
+
repository: string;
|
|
45
|
+
pullRequest?: number;
|
|
46
|
+
headRef?: string;
|
|
47
|
+
ref?: string;
|
|
48
|
+
accountId?: string;
|
|
49
|
+
}
|
|
50
|
+
export interface SelfTestReport {
|
|
51
|
+
provider: string;
|
|
52
|
+
repository: string;
|
|
53
|
+
mode: "permission" | "allowlist";
|
|
54
|
+
results: {
|
|
55
|
+
operation: string;
|
|
56
|
+
outcome: "pass" | "fail" | "skipped";
|
|
57
|
+
code?: string;
|
|
58
|
+
}[];
|
|
59
|
+
}
|
|
60
|
+
export declare function selfTestHostProvider(provider: LoadedProvider, cipher: SecretCipher, input: SelfTestInput, options?: {
|
|
61
|
+
fetchImpl?: typeof safeFetch;
|
|
62
|
+
simulated?: boolean;
|
|
63
|
+
}): Promise<SelfTestReport>;
|
|
64
|
+
export {};
|