Skip to content
Open
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
2 changes: 2 additions & 0 deletions libs/deepagents/src/sandbox-ptc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export type {
SandboxPtcMiddlewareOptions,
PtcToolCallTrace,
PtcExecuteResult,
NetworkPolicy,
NetworkRule,
} from "./types.js";
export { DEFAULT_PTC_EXCLUDED_TOOLS } from "./types.js";

Expand Down
51 changes: 46 additions & 5 deletions libs/deepagents/src/sandbox-ptc/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,12 @@ import {
generateWorkerReplPrompt,
} from "./prompt.js";
import { WorkerRepl } from "./worker-repl.js";
import type { SandboxPtcMiddlewareOptions, PtcExecuteResult } from "./types.js";
import { policyFetch } from "./network-policy.js";
import type {
SandboxPtcMiddlewareOptions,
PtcExecuteResult,
NetworkPolicy,
} from "./types.js";
import { DEFAULT_PTC_EXCLUDED_TOOLS } from "./types.js";

function getBackend(
Expand Down Expand Up @@ -125,6 +130,37 @@ function isSandboxWithInteractive(
);
}

/**
* Create a synthetic __http_fetch tool for sandbox PTC runtimes.
* The tool enforces the network policy and delegates to real fetch.
*/
function createHttpFetchTool(network: NetworkPolicy) {
return tool(
async (input: { url: string; method?: string; headers?: Record<string, string>; body?: string }) => {
const result = await policyFetch(
input.url,
{
method: input.method || "GET",
headers: input.headers,
body: input.body,
},
network,
);
return JSON.stringify({ ok: result.ok, status: result.status, body: result.body });
},
{
name: "__http_fetch",
description: "Policy-enforced HTTP fetch (internal, used by PTC runtimes)",
schema: z.object({
url: z.string(),
method: z.string().optional(),
headers: z.record(z.string(), z.string()).optional(),
body: z.string().optional(),
}),
},
);
}

