diff --git a/.changeset/strict-agent-profile.md b/.changeset/strict-agent-profile.md new file mode 100644 index 0000000..d56b091 --- /dev/null +++ b/.changeset/strict-agent-profile.md @@ -0,0 +1,5 @@ +--- +"@tangle-network/agent-interface": minor +--- + +Reject unknown fields at every defined AgentProfile object boundary, make profile and candidate MCP definitions exact local, remote, or disabled configurations, align reasoning capabilities with the native CLI controls, and bind exact system-prompt replacement to candidate profile-plan identity. 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 20c6e83..8476210 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 @@ -287,6 +287,27 @@ describe("agentCandidateExecutionPlanMaterialSchema", () => { ).toThrow(/lexicographically sorted/); }); + it("carries an exact typed system-prompt replacement in profile-plan identity", () => { + const material = { + sourceProfileDigest: candidateSha("0"), + harness: "claude-code", + files: [], + env: {}, + flags: [], + systemPrompt: { kind: "public", value: "Use the repository rules." }, + unsupported: [], + }; + expect(agentCandidateProfilePlanMaterialSchema.parse(material)).toEqual( + material, + ); + expect(() => + agentCandidateProfilePlanMaterialSchema.parse({ + ...material, + systemPrompt: "Use the repository rules.", + }), + ).toThrow(); + }); + it("requires a workspace root for candidate-targeted profile files", () => { 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 31b5fd2..c2d012d 100644 --- a/packages/agent-interface/src/agent-candidate-execution-plan-schema.ts +++ b/packages/agent-interface/src/agent-candidate-execution-plan-schema.ts @@ -186,6 +186,7 @@ export const agentCandidateProfilePlanMaterialSchema = z ), env: environmentConfigSchema, flags: z.array(agentCandidateConfigValueSchema), + systemPrompt: agentCandidateConfigValueSchema.optional(), unsupported: z .array( z diff --git a/packages/agent-interface/src/agent-candidate-profile-schema.ts b/packages/agent-interface/src/agent-candidate-profile-schema.ts index 7795ed1..16ec7b8 100644 --- a/packages/agent-interface/src/agent-candidate-profile-schema.ts +++ b/packages/agent-interface/src/agent-candidate-profile-schema.ts @@ -27,46 +27,35 @@ const executableSchema = z .string() .refine(isSafeExecutable, "executable must be a canonical non-shell command"); -export const agentCandidateMcpServerSchema = z - .object({ - transport: z.literal("stdio").optional(), - command: executableSchema.optional(), - args: z.array(agentCandidateConfigValueSchema).optional(), - env: environmentConfigSchema.optional(), - cwd: z - .string() - .refine( - (value) => isSafeRelativePath(value, true), - "MCP cwd must be a canonical workspace-relative path", - ) - .optional(), - enabled: z.boolean().optional(), - }) - .strict() - .superRefine((server, ctx) => { - if (server.enabled === false) { - if ( - server.transport !== undefined || - server.command !== undefined || - server.args !== undefined || - server.env !== undefined || - server.cwd !== undefined - ) { - ctx.addIssue({ - code: "custom", - message: "disabled MCP servers cannot carry process fields", - }); - } - return; - } - if (server.command === undefined) { - ctx.addIssue({ - code: "custom", - path: ["command"], - message: "enabled stdio MCP servers require a command", - }); - } - }) satisfies z.ZodType; +const agentCandidateLocalMcpServerSchema = z.strictObject({ + transport: z.literal("stdio").optional(), + command: executableSchema, + args: z.array(agentCandidateConfigValueSchema).optional(), + env: environmentConfigSchema.optional(), + cwd: z + .string() + .refine( + (value) => isSafeRelativePath(value, true), + "MCP cwd must be a canonical workspace-relative path", + ) + .optional(), + enabled: z.literal(true).optional(), +}); + +const agentCandidateDisabledMcpServerSchema = z.strictObject({ + enabled: z.literal(false), + transport: z.undefined().optional(), + command: z.undefined().optional(), + args: z.undefined().optional(), + env: z.undefined().optional(), + cwd: z.undefined().optional(), +}); + +export const agentCandidateMcpServerSchema: z.ZodType = + z.union([ + agentCandidateLocalMcpServerSchema, + agentCandidateDisabledMcpServerSchema, + ]); export const agentCandidateHookCommandSchema = z .object({ diff --git a/packages/agent-interface/src/agent-candidate.ts b/packages/agent-interface/src/agent-candidate.ts index b59a1ad..0c78fc8 100644 --- a/packages/agent-interface/src/agent-candidate.ts +++ b/packages/agent-interface/src/agent-candidate.ts @@ -2,7 +2,6 @@ import type { AgentProfile, AgentProfileFileMount, AgentProfileHookCommand, - AgentProfileMcpServer, AgentProfileMode, AgentProfileModelHints, AgentProfileResourceRef, @@ -128,16 +127,28 @@ export interface AgentCandidateResources failOnError: true; } -export interface AgentCandidateMcpServer - extends Omit< - AgentProfileMcpServer, - "transport" | "args" | "env" | "headers" | "url" | "metadata" - > { +interface AgentCandidateLocalMcpServer { transport?: "stdio"; + command: string; args?: AgentCandidateConfigValue[]; env?: Record; + cwd?: string; + enabled?: true; } +interface AgentCandidateDisabledMcpServer { + enabled: false; + transport?: never; + command?: never; + args?: never; + env?: never; + cwd?: never; +} + +export type AgentCandidateMcpServer = + | AgentCandidateLocalMcpServer + | AgentCandidateDisabledMcpServer; + export type AgentCandidateModelHints = Omit; export type AgentCandidateSubagentProfile = Omit< AgentSubagentProfile, @@ -398,6 +409,8 @@ export interface AgentCandidateProfilePlanMaterial { }>; env: Record; flags: AgentCandidateConfigValue[]; + /** Exact system-prompt replacement supplied to the harness, when supported. */ + systemPrompt?: AgentCandidateConfigValue; unsupported: Array<{ dimension: string; reason: string }>; } diff --git a/packages/agent-interface/src/agent-profile.ts b/packages/agent-interface/src/agent-profile.ts index 14fb4a9..3a03f89 100644 --- a/packages/agent-interface/src/agent-profile.ts +++ b/packages/agent-interface/src/agent-profile.ts @@ -117,9 +117,9 @@ export interface AgentProfileResources { * - `none` — extended thinking OFF (no reasoning budget at all) * - `minimal` — thinking ON, the lowest budget (distinct from `none`) * - `low` / `medium` / `high` / `xhigh` - * - `ultracode` — maximum (claude-code's "ultracode" run mode; codex's `max` reconciles here). - * A backend without a matching native tier clamps to its nearest (e.g. codex maps `ultracode` → `xhigh` - * on models that support it). + * - `ultracode` — maximum (Claude Code's `max` and Codex's `ultra` reconcile here). + * A backend without a matching native tier may clamp down to its strongest supported level, but it + * must never turn reasoning on for `none` or silently increase a requested effort. */ export type ReasoningEffort = | "none" @@ -230,18 +230,48 @@ export interface AgentProfileConfidential { /** * Generic MCP server configuration. */ -export interface AgentProfileMcpServer { - transport?: "stdio" | "sse" | "http"; - command?: string; +interface AgentProfileMcpServerBase { + metadata?: Record; +} + +interface AgentProfileLocalMcpServer extends AgentProfileMcpServerBase { + enabled?: true; + transport?: "stdio"; + command: string; args?: string[]; env?: Record; cwd?: string; - url?: string; + url?: never; + headers?: never; +} + +interface AgentProfileRemoteMcpServer extends AgentProfileMcpServerBase { + enabled?: true; + transport?: "sse" | "http"; + command?: never; + args?: never; + env?: never; + cwd?: never; + url: string; headers?: Record; - enabled?: boolean; - metadata?: Record; } +interface AgentProfileDisabledMcpServer extends AgentProfileMcpServerBase { + enabled: false; + transport?: never; + command?: never; + args?: never; + env?: never; + cwd?: never; + url?: never; + headers?: never; +} + +export type AgentProfileMcpServer = + | AgentProfileLocalMcpServer + | AgentProfileRemoteMcpServer + | AgentProfileDisabledMcpServer; + /** * Hub-managed integration grant. The sandbox runtime resolves each declared * connection/capability pair into an MCP tool backed by Tangle Hub policy. diff --git a/packages/agent-interface/src/harness-capabilities.test.ts b/packages/agent-interface/src/harness-capabilities.test.ts index dc58eb5..0b345d8 100644 --- a/packages/agent-interface/src/harness-capabilities.test.ts +++ b/packages/agent-interface/src/harness-capabilities.test.ts @@ -115,10 +115,32 @@ describe("reasoning effort support", () => { it("offers each harness its real adapter set, not the generic ladder", () => { // no-thinking runners expect(harnessReasoningEfforts("cli-base")).toEqual(["none"]); - // clamp-based: `none` dropped (inert ≡ auto); capped at the adapter's real ceiling - expect(harnessReasoningEfforts("codex")).toEqual(["low", "medium", "high", "xhigh"]); - expect(harnessReasoningEfforts("pi")).toEqual(["minimal", "low", "medium", "high", "xhigh"]); - expect(harnessReasoningEfforts("openclaw")).toEqual(["minimal", "low", "medium", "high", "xhigh"]); + // Explicit sets match each native CLI; model data narrows them later. + expect(harnessReasoningEfforts("codex")).toEqual([ + "minimal", + "low", + "medium", + "high", + "xhigh", + "ultracode", + ]); + expect(harnessReasoningEfforts("pi")).toEqual([ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + ]); + expect(harnessReasoningEfforts("openclaw")).toEqual([ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "ultracode", + ]); // claude: real `--effort` ladder low…max (ultracode stands in for max); no none/minimal expect(harnessReasoningEfforts("claude-code")).toEqual([ "low", @@ -127,8 +149,8 @@ describe("reasoning effort support", () => { "xhigh", "ultracode", ]); - // kimi: binary toggle (minimal = off, high = on) - expect(harnessReasoningEfforts("kimi-code")).toEqual(["minimal", "high"]); + // kimi: binary toggle (none = off, high = on) + expect(harnessReasoningEfforts("kimi-code")).toEqual(["none", "high"]); // pass-through / router-driven: full ladder (narrowed later by the model) expect(harnessReasoningEfforts("opencode")).toContain("ultracode"); }); @@ -145,6 +167,7 @@ describe("reasoning effort support", () => { "medium", ]); expect(reasoningEffortsFor("codex", { maxEffort: "high" })).toEqual([ + "minimal", "low", "medium", "high", diff --git a/packages/agent-interface/src/harness-capabilities.ts b/packages/agent-interface/src/harness-capabilities.ts index dfbdfe3..1b83607 100644 --- a/packages/agent-interface/src/harness-capabilities.ts +++ b/packages/agent-interface/src/harness-capabilities.ts @@ -12,8 +12,8 @@ import { canonicalizeHarness, type HarnessType } from "./harness.js"; * cli-bridge backends, the sandbox UI pickers, and the router all read one truth instead of each * hand-rolling a divergent copy. * - * Grounded in cli-bridge's real backend clamps (codex is `low..xhigh` for current models, kimi is - * binary on/off, claude carries the full range, cli-base has no agent) — NOT a guessed matrix. The + * Grounded in the native CLI controls (Codex is `minimal..ultra`, Kimi is binary on/off, Claude is + * `low..max`, cli-base has no agent) — NOT a guessed matrix. The * per-MODEL reasoning capability (does this specific model reason at all) is dynamic catalog data the * caller supplies. */ @@ -150,23 +150,32 @@ export function snapHarnessToModel( /** * The explicit reasoning-effort set a harness's runtime accepts when it ISN'T a plain `none…ceiling` * slice — grounded in the cli-bridge adapters (NOT the canonical ladder): - * - codex: current models advertise `low|medium|high|xhigh`; `none` is omitted (use `auto`) and - * legacy `minimal` requests clamp up to `low`. - * - claude-code: `--effort` accepts `low|medium|high|xhigh|max`. `ultracode` is the ladder's stand-in - * for claude's `max` (clamped at runtime); `minimal`→`low` and `none`/`auto`→no flag, so both are - * dropped as redundant. - * - pi / openclaw: `--thinking` accepts `minimal…xhigh` (max/ultracode clamp to `xhigh`; no `none`). - * - kimi-code: `--thinking` is BINARY (off/on) — `minimal` is the only value that emits - * `--no-thinking`, `high` is "thinking on". So two levels, not five. + * - codex: `model_reasoning_effort` accepts `minimal|low|medium|high|xhigh|max|ultra`; canonical + * `ultracode` maps to native `ultra`. Per-model catalog data narrows this list. + * - claude-code: `--effort` accepts `low|medium|high|xhigh|max`; canonical `ultracode` maps to + * native `max`. It cannot express `none` or `minimal`. + * - pi: `--thinking` accepts `off|minimal|low|medium|high|xhigh`; canonical `none` maps to `off`. + * - openclaw: `--thinking` accepts `off|minimal|low|medium|high|xhigh|max`; canonical `none` maps + * to `off` and `ultracode` maps to `max`. + * - kimi-code: `--thinking` is binary. Canonical `none` emits `--no-thinking`; any non-none level + * emits `--thinking`, represented here by `high`. */ const harnessReasoningEffortsOverride: Partial< Record > = { - codex: ["low", "medium", "high", "xhigh"], + codex: ["minimal", "low", "medium", "high", "xhigh", "ultracode"], "claude-code": ["low", "medium", "high", "xhigh", "ultracode"], - pi: ["minimal", "low", "medium", "high", "xhigh"], - openclaw: ["minimal", "low", "medium", "high", "xhigh"], - "kimi-code": ["minimal", "high"], + pi: ["none", "minimal", "low", "medium", "high", "xhigh"], + openclaw: [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "ultracode", + ], + "kimi-code": ["none", "high"], }; /** diff --git a/packages/agent-interface/src/profile-schema.test.ts b/packages/agent-interface/src/profile-schema.test.ts new file mode 100644 index 0000000..5aea0aa --- /dev/null +++ b/packages/agent-interface/src/profile-schema.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from "vitest"; +import type { AgentProfileMcpServer } from "./agent-profile.js"; +import { validateAgentProfileSecurity } from "./profile-security.js"; +import { + agentProfileDiffSchema, + agentProfileSchema, + capabilitySchema, +} from "./profile-schema.js"; + +// @ts-expect-error A server cannot select local and remote execution together. +const ambiguousMcpServer: AgentProfileMcpServer = { + command: "mcp", + url: "https://mcp.example.com", +}; +void ambiguousMcpServer; + +describe("agentProfileSchema", () => { + it("rejects unknown behavior at every defined object boundary", () => { + const invalidProfiles: Array<[string, unknown]> = [ + ["root", { unknown: true }], + ["prompt", { prompt: { systemPrompt: "review", unknown: true } }], + ["model", { model: { default: "openai/gpt-5", unknown: true } }], + [ + "resource collection", + { resources: { failOnError: true, unknown: true } }, + ], + [ + "resource reference", + { + resources: { + skills: [ + { + kind: "inline", + name: "review", + content: "Review carefully", + unknown: true, + }, + ], + }, + }, + ], + [ + "file mount", + { + resources: { + files: [ + { + path: "AGENTS.md", + resource: { + kind: "inline", + name: "instructions", + content: "Review carefully", + }, + unknown: true, + }, + ], + }, + }, + ], + [ + "subagent", + { + subagents: { + reviewer: { + prompt: "Review carefully", + permission: { bash: "deny" }, + }, + }, + }, + ], + [ + "hook", + { hooks: { beforeRun: [{ command: "prepare", unknown: true }] } }, + ], + ["mode", { modes: { review: { prompt: "Review", unknown: true } } }], + ["confidential", { confidential: { tee: "tdx", unknown: true } }], + ["MCP", { mcp: { local: { command: "mcp", unknown: true } } }], + [ + "connection", + { + connections: [ + { + connectionId: "github", + capabilities: ["repo.read"], + unknown: true, + }, + ], + }, + ], + ]; + + for (const [label, profile] of invalidProfiles) { + expect(agentProfileSchema.safeParse(profile).success, label).toBe(false); + } + }); + + it("keeps explicitly open metadata and extension values", () => { + const profile = { + model: { metadata: { providerSetting: { nested: true } } }, + metadata: { customer: { segment: "design" } }, + extensions: { opencode: { futureSetting: { enabled: true } } }, + }; + + expect(agentProfileSchema.parse(profile)).toEqual(profile); + }); + + it("accepts unambiguous local, remote, and disabled MCP servers", () => { + const profile = { + mcp: { + local: { command: "mcp", args: ["serve"], env: { TOKEN: "value" } }, + remote: { + transport: "http" as const, + url: "https://mcp.example.com", + headers: { Authorization: "Bearer value" }, + }, + disabled: { enabled: false }, + localWithUndefinedRemote: { command: "mcp", url: undefined }, + remoteWithUndefinedLocal: { + url: "https://mcp.example.com", + command: undefined, + }, + }, + }; + + expect(agentProfileSchema.parse(profile)).toEqual(profile); + expect( + validateAgentProfileSecurity( + { mcp: { disabled: { enabled: false } } }, + { + allowLocalMcp: false, + allowHooks: false, + allowedMcpHosts: [], + }, + ), + ).toMatchObject({ ok: true, issues: [] }); + }); + + it("rejects ambiguous or incomplete MCP servers", () => { + const invalidServers = [ + { command: "mcp", url: "https://mcp.example.com" }, + { transport: "stdio", url: "https://mcp.example.com" }, + { transport: "http", command: "mcp" }, + { args: ["serve"] }, + { headers: { Authorization: "Bearer value" } }, + { enabled: true }, + { enabled: false, transport: "stdio" }, + { enabled: false, command: "mcp" }, + { enabled: false, url: "https://mcp.example.com" }, + { enabled: false, args: [] }, + { enabled: false, cwd: "" }, + { enabled: false, headers: {} }, + { command: " " }, + { url: " " }, + { url: "not-a-url" }, + { url: "ftp://mcp.example.com" }, + ]; + + for (const server of invalidServers) { + expect( + agentProfileSchema.safeParse({ mcp: { invalid: server } }).success, + ).toBe(false); + } + }); +}); + +describe("profile container schemas", () => { + it("rejects unknown diff and capability fields", () => { + expect( + agentProfileDiffSchema.safeParse({ + kind: "agent-profile-diff", + source: { kind: "human", unknown: true }, + }).success, + ).toBe(false); + expect( + agentProfileDiffSchema.safeParse({ + kind: "agent-profile-diff", + remove: { prompt: { systemPrompt: true, unknown: true } }, + }).success, + ).toBe(false); + expect( + capabilitySchema.safeParse({ + id: "review", + definition: {}, + unknown: true, + }).success, + ).toBe(false); + }); +}); diff --git a/packages/agent-interface/src/profile-schema.ts b/packages/agent-interface/src/profile-schema.ts index 6288323..68c76a8 100644 --- a/packages/agent-interface/src/profile-schema.ts +++ b/packages/agent-interface/src/profile-schema.ts @@ -1,5 +1,8 @@ import { z } from "zod"; -import type { AgentProfile } from "./agent-profile.js"; +import type { + AgentProfile, + AgentProfileMcpServer, +} from "./agent-profile.js"; import type { AgentProfileDiff } from "./profile-diff.js"; import { harnessTypeSchema } from "./harness.js"; import { @@ -20,12 +23,12 @@ export const agentProfilePermissionSchema = z.union([ // Mirrors the canonical AgentProfileResourceRef (inline | github), kind-discriminated. export const agentProfileResourceRefSchema = z.union([ - z.object({ + z.strictObject({ kind: z.literal("inline"), name: z.string(), content: z.string(), }), - z.object({ + z.strictObject({ kind: z.literal("github"), repository: z.string().optional(), path: z.string(), @@ -34,19 +37,21 @@ export const agentProfileResourceRefSchema = z.union([ }), ]); -export const agentProfileFileMountSchema = z.object({ +export const agentProfileFileMountSchema = z.strictObject({ path: z.string(), resource: agentProfileResourceRefSchema, executable: z.boolean().optional(), }); -export const agentProfileResourcesSchema = z.object({ +export const agentProfileResourcesSchema = z.strictObject({ files: z.array(agentProfileFileMountSchema).optional(), tools: z.array(agentProfileResourceRefSchema).optional(), skills: z.array(agentProfileResourceRefSchema).optional(), agents: z.array(agentProfileResourceRefSchema).optional(), commands: z.array(agentProfileResourceRefSchema).optional(), - instructions: z.union([z.string(), agentProfileResourceRefSchema]).optional(), + instructions: z + .union([z.string(), agentProfileResourceRefSchema]) + .optional(), failOnError: z.boolean().optional(), }); @@ -60,7 +65,7 @@ export const reasoningEffortSchema = z.enum([ "ultracode", ]); -export const agentProfileModelHintsSchema = z.object({ +export const agentProfileModelHintsSchema = z.strictObject({ default: z.string().optional(), small: z.string().optional(), provider: z.string().optional(), @@ -68,12 +73,12 @@ export const agentProfileModelHintsSchema = z.object({ metadata: z.record(z.string(), z.unknown()).optional(), }); -export const agentProfilePromptSchema = z.object({ +export const agentProfilePromptSchema = z.strictObject({ systemPrompt: z.string().optional(), instructions: z.array(z.string()).optional(), }); -export const agentSubagentProfileSchema = z.object({ +export const agentSubagentProfileSchema = z.strictObject({ description: z.string().optional(), prompt: z.string().optional(), model: z.string().optional(), @@ -83,7 +88,7 @@ export const agentSubagentProfileSchema = z.object({ metadata: z.record(z.string(), z.unknown()).optional(), }); -export const agentProfileHookCommandSchema = z.object({ +export const agentProfileHookCommandSchema = z.strictObject({ command: z.string(), timeoutMs: z.number().positive().optional(), blocking: z.boolean().optional(), @@ -91,7 +96,7 @@ export const agentProfileHookCommandSchema = z.object({ env: z.record(z.string(), z.string()).optional(), }); -export const agentProfileModeSchema = z.object({ +export const agentProfileModeSchema = z.strictObject({ description: z.string().optional(), model: z.string().optional(), prompt: z.string().optional(), @@ -100,26 +105,70 @@ export const agentProfileModeSchema = z.object({ metadata: z.record(z.string(), z.unknown()).optional(), }); -export const agentProfileConfidentialSchema = z.object({ +export const agentProfileConfidentialSchema = z.strictObject({ tee: z.string().optional(), attestationNonce: z.string().optional(), sealed: z.boolean().optional(), attestationRefresh: z.boolean().optional(), }); -export const agentProfileMcpServerSchema = z.object({ - transport: z.enum(["stdio", "sse", "http"]).optional(), - command: z.string().optional(), +const nonBlankMcpValueSchema = z + .string() + .refine((value) => value.trim().length > 0, "value cannot be blank"); + +const remoteMcpUrlSchema = nonBlankMcpValueSchema.refine((value) => { + try { + const url = new URL(value); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +}, "remote MCP url must be an absolute HTTP(S) URL"); + +const agentProfileLocalMcpServerSchema = z.strictObject({ + transport: z.literal("stdio").optional(), + command: nonBlankMcpValueSchema, args: z.array(z.string()).optional(), env: z.record(z.string(), z.string()).optional(), cwd: z.string().optional(), - url: z.string().optional(), + url: z.undefined().optional(), + headers: z.undefined().optional(), + enabled: z.literal(true).optional(), + metadata: z.record(z.string(), z.unknown()).optional(), +}); + +const agentProfileRemoteMcpServerSchema = z.strictObject({ + transport: z.enum(["sse", "http"]).optional(), + command: z.undefined().optional(), + args: z.undefined().optional(), + env: z.undefined().optional(), + cwd: z.undefined().optional(), + url: remoteMcpUrlSchema, headers: z.record(z.string(), z.string()).optional(), - enabled: z.boolean().optional(), + enabled: z.literal(true).optional(), metadata: z.record(z.string(), z.unknown()).optional(), }); -export const agentProfileConnectionSchema = z.object({ +const agentProfileDisabledMcpServerSchema = z.strictObject({ + enabled: z.literal(false), + transport: z.undefined().optional(), + command: z.undefined().optional(), + args: z.undefined().optional(), + env: z.undefined().optional(), + cwd: z.undefined().optional(), + url: z.undefined().optional(), + headers: z.undefined().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), +}); + +export const agentProfileMcpServerSchema: z.ZodType = + z.union([ + agentProfileLocalMcpServerSchema, + agentProfileRemoteMcpServerSchema, + agentProfileDisabledMcpServerSchema, + ]); + +export const agentProfileConnectionSchema = z.strictObject({ connectionId: z.string().min(1), capabilities: z.array(z.string().min(1)).min(1), alias: z.string().min(1).optional(), @@ -127,12 +176,12 @@ export const agentProfileConnectionSchema = z.object({ const removeListSchema = z.union([z.literal(true), z.array(z.string().min(1))]); -export const agentProfilePromptRemovalSchema = z.object({ +export const agentProfilePromptRemovalSchema = z.strictObject({ systemPrompt: z.literal(true).optional(), instructions: removeListSchema.optional(), }); -export const agentProfileResourceRemovalSchema = z.object({ +export const agentProfileResourceRemovalSchema = z.strictObject({ files: removeListSchema.optional(), tools: removeListSchema.optional(), skills: removeListSchema.optional(), @@ -142,10 +191,12 @@ export const agentProfileResourceRemovalSchema = z.object({ failOnError: z.literal(true).optional(), }); -export const agentProfileDiffRemovalSchema = z.object({ +export const agentProfileDiffRemovalSchema = z.strictObject({ identity: z.literal(true).optional(), tags: removeListSchema.optional(), - prompt: z.union([z.literal(true), agentProfilePromptRemovalSchema]).optional(), + prompt: z + .union([z.literal(true), agentProfilePromptRemovalSchema]) + .optional(), model: removeListSchema.optional(), harness: z.literal(true).optional(), permissions: removeListSchema.optional(), @@ -153,7 +204,9 @@ export const agentProfileDiffRemovalSchema = z.object({ mcp: removeListSchema.optional(), connections: removeListSchema.optional(), subagents: removeListSchema.optional(), - resources: z.union([z.literal(true), agentProfileResourceRemovalSchema]).optional(), + resources: z + .union([z.literal(true), agentProfileResourceRemovalSchema]) + .optional(), hooks: removeListSchema.optional(), modes: removeListSchema.optional(), confidential: z.literal(true).optional(), @@ -166,7 +219,7 @@ export const agentProfileDiffRemovalSchema = z.object({ * the canonical {@link AgentProfile} TS contract. Kept structurally in lock-step * with that interface by the compile-time guard at the bottom of this file. */ -export const agentProfileSchema = z.object({ +export const agentProfileSchema = z.strictObject({ name: z.string().optional(), description: z.string().optional(), version: z.string().optional(), @@ -194,15 +247,15 @@ export const agentProfileSchema = z.object({ .optional(), }); -export const agentProfileDiffSchema: z.ZodType = z - .object({ +export const agentProfileDiffSchema: z.ZodType = z.strictObject( + { kind: z.literal("agent-profile-diff"), id: z.string().min(1).optional(), title: z.string().min(1).optional(), description: z.string().optional(), rationale: z.string().optional(), source: z - .object({ + .strictObject({ kind: z.enum([ "trace", "frontier-author", @@ -217,8 +270,8 @@ export const agentProfileDiffSchema: z.ZodType = z set: agentProfileSchema.optional(), remove: agentProfileDiffRemovalSchema.optional(), metadata: z.record(z.string(), z.unknown()).optional(), - }) - .strict(); + }, +); // ── Compile-time drift guard ────────────────────────────────────────────────── // The Zod schema and the hand-written {@link AgentProfile} interface must agree in @@ -257,8 +310,8 @@ export interface Capability { recommendedSize?: SandboxSizePreset; } -export const capabilitySchema: z.ZodType = z.object({ - id: z.string().min(1), - definition: agentProfileSchema as z.ZodType, - recommendedSize: z.enum(SANDBOX_SIZE_PRESET_NAMES).optional(), +export const capabilitySchema: z.ZodType = z.strictObject({ + id: z.string().min(1), + definition: agentProfileSchema as z.ZodType, + recommendedSize: z.enum(SANDBOX_SIZE_PRESET_NAMES).optional(), }); diff --git a/packages/agent-interface/src/profile-security.ts b/packages/agent-interface/src/profile-security.ts index 737de2e..a1ff5ca 100644 --- a/packages/agent-interface/src/profile-security.ts +++ b/packages/agent-interface/src/profile-security.ts @@ -151,6 +151,7 @@ export function validateAgentProfileSecurity( const issues: AgentProfileValidationIssue[] = []; for (const [name, server] of Object.entries(profile.mcp ?? {})) { + if (server.enabled === false) continue; if (isLocalMcpServer(server)) { if (!policy.allowLocalMcp) { issues.push({