diff --git a/.changeset/six-insects-smile.md b/.changeset/six-insects-smile.md new file mode 100644 index 000000000..28d5409c8 --- /dev/null +++ b/.changeset/six-insects-smile.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Add an opt-in `defineAgent({ builtInTools })` framework-tool allowlist. Allowlisted agents deny new framework tools by default while preserving authored tools and overrides. diff --git a/docs/agent-config.md b/docs/agent-config.md index 753e5fa77..65684f05d 100644 --- a/docs/agent-config.md +++ b/docs/agent-config.md @@ -167,6 +167,35 @@ parent's quota, so a delegation tree can never outspend the budget configured at its root. An authored child limit applies only when it is tighter than the parent's grant; an uncapped parent delegates uncapped children. +## Built-in tool allowlist + +By default, eve enables its framework-provided tools. To make production +capabilities deny-by-default, opt into `builtInTools.mode: "allowlist"` and +name the framework tools this agent needs: + +```ts title="agent/agent.ts" +import { defineAgent } from "eve"; + +export default defineAgent({ + model: "anthropic/claude-opus-4.8", + builtInTools: { + mode: "allowlist", + allow: ["ask_question", "todo", "web_fetch", "load_skill"], + }, +}); +``` + +The policy applies only to eve-built tools. Your authored tools, dynamic +tools, and same-slug authored overrides still work normally. This means a +custom `agent/tools/web_fetch.ts` continues to be available even when +`"web_fetch"` is absent from the allowlist. `disableTool()` remains available +for explicitly removing a known framework tool. New framework tools introduced +by a future eve upgrade are denied unless you add them to `allow`. + +`GET /eve/v1/info` reports the effective policy in `tools.builtInPolicy`, the +final model-visible static inventory in `tools.available`, and marks framework +tools omitted by policy with `deniedByPolicy: true`. + ## Workflow world By default, eve selects the Workflow SDK world for the host: Vercel Workflow on @@ -213,6 +242,7 @@ installed package must stay external in hosted output, list it in | `reasoning` | `AgentReasoningDefinition` | provider default | Provider-agnostic reasoning effort forwarded to the agent's turn model calls. | | `modelOptions` | `AgentModelOptionsDefinition` | none | Provider option overrides forwarded to the model call. | | `limits` | `AgentLimitsDefinition` | field-specific | Framework-owned runtime limits. `maxInputTokensPerSession` defaults to `40_000_000` for root sessions, and delegated subagent sessions inherit the parent's remaining quota; `maxOutputTokensPerSession` is unset unless configured; `false` uncaps a session token limit. | +| `builtInTools` | `AgentBuiltInToolsDefinition` | all built-ins | Opt-in deny-by-default allowlist for eve framework tools. Authored tools and same-slug overrides remain available. | | `experimental` | `{ workflow?: { world?: string } }` | unset | Opt-in settings that can change or disappear in any release. Treat them as unstable. `workflow.world` selects the Workflow world package backing session state, queues, hooks, and streams on the root agent. | | `outputSchema` | Standard Schema or a JSON Schema object | none | Structured return type for task-mode runs (a subagent, schedule, or remote job). Interactive conversation turns ignore it unless the client supplies a per-message schema. | | `build` | `{ externalDependencies?: string[] }` | none | Hosted-build packaging controls. `externalDependencies` keeps listed packages external while eve compiles authored modules such as tools and channels, and traces those packages into the hosted output. | diff --git a/docs/concepts/default-harness.md b/docs/concepts/default-harness.md index 0f67c97c5..5f2ae1db3 100644 --- a/docs/concepts/default-harness.md +++ b/docs/concepts/default-harness.md @@ -24,6 +24,10 @@ Compaction also preserves the framework's own tool state automatically. It reset Built-in tools require no imports. The exact set depends on the agent and session. `agent` is available only in the root session; `load_skill` and `connection_search` appear only when the agent declares the corresponding resources; `ask_question` requires a session that can request user input; and `web_search` requires a supported model provider. The harness advertises only the tools available to the current session. +Agents that need deny-by-default capabilities can configure a +[`builtInTools` allowlist](../agent-config#built-in-tool-allowlist). Authored +tools are not affected by that framework-tool policy. + The shell and file tools (`bash`, `read_file`, `write_file`, `glob`, `grep`) run in the app and proxy their work into the agent's [sandbox](../sandbox). The table shows where each tool's effect lands. | Tool | Does | Where it runs | diff --git a/packages/eve/src/client/agent-info-schema.ts b/packages/eve/src/client/agent-info-schema.ts index 67a461e8c..f9fe6b750 100644 --- a/packages/eve/src/client/agent-info-schema.ts +++ b/packages/eve/src/client/agent-info-schema.ts @@ -39,6 +39,7 @@ const tool = entry.extend({ const frameworkTool = tool.extend({ disabledByAuthor: z.boolean(), + deniedByPolicy: z.boolean().optional(), replacedByAuthoredTool: z.boolean(), status: z.enum(["active", "disabled", "replaced"]), }); @@ -173,6 +174,12 @@ export const AgentInfoResultSchema = z.object({ tools: z.object({ authored: z.array(tool), available: z.array(tool), + builtInPolicy: z + .discriminatedUnion("mode", [ + z.object({ mode: z.literal("all") }), + z.object({ mode: z.literal("allowlist"), allow: z.array(z.string()) }), + ]) + .optional(), disabledFramework: z.array(z.string()), dynamic: z.array(dynamicResolver), framework: z.array(frameworkTool), diff --git a/packages/eve/src/compiler/manifest.ts b/packages/eve/src/compiler/manifest.ts index 461f3ac76..8f91bd5b7 100644 --- a/packages/eve/src/compiler/manifest.ts +++ b/packages/eve/src/compiler/manifest.ts @@ -41,7 +41,7 @@ export const ROOT_COMPILED_AGENT_NODE_ID = "__root__"; /** * Current compiled manifest schema version. */ -export const COMPILED_AGENT_MANIFEST_VERSION = 36; +export const COMPILED_AGENT_MANIFEST_VERSION = 37; /** * Compiled channel entry preserved in the compiled manifest. @@ -406,6 +406,13 @@ const compiledAgentLimitsDefinitionSchema = z }) .strict(); +const compiledAgentBuiltInToolsDefinitionSchema = z + .object({ + allow: z.array(z.string()).readonly(), + mode: z.literal("allowlist"), + }) + .strict(); + const compiledWorkflowToolDefinitionSchema: z.ZodType = z .object({ maxSubagents: z.number().int().positive().optional(), @@ -415,6 +422,7 @@ const compiledWorkflowToolDefinitionSchema: z.ZodType = z .object({ build: compiledAgentBuildDefinitionSchema.optional(), + builtInTools: compiledAgentBuiltInToolsDefinitionSchema.optional(), compaction: compiledAgentCompactionDefinitionSchema.optional(), description: z.string().optional(), dynamicModel: compiledDynamicModelDefinitionSchema.optional(), @@ -790,6 +798,13 @@ export function createCompiledAgentNodeManifest(input: { ? undefined : [...input.config.build.externalDependencies], }, + builtInTools: + input.config.builtInTools === undefined + ? undefined + : { + allow: [...input.config.builtInTools.allow], + mode: input.config.builtInTools.mode, + }, compaction: { model: input.config.compaction?.model === undefined diff --git a/packages/eve/src/compiler/normalize-agent-config.test.ts b/packages/eve/src/compiler/normalize-agent-config.test.ts index 7c75c0df5..2061d4ef3 100644 --- a/packages/eve/src/compiler/normalize-agent-config.test.ts +++ b/packages/eve/src/compiler/normalize-agent-config.test.ts @@ -56,6 +56,35 @@ describe("compileAgentConfig", () => { sourceKind: "module", }); }); + + it("preserves a framework built-in tool allowlist", async () => { + mocks.loadModuleBackedDefinition.mockResolvedValue({ + model: "openai/gpt-5.5", + builtInTools: { + mode: "allowlist", + allow: ["todo", "connection_search"], + }, + }); + + const manifest = createAgentSourceManifest({ + agentId: "app", + agentRoot: "/app/agent", + appRoot: "/app", + configModule: createModuleSourceRef({ + logicalPath: "agent.ts", + sourceId: "agent-config", + }), + }); + + const compiled = await compileAgentConfig(manifest, { + modelCatalog: createModelCatalog(), + }); + + expect(compiled.builtInTools).toEqual({ + mode: "allowlist", + allow: ["todo", "connection_search"], + }); + }); }); function createModelCatalog(): ManifestCompileContext["modelCatalog"] { diff --git a/packages/eve/src/compiler/normalize-agent-config.ts b/packages/eve/src/compiler/normalize-agent-config.ts index ffa999e02..d5e5e1ff7 100644 --- a/packages/eve/src/compiler/normalize-agent-config.ts +++ b/packages/eve/src/compiler/normalize-agent-config.ts @@ -67,6 +67,7 @@ export async function compileAgentConfig( const compiledConfig: { build?: CompiledAgentDefinition["build"]; + builtInTools?: CompiledAgentDefinition["builtInTools"]; compaction: { model?: CompiledRuntimeModelReference; thresholdPercent?: number; @@ -117,6 +118,13 @@ export async function compileAgentConfig( }; } + if (definition.builtInTools !== undefined) { + compiledConfig.builtInTools = { + mode: definition.builtInTools.mode, + allow: [...definition.builtInTools.allow], + }; + } + if (definition.outputSchema !== undefined) { compiledConfig.outputSchema = serializeOutputSchema(definition.outputSchema); } diff --git a/packages/eve/src/execution/dispatch-runtime-actions-step.ts b/packages/eve/src/execution/dispatch-runtime-actions-step.ts index 370b11a75..c78749742 100644 --- a/packages/eve/src/execution/dispatch-runtime-actions-step.ts +++ b/packages/eve/src/execution/dispatch-runtime-actions-step.ts @@ -45,6 +45,8 @@ import { hydrateDurableSession } from "#execution/session.js"; import { buildSubagentRunInput, type SubagentInputSource } from "#execution/subagent-tool.js"; import { createWorkflowRuntime, workflowEntryReference } from "#execution/workflow-runtime.js"; import { createLogger, logError } from "#internal/logging.js"; +import { AGENT_TOOL_NAME } from "#runtime/framework-tools/agent.js"; +import { isFrameworkToolAllowed } from "#runtime/framework-tools/index.js"; import { toErrorMessage } from "#shared/errors.js"; import { resolveSubagentDepth } from "#harness/subagent-depth.js"; @@ -99,6 +101,19 @@ export async function dispatchRuntimeActionsStep(input: { try { for (const action of batch.actions) { + if ( + isRecursiveAgentAction(action, bundle.subagentRegistry.subagentsByNodeId) && + !isFrameworkToolAllowed(bundle.resolvedAgent.config.builtInTools, AGENT_TOOL_NAME) + ) { + log.warn("recursive agent call blocked by built-in tool policy", { + callId: action.callId, + nodeId: action.nodeId, + subagentName: action.subagentName, + }); + results.push(createRecursiveAgentPolicyDeniedResult(action)); + continue; + } + if ( isRecursiveAgentAction(action, bundle.subagentRegistry.subagentsByNodeId) && (session.rootSessionId !== undefined || subagentDepth.currentDepth > 0) @@ -261,6 +276,21 @@ function createRemoteAgentStartFailureResult(input: { }; } +function createRecursiveAgentPolicyDeniedResult( + action: RuntimeSubagentCallActionRequest, +): RuntimeSubagentResultActionResult { + return { + callId: action.callId, + isError: true, + kind: "subagent-result", + output: { + code: "FRAMEWORK_TOOL_DENIED_BY_POLICY", + message: 'The built-in "agent" tool is denied by this agent\'s built-in tool policy.', + }, + subagentName: action.subagentName, + }; +} + function createRecursiveAgentRootOnlyResult( action: RuntimeSubagentCallActionRequest, ): RuntimeSubagentResultActionResult { diff --git a/packages/eve/src/execution/node-step.ts b/packages/eve/src/execution/node-step.ts index c43d4da5c..14b5cc643 100644 --- a/packages/eve/src/execution/node-step.ts +++ b/packages/eve/src/execution/node-step.ts @@ -17,6 +17,7 @@ import { } from "#runtime/agent/resolve-model.js"; import type { RuntimeCompiledArtifactsSource } from "#runtime/compiled-artifacts-source.js"; import { AGENT_TOOL_DESCRIPTION, AGENT_TOOL_NAME } from "#runtime/framework-tools/agent.js"; +import { isFrameworkToolAllowed } from "#runtime/framework-tools/index.js"; import { ROOT_RUNTIME_AGENT_NODE_ID, type ResolvedRuntimeAgentNode } from "#runtime/graph.js"; import type { PreparedRuntimeTool } from "#runtime/sessions/turn.js"; @@ -189,6 +190,7 @@ export function createNodeHarnessTools(input: { if ( input.node.nodeId === ROOT_RUNTIME_AGENT_NODE_ID && + isFrameworkToolAllowed(input.node.agent.config?.builtInTools, AGENT_TOOL_NAME) && !input.node.agent.disabledFrameworkTools.includes(AGENT_TOOL_NAME) && !tools.has(AGENT_TOOL_NAME) ) { diff --git a/packages/eve/src/execution/workflow-steps.test.ts b/packages/eve/src/execution/workflow-steps.test.ts index ed804a817..28dc98b6a 100644 --- a/packages/eve/src/execution/workflow-steps.test.ts +++ b/packages/eve/src/execution/workflow-steps.test.ts @@ -742,6 +742,93 @@ describe("dispatchRuntimeActionsStep", () => { ); expect(workflowWritesByNamespace.get(DEFAULT_WORKFLOW_STREAM_NAMESPACE)).toBeUndefined(); }); + + it("blocks a stale recursive agent call denied by the built-in tool policy", async () => { + const compiledBundle = { + adapterRegistry: { + adaptersByKind: new Map([[threadContextAdapter.kind, threadContextAdapter]]), + }, + compiledArtifactsSource: {}, + graph: { + nodesByNodeId: new Map(), + root: { + sandboxRegistry: { sandbox: null }, + turnAgent: TestTurnAgent, + }, + }, + hookRegistry: createEmptyHookRegistry(), + resolvedAgent: { + config: { + builtInTools: { mode: "allowlist", allow: [] }, + }, + }, + subagentRegistry: { + subagentsByNodeId: new Map(), + }, + toolRegistry: {}, + turnAgent: TestTurnAgent, + } as never; + vi.mocked(getCompiledRuntimeAgentBundle).mockResolvedValue(compiledBundle); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const session = setPendingRuntimeActionBatch({ + actions: [ + { + callId: "call-1", + description: "Delegate the work.", + input: { message: "try a stale action" }, + kind: "subagent-call", + name: "agent", + nodeId: "__root__", + subagentName: "agent", + }, + ], + event: { sequence: 0, stepIndex: 0, turnId: "turn_0" }, + responseMessages: [], + session: createStubSession({ + continuationToken: "http:parent", + sessionId: "root-session", + }), + }); + installSessionStoreMocks([session]); + + const sessionState = createStubSessionState({ + continuationToken: "http:parent", + sessionId: "root-session", + }); + + await expect( + dispatchRuntimeActionsStep({ + parentContinuationToken: "turn-inbox", + parentWritable: createTestWritable(), + serializedContext: createSerializedContext(), + sessionState, + }), + ).resolves.toEqual({ + results: [ + { + callId: "call-1", + isError: true, + kind: "subagent-result", + output: { + code: "FRAMEWORK_TOOL_DENIED_BY_POLICY", + message: 'The built-in "agent" tool is denied by this agent\'s built-in tool policy.', + }, + subagentName: "agent", + }, + ], + sessionState, + }); + expect(startMock).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith( + "[eve:execution.dispatch-runtime-actions] recursive agent call blocked by built-in tool policy", + expect.objectContaining({ + callId: "call-1", + subagentName: "agent", + }), + ); + expect(workflowWritesByNamespace.get(DEFAULT_WORKFLOW_STREAM_NAMESPACE)).toBeUndefined(); + }); }); describe("turnStep", () => { diff --git a/packages/eve/src/internal/authored-definition/core.test.ts b/packages/eve/src/internal/authored-definition/core.test.ts index 46043724a..993a8d4fe 100644 --- a/packages/eve/src/internal/authored-definition/core.test.ts +++ b/packages/eve/src/internal/authored-definition/core.test.ts @@ -9,6 +9,41 @@ import { defineDynamic } from "#public/definitions/tool.js"; const FAILURE_MESSAGE = "Expected the agent config to match the public eve shape."; describe("normalizeAgentDefinition", () => { + it("accepts a framework built-in tool allowlist", () => { + const definition = normalizeAgentDefinition( + { + model: "openai/gpt-5.5", + builtInTools: { + mode: "allowlist", + allow: ["todo", "connection_search"], + }, + }, + FAILURE_MESSAGE, + ); + + expect(definition.builtInTools).toEqual({ + mode: "allowlist", + allow: ["todo", "connection_search"], + }); + }); + + it.each([ + [{ mode: "denylist", allow: ["todo"] }], + [{ mode: "allowlist", allow: "todo" }], + [{ mode: "allowlist", allow: [42] }], + [{ mode: "allowlist", allow: ["todo"], extra: true }], + ])("rejects an invalid framework built-in tool policy: %j", (builtInTools) => { + expect(() => + normalizeAgentDefinition( + { + model: "openai/gpt-5.5", + builtInTools, + }, + FAILURE_MESSAGE, + ), + ).toThrow(FAILURE_MESSAGE); + }); + it("accepts provider-agnostic reasoning effort", () => { const definition = normalizeAgentDefinition( { diff --git a/packages/eve/src/internal/authored-definition/core.ts b/packages/eve/src/internal/authored-definition/core.ts index 688a2016a..3e8e5d8d5 100644 --- a/packages/eve/src/internal/authored-definition/core.ts +++ b/packages/eve/src/internal/authored-definition/core.ts @@ -48,6 +48,7 @@ export function normalizeAgentDefinition( record, [ "build", + "builtInTools", "compaction", "description", "experimental", @@ -80,6 +81,10 @@ export function normalizeAgentDefinition( definition.build = normalizeAgentBuildDefinition(record.build, message); } + if (record.builtInTools !== undefined) { + definition.builtInTools = normalizeAgentBuiltInToolsDefinition(record.builtInTools, message); + } + if (record.experimental !== undefined) { definition.experimental = normalizeAgentExperimentalDefinition(record.experimental, message); } @@ -110,6 +115,21 @@ export function normalizeAgentDefinition( return definition as Readonly; } +function normalizeAgentBuiltInToolsDefinition( + value: unknown, + message: string, +): NonNullable { + const record = expectObjectRecord(value, message); + expectOnlyKnownKeys(record, ["allow", "mode"], message); + if (record.mode !== "allowlist" || !Array.isArray(record.allow)) { + throw new Error(`${message} "builtInTools" must use mode "allowlist" with an "allow" array.`); + } + return { + mode: "allowlist", + allow: record.allow.map((name) => expectString(name, message)), + }; +} + function normalizeAgentReasoningDefinition( value: unknown, message: string, diff --git a/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response-from-manifest.ts b/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response-from-manifest.ts index 3d052ce1c..e177b867b 100644 --- a/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response-from-manifest.ts +++ b/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response-from-manifest.ts @@ -1,4 +1,8 @@ -import { getAllFrameworkToolNames } from "#runtime/framework-tools/index.js"; +import { + CONNECTION_SEARCH_TOOL_NAME, + getAllFrameworkToolNames, + isFrameworkToolAllowed, +} from "#runtime/framework-tools/index.js"; import { getAllFrameworkChannelNames, getFrameworkChannelDefinitions, @@ -66,6 +70,7 @@ export function buildAgentInfoResponseFromManifest( ); const frameworkToolInfo = buildFrameworkToolInfo({ authoredToolNames, + builtInTools: manifest.config.builtInTools, delegationToolNames: getRootDelegationToolNames(manifest), disabledFrameworkToolNames: disabledFrameworkTools, }); @@ -200,9 +205,14 @@ export function buildAgentInfoResponseFromManifest( tools: { available: [...frameworkToolInfo.available, ...authoredTools], authored: authoredTools, + builtInPolicy: + manifest.config.builtInTools === undefined + ? { mode: "all" } + : { mode: "allowlist", allow: [...manifest.config.builtInTools.allow] }, disabledFramework: [...manifest.disabledFrameworkTools], dynamic: [ - ...(manifest.connections.length > 0 + ...(manifest.connections.length > 0 && + isFrameworkToolAllowed(manifest.config.builtInTools, CONNECTION_SEARCH_TOOL_NAME) ? [renderDynamicResolver(createConnectionSearchResolver(), { origin: "framework" })] : []), ...manifest.dynamicTools.map((resolver) => diff --git a/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response.test.ts b/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response.test.ts index 5898486fe..caf3e8c68 100644 --- a/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response.test.ts +++ b/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response.test.ts @@ -30,6 +30,22 @@ describe("buildFrameworkToolInfo", () => { }); }); + it("reports an allowlist-denied framework tool separately from disableTool", () => { + const info = buildFrameworkToolInfo({ + authoredToolNames: new Set(), + builtInTools: { mode: "allowlist", allow: ["todo"] }, + delegationToolNames: new Set(), + disabledFrameworkToolNames: new Set(), + }); + + expect(info.available.map((tool) => tool.name)).toEqual(["todo"]); + expect(info.framework.find((tool) => tool.name === "bash")).toMatchObject({ + deniedByPolicy: true, + disabledByAuthor: false, + status: "disabled", + }); + }); + it("reports a declared agent delegation tool as replacing the recursive action", () => { const info = buildFrameworkToolInfo({ authoredToolNames: new Set(), diff --git a/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response.ts b/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response.ts index 86422057a..dda9f7fef 100644 --- a/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response.ts +++ b/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response.ts @@ -1,7 +1,9 @@ import { ROOT_COMPILED_AGENT_NODE_ID } from "#compiler/manifest.js"; import { + CONNECTION_SEARCH_TOOL_NAME, getAllFrameworkToolDefinitions, getAllFrameworkToolNames, + isFrameworkToolAllowed, } from "#runtime/framework-tools/index.js"; import { getAllFrameworkChannelNames, @@ -50,6 +52,8 @@ export interface AgentInfoToolEntry extends AgentInfoSource { export interface AgentInfoFrameworkToolEntry extends AgentInfoToolEntry { readonly disabledByAuthor: boolean; + /** True when the framework tool is omitted by `defineAgent({ builtInTools })`. */ + readonly deniedByPolicy?: boolean; readonly replacedByAuthoredTool: boolean; readonly status: "active" | "disabled" | "replaced"; } @@ -63,6 +67,10 @@ export interface AgentInfoDynamicResolverEntry extends AgentInfoSource { export interface AgentInfoTools { readonly available: readonly AgentInfoToolEntry[]; readonly authored: readonly AgentInfoToolEntry[]; + /** Effective policy. `all` is the backward-compatible default. */ + readonly builtInPolicy: + | { readonly mode: "all" } + | { readonly mode: "allowlist"; readonly allow: readonly string[] }; readonly disabledFramework: readonly string[]; readonly dynamic: readonly AgentInfoDynamicResolverEntry[]; readonly framework: readonly AgentInfoFrameworkToolEntry[]; @@ -366,6 +374,7 @@ function buildToolInfo( ); const frameworkInfo = buildFrameworkToolInfo({ authoredToolNames, + builtInTools: agent.config.builtInTools, delegationToolNames, disabledFrameworkToolNames: disabledFrameworkTools, }); @@ -373,11 +382,17 @@ function buildToolInfo( return { available: [...frameworkInfo.available, ...authored], authored, + builtInPolicy: + agent.config.builtInTools === undefined + ? { mode: "all" } + : { mode: "allowlist", allow: [...agent.config.builtInTools.allow] }, disabledFramework: [...agent.disabledFrameworkTools], dynamic: [ - ...dynamicFrameworkResolvers.map((resolver) => - renderDynamicResolver(resolver, { origin: "framework" }), - ), + ...(isFrameworkToolAllowed(agent.config.builtInTools, CONNECTION_SEARCH_TOOL_NAME) + ? dynamicFrameworkResolvers.map((resolver) => + renderDynamicResolver(resolver, { origin: "framework" }), + ) + : []), ...agent.dynamicToolResolvers.map((resolver) => renderDynamicResolver(resolver, { origin: "authored" }), ), @@ -389,6 +404,7 @@ function buildToolInfo( export function buildFrameworkToolInfo(input: { readonly authoredToolNames: ReadonlySet; + readonly builtInTools?: ResolvedAgent["config"]["builtInTools"]; readonly delegationToolNames: ReadonlySet; readonly disabledFrameworkToolNames: ReadonlySet; }): Pick { @@ -398,12 +414,14 @@ export function buildFrameworkToolInfo(input: { for (const definition of getAllFrameworkToolDefinitions()) { const disabledByAuthor = input.disabledFrameworkToolNames.has(definition.name); + const deniedByPolicy = !isFrameworkToolAllowed(input.builtInTools, definition.name); const replacedByAuthoredTool = input.authoredToolNames.has(definition.name); - const status: AgentInfoFrameworkToolEntry["status"] = disabledByAuthor - ? "disabled" - : occupiedToolNames.has(definition.name) - ? "replaced" - : "active"; + const status: AgentInfoFrameworkToolEntry["status"] = + disabledByAuthor || deniedByPolicy + ? "disabled" + : occupiedToolNames.has(definition.name) + ? "replaced" + : "active"; const rendered = renderTool(definition, { origin: "framework", replacesFrameworkTool: false, @@ -416,6 +434,7 @@ export function buildFrameworkToolInfo(input: { framework.push({ ...rendered, disabledByAuthor, + deniedByPolicy, replacedByAuthoredTool, status, }); diff --git a/packages/eve/src/public/definitions/agent.ts b/packages/eve/src/public/definitions/agent.ts index cf7b5bbf4..d62b6cfd4 100644 --- a/packages/eve/src/public/definitions/agent.ts +++ b/packages/eve/src/public/definitions/agent.ts @@ -7,6 +7,7 @@ export type { AgentModelResolver, AgentReasoningDefinition, AgentBuildDefinition, + AgentBuiltInToolsDefinition, PublicAgentDynamicModelDefinition as AgentDynamicModelDefinition, PublicAgentDynamicModelResult as AgentDynamicModelResult, AgentExperimentalDefinition, diff --git a/packages/eve/src/runtime/framework-tools/index.test.ts b/packages/eve/src/runtime/framework-tools/index.test.ts index 269d039da..4bbc288a9 100644 --- a/packages/eve/src/runtime/framework-tools/index.test.ts +++ b/packages/eve/src/runtime/framework-tools/index.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { getAllFrameworkToolDefinitions, getAllFrameworkToolNames, + getBuiltInToolPolicyNames, getFrameworkToolDefinitions, } from "#runtime/framework-tools/index.js"; import { isToolSchema } from "#shared/tool-schema.js"; @@ -39,6 +40,15 @@ describe("framework-tools/index", () => { expect(getFrameworkToolDefinitions().map((tool) => tool.name)).not.toContain("agent"); }); + it("accepts static and dynamic framework tool names in built-in policies", () => { + const policyNames = getBuiltInToolPolicyNames(); + + expect(policyNames.has("connection_search")).toBe(true); + for (const name of getAllFrameworkToolNames()) { + expect(policyNames.has(name)).toBe(true); + } + }); + it("uses one validated runtime schema for every framework-defined input", () => { for (const tool of getAllFrameworkToolDefinitions()) { if (tool.inputSchema !== null) { diff --git a/packages/eve/src/runtime/framework-tools/index.ts b/packages/eve/src/runtime/framework-tools/index.ts index 77bb83208..8e710e1d5 100644 --- a/packages/eve/src/runtime/framework-tools/index.ts +++ b/packages/eve/src/runtime/framework-tools/index.ts @@ -19,6 +19,7 @@ export { ReadFileStateKey } from "#runtime/framework-tools/file-state.js"; export type { TodoItem, TodoState } from "#runtime/framework-tools/todo.js"; export { TodoStateKey } from "#runtime/framework-tools/todo.js"; +import type { AgentBuiltInToolsDefinition } from "#shared/agent-definition.js"; import type { ResolvedSkillDefinition, ResolvedToolDefinition } from "#runtime/types.js"; const REGISTERED_FRAMEWORK_TOOLS: readonly ResolvedToolDefinition[] = [ @@ -39,6 +40,17 @@ const ALL_FRAMEWORK_TOOLS: readonly ResolvedToolDefinition[] = [ AGENT_TOOL_DEFINITION, ]; +/** Stable model-visible name for framework connection discovery. */ +export const CONNECTION_SEARCH_TOOL_NAME = "connection_search"; + +/** Returns whether a framework tool is enabled by the agent's opt-in policy. */ +export function isFrameworkToolAllowed( + policy: AgentBuiltInToolsDefinition | undefined, + name: string, +): boolean { + return policy === undefined || policy.allow.includes(name); +} + /** * Returns framework-owned tool definitions registered in the tool registry * alongside authored tools during graph resolution. @@ -80,3 +92,13 @@ export function getAllFrameworkToolDefinitions(): readonly ResolvedToolDefinitio export function getAllFrameworkToolNames(): ReadonlySet { return new Set(ALL_FRAMEWORK_TOOLS.map((definition) => definition.name)); } + +/** + * Returns every name accepted by `defineAgent({ builtInTools.allow })`. + * + * This includes conditional dynamic framework tools that cannot be disabled + * with a filesystem sentinel. + */ +export function getBuiltInToolPolicyNames(): ReadonlySet { + return new Set([...getAllFrameworkToolNames(), CONNECTION_SEARCH_TOOL_NAME]); +} diff --git a/packages/eve/src/runtime/resolve-agent-graph.ts b/packages/eve/src/runtime/resolve-agent-graph.ts index f9a8dfb75..286f938cf 100644 --- a/packages/eve/src/runtime/resolve-agent-graph.ts +++ b/packages/eve/src/runtime/resolve-agent-graph.ts @@ -15,8 +15,11 @@ import { } from "#runtime/framework-channels/index.js"; import { createConnectionSearchResolver } from "#runtime/framework-tools/connection-search-dynamic.js"; import { + CONNECTION_SEARCH_TOOL_NAME, getAllFrameworkToolNames, + getBuiltInToolPolicyNames, getFrameworkToolDefinitions, + isFrameworkToolAllowed, } from "#runtime/framework-tools/index.js"; import { type ResolvedAgentGraphBundle, ROOT_RUNTIME_AGENT_NODE_ID } from "#runtime/graph.js"; import { createRuntimeHookRegistry } from "#runtime/hooks/registry.js"; @@ -141,6 +144,7 @@ async function resolveRuntimeAgentNode( }); const frameworkToolNames = new Set(frameworkTools.map((t) => t.name)); const allFrameworkToolNames = getAllFrameworkToolNames(); + const builtInToolPolicyNames = getBuiltInToolPolicyNames(); // Authored tools whose filename slug matches a framework default replace // it. Authored disable sentinels (whose target is also taken from the @@ -163,9 +167,25 @@ async function resolveRuntimeAgentNode( } } + for (const allowedName of agent.config.builtInTools?.allow ?? []) { + if (!builtInToolPolicyNames.has(allowedName)) { + throw new ResolveRuntimeAgentGraphError( + `defineAgent({ builtInTools }) allows "${allowedName}", but it is not a framework tool. ` + + `Use one of: ${[...builtInToolPolicyNames].sort().join(", ")}.`, + { + nodeId, + sourceId: input.sourceId, + }, + ); + } + } + const disabledFrameworkTools = new Set(agent.disabledFrameworkTools); const activeFrameworkTools = frameworkTools.filter( - (tool) => !authoredToolNames.has(tool.name) && !disabledFrameworkTools.has(tool.name), + (tool) => + isFrameworkToolAllowed(agent.config.builtInTools, tool.name) && + !authoredToolNames.has(tool.name) && + !disabledFrameworkTools.has(tool.name), ); const toolRegistry = await createRuntimeToolRegistry( @@ -231,7 +251,12 @@ async function resolveRuntimeAgentNode( const resolvedAgent = hasConnections ? { ...agent, - dynamicToolResolvers: [...agent.dynamicToolResolvers, createConnectionSearchResolver()], + dynamicToolResolvers: isFrameworkToolAllowed( + agent.config.builtInTools, + CONNECTION_SEARCH_TOOL_NAME, + ) + ? [...agent.dynamicToolResolvers, createConnectionSearchResolver()] + : agent.dynamicToolResolvers, } : agent; diff --git a/packages/eve/src/runtime/resolve-agent.ts b/packages/eve/src/runtime/resolve-agent.ts index 48418b865..cf8003c10 100644 --- a/packages/eve/src/runtime/resolve-agent.ts +++ b/packages/eve/src/runtime/resolve-agent.ts @@ -152,6 +152,7 @@ function createResolvedInstructionsDefinition( function createResolvedAgentConfig(manifest: CompiledAgentNodeManifest): ResolvedAgent["config"] { const config: { + builtInTools?: ResolvedAgent["config"]["builtInTools"]; compaction?: ResolvedAgent["config"]["compaction"]; dynamicModel?: ResolvedAgent["config"]["dynamicModel"]; experimental?: ResolvedAgent["config"]["experimental"]; @@ -217,6 +218,13 @@ function createResolvedAgentConfig(manifest: CompiledAgentNodeManifest): Resolve config.compaction = compaction; } + if (manifest.config.builtInTools !== undefined) { + config.builtInTools = { + mode: manifest.config.builtInTools.mode, + allow: [...manifest.config.builtInTools.allow], + }; + } + if (manifest.config.dynamicModel !== undefined) { config.dynamicModel = { ...createResolvedModuleSourceRef(manifest.config.dynamicModel), diff --git a/packages/eve/src/shared/agent-definition.ts b/packages/eve/src/shared/agent-definition.ts index 2d8cee7bb..cf8ae524a 100644 --- a/packages/eve/src/shared/agent-definition.ts +++ b/packages/eve/src/shared/agent-definition.ts @@ -178,6 +178,20 @@ export interface AgentLimitsDefinition { readonly maxOutputTokensPerSession?: number | false; } +/** + * Opt-in policy for framework-provided tools. + * + * This policy only controls tools owned by eve itself. Authored tools and + * same-slug authored overrides remain available so an application can retain + * its own implementation while denying the corresponding framework default. + */ +export interface AgentBuiltInToolsDefinition { + /** Deny every framework tool except the explicitly named entries. */ + readonly mode: "allowlist"; + /** Names of framework tools that remain available. */ + readonly allow: readonly string[]; +} + /** * Experimental, opt-in agent capabilities authored in `agent.ts`. * @@ -235,6 +249,7 @@ export interface AgentWorkflowDefinition { * stamps the path-derived `agentId` onto every compiled agent node. */ export type InternalAgentDefinition = { + builtInTools?: AgentBuiltInToolsDefinition; name: string; description?: string; build?: AgentBuildDefinition; @@ -255,6 +270,11 @@ export type InternalAgentDefinition = { * a `name` field. */ export type PublicAgentDefinition = { + /** + * Optional deny-by-default policy for eve framework tools. Omit this to + * preserve eve's backward-compatible default of enabling all built-ins. + */ + readonly builtInTools?: AgentBuiltInToolsDefinition; /** * Human-readable description of the agent's purpose. Required for * subagents (authored under `subagents//agent.ts`): surfaced to diff --git a/packages/eve/test/runtime-agent-framework-tools.test.ts b/packages/eve/test/runtime-agent-framework-tools.test.ts index d9ad5e544..7f8c4e271 100644 --- a/packages/eve/test/runtime-agent-framework-tools.test.ts +++ b/packages/eve/test/runtime-agent-framework-tools.test.ts @@ -10,6 +10,107 @@ import { TEST_DEFAULT_MODEL_ID } from "../src/internal/testing/app-harness.js"; import { resolveRuntimeAgentGraph } from "../src/runtime/resolve-agent-graph.js"; describe("runtime agent framework tools", () => { + it("denies unlisted framework tools while preserving authored overrides", async () => { + const manifest = createCompiledAgentManifest({ + agentRoot: "/app/agent", + appRoot: "/app", + config: { + builtInTools: { mode: "allowlist", allow: ["todo"] }, + model: { + id: TEST_DEFAULT_MODEL_ID, + routing: { kind: "gateway", target: "openai" }, + }, + name: "restricted-agent", + }, + tools: [ + { + description: "Application-owned fetch implementation.", + inputSchema: null, + logicalPath: "tools/web-fetch.mjs", + name: "web_fetch", + sourceId: "tools/web-fetch.mjs", + sourceKind: "module", + }, + ], + }); + const graph = await resolveRuntimeAgentGraph({ + manifest, + moduleMap: { + nodes: { + [ROOT_COMPILED_AGENT_NODE_ID]: { + modules: { + "tools/web-fetch.mjs": { + default: { + description: "Application-owned fetch implementation.", + execute: () => "authored-fetch", + }, + }, + }, + }, + }, + }, + }); + + expect(graph.root.turnAgent.tools.map((tool) => tool.name)).toContain("todo"); + expect(graph.root.turnAgent.tools.map((tool) => tool.name)).toContain("web_fetch"); + expect(graph.root.turnAgent.tools.map((tool) => tool.name)).not.toContain("bash"); + expect(createNodeHarnessTools({ node: graph.root }).has("agent")).toBe(false); + }); + + it("keeps the built-in agent action when explicitly allowed", async () => { + const manifest = createCompiledAgentManifest({ + agentRoot: "/app/agent", + appRoot: "/app", + config: { + builtInTools: { mode: "allowlist", allow: ["agent"] }, + model: { + id: TEST_DEFAULT_MODEL_ID, + routing: { kind: "gateway", target: "openai" }, + }, + name: "delegating-agent", + }, + }); + const graph = await resolveRuntimeAgentGraph({ + manifest, + moduleMap: { + nodes: { + [ROOT_COMPILED_AGENT_NODE_ID]: { modules: {} }, + }, + }, + }); + + expect(createNodeHarnessTools({ node: graph.root }).has("agent")).toBe(true); + expect(graph.root.turnAgent.tools.map((tool) => tool.name)).not.toContain("bash"); + }); + + it("rejects unknown names in the built-in tool allowlist", async () => { + const manifest = createCompiledAgentManifest({ + agentRoot: "/app/agent", + appRoot: "/app", + config: { + builtInTools: { mode: "allowlist", allow: ["todo_typo"] }, + model: { + id: TEST_DEFAULT_MODEL_ID, + routing: { kind: "gateway", target: "openai" }, + }, + name: "invalid-agent", + }, + }); + + await expect( + resolveRuntimeAgentGraph({ + manifest, + moduleMap: { + nodes: { + [ROOT_COMPILED_AGENT_NODE_ID]: { modules: {} }, + }, + }, + }), + ).rejects.toThrow( + 'defineAgent({ builtInTools }) allows "todo_typo", but it is not a framework tool.', + ); + }); + it("lets an authored agent tool replace the built-in agent action", async () => { const manifest = createCompiledAgentManifest({ agentRoot: "/app/agent",