/**
* Create a middleware that enables Programmatic Tool Calling (PTC).
*
Expand All @@ -138,18 +174,20 @@ function isSandboxWithInteractive(
export function createSandboxPtcMiddleware(
options: SandboxPtcMiddlewareOptions = {},
) {
const { backend, ptc = true, timeoutMs = 300_000 } = options;
const { backend, ptc = true, timeoutMs = 300_000, network } = options;

let ptcTools: StructuredToolInterface[] = [];
let cachedSandboxPrompt: string | null = null;
let cachedReplPrompt: string | null = null;
let repl: WorkerRepl | null = null;
let detectedSandbox: boolean | null = null;

const httpFetchTool = network ? createHttpFetchTool(network) : null;

const jsEvalTool = tool(
async (input: { code: string }, runnableConfig: ToolRuntime) => {
if (!repl) {
repl = new WorkerRepl(ptcTools, { timeoutMs });
repl = new WorkerRepl(ptcTools, { timeoutMs, network });
}
repl.tools = ptcTools;

Expand Down Expand Up @@ -182,6 +220,9 @@ export function createSandboxPtcMiddleware(
wrapModelCall: async (request, handler) => {
const agentTools = (request.tools || []) as StructuredToolInterface[];
ptcTools = filterToolsForPtc(agentTools, ptc);
if (httpFetchTool && !ptcTools.some((t) => t.name === "__http_fetch")) {
ptcTools.push(httpFetchTool);
}

// Detect sandbox support lazily (once)
if (detectedSandbox === null) {
Expand All @@ -196,7 +237,7 @@ export function createSandboxPtcMiddleware(
if (detectedSandbox) {
// Sandbox mode: inject bash/python/node PTC prompt, hide js_eval
if (ptcTools.length > 0 && !cachedSandboxPrompt) {
cachedSandboxPrompt = generateSandboxPtcPrompt(ptcTools);
cachedSandboxPrompt = generateSandboxPtcPrompt(ptcTools, network);
}
const tools = (request.tools as { name: string }[]).filter(
(t) => t.name !== "js_eval",
Expand All @@ -210,7 +251,7 @@ export function createSandboxPtcMiddleware(
// Worker REPL mode: inject JS REPL prompt, hide PTC tools from the
// model so it must use toolCall()/spawnAgent() inside js_eval
if (ptcTools.length > 0 && !cachedReplPrompt) {
cachedReplPrompt = generateWorkerReplPrompt(ptcTools);
cachedReplPrompt = generateWorkerReplPrompt(ptcTools, network);
}
const ptcToolNames = new Set(ptcTools.map((t) => t.name));
const visibleTools = (request.tools as { name: string }[]).filter(
Expand Down
123 changes: 123 additions & 0 deletions libs/deepagents/src/sandbox-ptc/network-policy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { describe, it, expect } from "vitest";

import { findMatchingRule, summarizePolicy } from "./network-policy.js";
import type { NetworkPolicy } from "./types.js";

const basePolicy: NetworkPolicy = {
allowed: {
"google.com": {},
"api.google.com/v1": {
headers: { "X-Api-Key": "key-123" },
methods: ["GET", "POST"],
maxResponseBytes: 5 * 1024 * 1024,
timeoutMs: 10_000,
},
"api.google.com": {},
},
blocked: ["169.254.169.254", "api.google.com/v2"],
defaultHeaders: { "User-Agent": "DeepAgent/1.0" },
defaultMaxResponseBytes: 10 * 1024 * 1024,
defaultTimeoutMs: 30_000,
};

describe("findMatchingRule", () => {
it("should allow requests to a whitelisted host", () => {
const result = findMatchingRule("https://google.com/search?q=hello", "GET", basePolicy);
expect(result.allowed).toBe(true);
});

it("should reject requests to a non-whitelisted host", () => {
const result = findMatchingRule("https://evil.com/steal", "GET", basePolicy);
expect(result.allowed).toBe(false);
if (!result.allowed) {
expect(result.reason).toContain("not in allowed list");
}
});

it("should block requests matching the blocked list", () => {
const result = findMatchingRule("http://169.254.169.254/latest/meta-data/", "GET", basePolicy);
expect(result.allowed).toBe(false);
if (!result.allowed) {
expect(result.reason).toContain("Blocked by policy");
}
});

it("should block path-specific blocked entries", () => {
const result = findMatchingRule("https://api.google.com/v2/translate", "GET", basePolicy);
expect(result.allowed).toBe(false);
if (!result.allowed) {
expect(result.reason).toContain("Blocked by policy");
}
});

it("should match the most specific allowed rule", () => {
const result = findMatchingRule("https://api.google.com/v1/search", "GET", basePolicy);
expect(result.allowed).toBe(true);
if (result.allowed) {
expect(result.mergedHeaders["X-Api-Key"]).toBe("key-123");
expect(result.maxResponseBytes).toBe(5 * 1024 * 1024);
expect(result.timeoutMs).toBe(10_000);
}
});

it("should fall back to less specific rule when path doesn't match", () => {
const result = findMatchingRule("https://api.google.com/v3/other", "GET", basePolicy);
expect(result.allowed).toBe(true);
if (result.allowed) {
expect(result.mergedHeaders["X-Api-Key"]).toBeUndefined();
expect(result.maxResponseBytes).toBe(10 * 1024 * 1024);
}
});

it("should merge defaultHeaders with rule headers", () => {
const result = findMatchingRule("https://api.google.com/v1/search", "GET", basePolicy);
expect(result.allowed).toBe(true);
if (result.allowed) {
expect(result.mergedHeaders["User-Agent"]).toBe("DeepAgent/1.0");
expect(result.mergedHeaders["X-Api-Key"]).toBe("key-123");
}
});

it("should enforce method restrictions", () => {
const getResult = findMatchingRule("https://api.google.com/v1/search", "GET", basePolicy);
expect(getResult.allowed).toBe(true);

const deleteResult = findMatchingRule("https://api.google.com/v1/search", "DELETE", basePolicy);
expect(deleteResult.allowed).toBe(false);
if (!deleteResult.allowed) {
expect(deleteResult.reason).toContain("Method DELETE not allowed");
}
});

it("should allow all methods when no restriction is set", () => {
const result = findMatchingRule("https://google.com/anything", "DELETE", basePolicy);
expect(result.allowed).toBe(true);
});

it("should reject invalid URLs", () => {
const result = findMatchingRule("not-a-url", "GET", basePolicy);
expect(result.allowed).toBe(false);
if (!result.allowed) {
expect(result.reason).toContain("Invalid URL");
}
});

it("should use defaults when no per-rule overrides exist", () => {
const result = findMatchingRule("https://google.com/test", "GET", basePolicy);
expect(result.allowed).toBe(true);
if (result.allowed) {
expect(result.maxResponseBytes).toBe(10 * 1024 * 1024);
expect(result.timeoutMs).toBe(30_000);
}
});
});

describe("summarizePolicy", () => {
it("should list allowed origins and blocked entries", () => {
const summary = summarizePolicy(basePolicy);
expect(summary).toContain("google.com");
expect(summary).toContain("api.google.com/v1");
expect(summary).toContain("169.254.169.254");
expect(summary).toContain("api.google.com/v2");
});
});
Loading
Loading