diff --git a/.changeset/freeze-candidate-model-gateway.md b/.changeset/freeze-candidate-model-gateway.md new file mode 100644 index 0000000..31da845 --- /dev/null +++ b/.changeset/freeze-candidate-model-gateway.md @@ -0,0 +1,5 @@ +--- +"@tangle-network/agent-interface": minor +--- + +Bind each model-calling candidate execution plan to an exact evaluator-approved gateway domain allowlist while preserving disabled general network access. diff --git a/packages/agent-interface/src/agent-candidate-execution-plan-schema.test.ts b/packages/agent-interface/src/agent-candidate-execution-plan-schema.test.ts index 11d7376..aff8ed8 100644 --- a/packages/agent-interface/src/agent-candidate-execution-plan-schema.test.ts +++ b/packages/agent-interface/src/agent-candidate-execution-plan-schema.test.ts @@ -105,6 +105,10 @@ function planFixture() { access: { kind: "evaluator-mediated" as const, grantDigest: candidateSha("9"), + network: { + mode: "gateway-only" as const, + domains: ["router.tangle.tools"], + }, }, routes: [ { kind: "primary" as const, requested: "openai/gpt-5.4" }, @@ -270,6 +274,65 @@ describe("agentCandidateExecutionPlanMaterialSchema", () => { expect(() => agentCandidateResolvedModelSchema.parse(withoutEffort)).toThrow(); }); + it("freezes only exact model-gateway domains and disables them for zero-call plans", () => { + const plan = planFixture(); + for (const domain of [ + "*.tangle.tools", + ".tangle.tools", + "https://router.tangle.tools", + "ROUTER.TANGLE.TOOLS", + "localhost", + "10.0.0.1", + ]) { + expect(() => + agentCandidateExecutionPlanMaterialSchema.parse({ + ...plan, + model: { + ...plan.model, + access: { + ...plan.model.access, + network: { mode: "gateway-only", domains: [domain] }, + }, + }, + }), + ).toThrow(); + } + expect(() => + agentCandidateExecutionPlanMaterialSchema.parse({ + ...plan, + model: { + ...plan.model, + access: { + ...plan.model.access, + network: { + mode: "gateway-only", + domains: ["z.example.com", "a.example.com"], + }, + }, + }, + }), + ).toThrow(/lexicographically sorted/); + expect(() => + agentCandidateExecutionPlanMaterialSchema.parse({ + ...plan, + limits: { ...plan.limits, maxModelCalls: 0 }, + model: { + ...plan.model, + access: { ...plan.model.access, network: { mode: "disabled" } }, + }, + }), + ).not.toThrow(); + expect(() => + agentCandidateExecutionPlanMaterialSchema.parse({ + ...plan, + model: { + ...plan.model, + access: { ...plan.model.access, network: { mode: "disabled" } }, + }, + }), + ).toThrow(/require one frozen gateway allowlist/); + }); + it("freezes counted attempts, retry policy, and tool-loop steps", () => { const plan = planFixture(); expect(() => diff --git a/packages/agent-interface/src/agent-candidate-execution-plan-schema.ts b/packages/agent-interface/src/agent-candidate-execution-plan-schema.ts index f167c60..6b16b2c 100644 --- a/packages/agent-interface/src/agent-candidate-execution-plan-schema.ts +++ b/packages/agent-interface/src/agent-candidate-execution-plan-schema.ts @@ -4,6 +4,7 @@ import type { AgentCandidateExecutionLimits, AgentCandidateExecutionPlanEvidence, AgentCandidateExecutionPlanMaterialV1, + AgentCandidateModelAccessNetwork, AgentCandidateProfilePlanEvidence, AgentCandidateProfilePlanMaterialV1, AgentCandidateResolvedModel, @@ -23,6 +24,7 @@ import { environmentConfigSchema, gitObjectSchema, isCanonicalJsonValue, + isObviouslyPrivateHostname, isSafeExecutable, isSafeRelativePath, sameGitObjectFormat, @@ -53,6 +55,48 @@ function pathsOverlap(left: string, right: string): boolean { return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`); } +function isExactPublicGatewayDomain(value: string): boolean { + if ( + value.length > 253 || + value !== value.toLowerCase() || + value.endsWith(".") || + value.includes(":") || + isObviouslyPrivateHostname(value) + ) { + return false; + } + const labels = value.split("."); + return ( + labels.length >= 2 && + labels.every( + (label) => + label.length >= 1 && + label.length <= 63 && + /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label), + ) && + /[a-z]/.test(labels.at(-1) ?? "") + ); +} + +export const agentCandidateModelAccessNetworkSchema = z.discriminatedUnion("mode", [ + z.object({ mode: z.literal("disabled") }).strict(), + z + .object({ + mode: z.literal("gateway-only"), + domains: z + .array( + z + .string() + .refine( + isExactPublicGatewayDomain, + "model gateway must be an exact lowercase public DNS name", + ), + ) + .min(1), + }) + .strict(), +]) satisfies z.ZodType; + export const agentCandidateResolvedModelSchema = z .object({ requested: z.string().min(1), @@ -255,6 +299,7 @@ export const agentCandidateExecutionPlanMaterialSchema = z .object({ kind: z.literal("evaluator-mediated"), grantDigest: sha256DigestSchema, + network: agentCandidateModelAccessNetworkSchema, }) .strict(), routes: z @@ -379,6 +424,37 @@ export const agentCandidateExecutionPlanMaterialSchema = z }); } } + const modelNetwork = material.model.access.network; + if (material.limits.maxModelCalls === 0 && modelNetwork.mode !== "disabled") { + ctx.addIssue({ + code: "custom", + path: ["model", "access", "network"], + message: "zero-call plans cannot expose a model gateway", + }); + } + if (material.limits.maxModelCalls > 0 && modelNetwork.mode !== "gateway-only") { + ctx.addIssue({ + code: "custom", + path: ["model", "access", "network"], + message: "model-calling plans require one frozen gateway allowlist", + }); + } + if (modelNetwork.mode === "gateway-only") { + addDuplicateIssues( + modelNetwork.domains, + ["model", "access", "network", "domains"], + ctx, + ); + for (let index = 1; index < modelNetwork.domains.length; index++) { + if ((modelNetwork.domains[index - 1] ?? "") >= (modelNetwork.domains[index] ?? "")) { + ctx.addIssue({ + code: "custom", + path: ["model", "access", "network", "domains", index], + message: "model gateway domains must be lexicographically sorted", + }); + } + } + } const activeCode = material.codeKind !== "disabled"; if (activeCode !== (material.candidateWorkspace !== undefined)) { ctx.addIssue({ diff --git a/packages/agent-interface/src/agent-candidate-schema.test.ts b/packages/agent-interface/src/agent-candidate-schema.test.ts index 75a38f4..f250236 100644 --- a/packages/agent-interface/src/agent-candidate-schema.test.ts +++ b/packages/agent-interface/src/agent-candidate-schema.test.ts @@ -421,6 +421,10 @@ describe("candidate receipts", () => { access: { kind: "evaluator-mediated" as const, grantDigest: candidateSha("d"), + network: { + mode: "gateway-only" as const, + domains: ["router.tangle.tools"], + }, }, routes: [{ kind: "primary" as const, requested: "openai/gpt-5.4" }], }, diff --git a/packages/agent-interface/src/agent-candidate.ts b/packages/agent-interface/src/agent-candidate.ts index e82c970..218a5eb 100644 --- a/packages/agent-interface/src/agent-candidate.ts +++ b/packages/agent-interface/src/agent-candidate.ts @@ -453,6 +453,11 @@ export interface AgentCandidateProfileApplication { mountPaths: string[]; } +/** Evaluator-owned network exception for the one frozen model gateway. */ +export type AgentCandidateModelAccessNetwork = + | { mode: "disabled" } + | { mode: "gateway-only"; domains: string[] }; + /** * Canonical, digest-free per-task execution identity document. * @@ -506,6 +511,7 @@ export interface AgentCandidateExecutionPlanMaterialV1 { access: { kind: "evaluator-mediated"; grantDigest: Sha256Digest; + network: AgentCandidateModelAccessNetwork; }; routes: Array< | { kind: "primary"; requested?: string }