Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/freeze-candidate-model-gateway.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -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(() =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
AgentCandidateExecutionLimits,
AgentCandidateExecutionPlanEvidence,
AgentCandidateExecutionPlanMaterialV1,
AgentCandidateModelAccessNetwork,
AgentCandidateProfilePlanEvidence,
AgentCandidateProfilePlanMaterialV1,
AgentCandidateResolvedModel,
Expand All @@ -23,6 +24,7 @@ import {
environmentConfigSchema,
gitObjectSchema,
isCanonicalJsonValue,
isObviouslyPrivateHostname,
isSafeExecutable,
isSafeRelativePath,
sameGitObjectFormat,
Expand Down Expand Up @@ -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<AgentCandidateModelAccessNetwork>;

export const agentCandidateResolvedModelSchema = z
.object({
requested: z.string().min(1),
Expand Down Expand Up @@ -255,6 +299,7 @@ export const agentCandidateExecutionPlanMaterialSchema = z
.object({
kind: z.literal("evaluator-mediated"),
grantDigest: sha256DigestSchema,
network: agentCandidateModelAccessNetworkSchema,
})
.strict(),
routes: z
Expand Down Expand Up @@ -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({
Expand Down
4 changes: 4 additions & 0 deletions packages/agent-interface/src/agent-candidate-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }],
},
Expand Down
6 changes: 6 additions & 0 deletions packages/agent-interface/src/agent-candidate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -506,6 +511,7 @@ export interface AgentCandidateExecutionPlanMaterialV1 {
access: {
kind: "evaluator-mediated";
grantDigest: Sha256Digest;
network: AgentCandidateModelAccessNetwork;
};
routes: Array<
| { kind: "primary"; requested?: string }
Expand Down
Loading