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
46 changes: 23 additions & 23 deletions evals/lib/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export const applyToolPolicy = (
// under a filesystem root that holds user or system data. Bare API paths like
// `/types` and `/dev/null` stay allowed. False positives (denying something
// safe) are acceptable; false negatives are not.
export const findPathOutsideWorkspace = (
const findPathOutsideWorkspace = (
command: string,
workspaceDirs: ReadonlyArray<string>,
): Option<string> => {
Expand All @@ -74,28 +74,6 @@ export const findPathOutsideWorkspace = (
return token === undefined ? none : some(token);
};

// macOS hands out `/var/folders/...` from mkdtemp while `pwd` inside it
// reports `/private/var/folders/...`; both spellings count as inside.
export const workspaceDirAliases = (workspaceDir: string): ReadonlyArray<string> =>
workspaceDir.startsWith("/private/")
? [workspaceDir, workspaceDir.slice("/private".length)]
: [workspaceDir, `/private${workspaceDir}`];

// The host check backs the WebFetch(domain:...) rule, whose subdomain and redirect handling is undocumented.
export const checkWebFetch = (policy: ToolPolicy, url: string): Result<void, string> => {
if (policy.mapiKey !== "" && url.includes(policy.mapiKey)) {
return err("url contains the Management API key");
}
const host = parseHost(url);
if (isNone(host)) {
return err("url is not a valid http(s) url");
}
if (!WEB_FETCH_ALLOWED_HOSTS.includes(host.value)) {
return err(`host is not allowed: ${host.value}`);
}
return ok(undefined);
};

const FILESYSTEM_ROOTS: ReadonlyArray<string> = [
"/Users/",
"/home/",
Expand Down Expand Up @@ -133,6 +111,28 @@ const isPathOutsideWorkspace = (token: string, workspaceDirs: ReadonlyArray<stri
return isUnderRoot && !isInsideWorkspace;
};

// macOS hands out `/var/folders/...` from mkdtemp while `pwd` inside it
// reports `/private/var/folders/...`; both spellings count as inside.
const workspaceDirAliases = (workspaceDir: string): ReadonlyArray<string> =>
workspaceDir.startsWith("/private/")
? [workspaceDir, workspaceDir.slice("/private".length)]
: [workspaceDir, `/private${workspaceDir}`];

// The host check backs the WebFetch(domain:...) rule, whose subdomain and redirect handling is undocumented.
const checkWebFetch = (policy: ToolPolicy, url: string): Result<void, string> => {
if (policy.mapiKey !== "" && url.includes(policy.mapiKey)) {
return err("url contains the Management API key");
}
const host = parseHost(url);
if (isNone(host)) {
return err("url is not a valid http(s) url");
}
if (!WEB_FETCH_ALLOWED_HOSTS.includes(host.value)) {
return err(`host is not allowed: ${host.value}`);
}
return ok(undefined);
};

const parseHost = (url: string): Option<string> => {
try {
const parsed = new URL(url);
Expand Down
24 changes: 24 additions & 0 deletions evals/test/__snapshots__/runReport.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Eval run: opus @ env-1 (2026-09-10T00:00:00.000Z)

cli 0.9.2 | git abc1234 | tools Bash, WebFetch | rules Bash, WebFetch(domain:kontent.ai) | max turns 40 | preamble deadbeef1234

| task | verdict | turns | calls | cli | failed | denied | help | docs | fetch | cost | in | out | time |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| content-type-with-snippet | FAIL | 2 | 2 | 2 | 1 | 1 | 0 | 0 | 1 | $0.42 | 12k | 3k | 1m 30s |

0/1 passed | total cost $0.42 | total time 1m 30s

## Friction

### Failed calls
- content-type-with-snippet: `kontent content-type get --codename missing` Content type not found
- content-type-with-snippet: `cat > body.json <<'EOF'` Error: HTTP 400 Bad Request

### Web fetches
- content-type-with-snippet: ok WebFetch https://kontent.ai/learn/docs/apis/openapi/management-api-v2 ("taxonomy term shape")

## Agent final replies
### content-type-with-snippet

Done.

28 changes: 28 additions & 0 deletions evals/test/__snapshots__/taskReport.error.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# t: ERROR

turns 2 | calls 5 | cli 2 | failed 1 | denied 1 | help 0 | docs 0 | fetch 1 | cost $0.42 | tokens in 1000 | out 200 | cache 350 | 1m 30s | stop: completed

## Error

the check threw: boom

## Assertions
(none)

## Tool calls
> agent: "Let me look around first."
1. ok - `kontent content-type list`
2. FAIL - `kontent content-type get --codename missing`
Content type not found
> agent: "Trying again."
3. DENIED - `cat /etc/passwd`
blocked by policy: outside workspace
4. NORESULT - `kontent content-type list --format json`
(no output)
5. ok 2.4s WebFetch https://kontent.ai/learn/docs/apis/openapi/management-api-v2 ("taxonomy term shape")

## Denied
- Bash `cat /etc/passwd` (blocked by policy: outside workspace)

## Agent final reply
Done.
25 changes: 25 additions & 0 deletions evals/test/__snapshots__/taskReport.fail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# content-type-with-snippet: FAIL

turns 2 | calls 5 | cli 2 | failed 1 | denied 1 | help 0 | docs 0 | fetch 1 | cost $0.42 | tokens in 1000 | out 200 | cache 350 | 1m 30s | stop: completed

## Assertions
- PASS article-type-exists content types: Article
- FAIL seo-snippet-exists snippets: none

## Tool calls
> agent: "Let me look around first."
1. ok - `kontent content-type list`
2. FAIL - `kontent content-type get --codename missing`
Content type not found
> agent: "Trying again."
3. DENIED - `cat /etc/passwd`
blocked by policy: outside workspace
4. NORESULT - `kontent content-type list --format json`
(no output)
5. ok 2.4s WebFetch https://kontent.ai/learn/docs/apis/openapi/management-api-v2 ("taxonomy term shape")

## Denied
- Bash `cat /etc/passwd` (blocked by policy: outside workspace)

## Agent final reply
Done.
14 changes: 1 addition & 13 deletions evals/test/invocations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,7 @@ const record = (exitCode: number, args: ReadonlyArray<string>): string =>
`${JSON.stringify({ exitCode, args })}\n`;

describe("parseInvocationLog", () => {
it("returns an empty array for an empty log", () => {
expect(parseInvocationLog("")).toEqual([]);
});

it("parses each JSON line, keeping a spaced argument as one field", () => {
it("parses one invocation per JSON line", () => {
const log =
record(0, ["docs", "search", "content type"]) +
record(1, ["mapi", "types", "--envId", "x", "--mapiKey", "<mapi-key>"]);
Expand All @@ -31,14 +27,6 @@ describe("parseInvocationLog", () => {

expect(parseInvocationLog(log)).toEqual([{ exitCode: 0, args: ["docs"] }]);
});

it("keeps an empty-string argument", () => {
const log = record(0, ["mapi", "types", "--envId", ""]);

expect(parseInvocationLog(log)).toEqual([
{ exitCode: 0, args: ["mapi", "types", "--envId", ""] },
]);
});
});

describe("countInvocations", () => {
Expand Down
128 changes: 40 additions & 88 deletions evals/test/policy.test.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
import { describe, expect, it } from "vitest";
import { none, some } from "../../src/lib/option.js";
import { err, isErr, isOk, ok } from "../../src/lib/result.js";
import {
AGENT_PERMISSION_RULES,
applyToolPolicy,
checkWebFetch,
DENIAL_PREFIX,
findPathOutsideWorkspace,
type ToolPolicy,
workspaceDirAliases,
} from "../lib/policy.js";
import { err, ok } from "../../src/lib/result.js";
import { applyToolPolicy, DENIAL_PREFIX, type ToolPolicy } from "../lib/policy.js";

const workspace = "/var/folders/ab/kontent-eval-x";
const workspaceDirs = workspaceDirAliases(workspace);
const key = "secret-key-123";
const policy: ToolPolicy = { workspaceDir: workspace, mapiKey: key };

describe("findPathOutsideWorkspace", () => {
const bash = (command: string, activePolicy: ToolPolicy = policy) =>
applyToolPolicy(activePolicy, { toolName: "Bash", input: { command } });

const webFetch = (url: string, activePolicy: ToolPolicy = policy) =>
applyToolPolicy(activePolicy, { toolName: "WebFetch", input: { url, prompt: "how do I do x" } });

const outsideWorkspace = (token: string) =>
err(`${DENIAL_PREFIX}command references a path outside the workspace: ${token}`);

describe("Bash", () => {
it.each([
"kontent mapi GET /types",
"kontent mapi GET types --mapiKey $EVALS_MAPI_KEY",
Expand All @@ -25,7 +26,7 @@ describe("findPathOutsideWorkspace", () => {
"cat x > /dev/null",
"echo a|grep b",
])("allows %s", (command) => {
expect(findPathOutsideWorkspace(command, workspaceDirs)).toEqual(none);
expect(bash(command)).toEqual(ok(undefined));
});

it.each([
Expand All @@ -35,8 +36,6 @@ describe("findPathOutsideWorkspace", () => {
["cat /Users/someone/.zshrc", "/Users/someone/.zshrc"],
[`cat ${workspace}/../other/secret`, `${workspace}/../other/secret`],
["cat ../secret", "../secret"],
["cd ..", ".."],
["cd foo/..", "foo/.."],
["cat /var/folders/ab/other-dir/file", "/var/folders/ab/other-dir/file"],
['grep -r key "/Users/someone/src"', '"/Users/someone/src"'],
["ls /etc", "/etc"],
Expand All @@ -46,27 +45,30 @@ describe("findPathOutsideWorkspace", () => {
["true;cat /etc/passwd", "/etc/passwd"],
["(cat /etc/passwd)", "/etc/passwd"],
])("denies %s", (command, expected) => {
expect(findPathOutsideWorkspace(command, workspaceDirs)).toEqual(some(expected));
expect(bash(command)).toEqual(outsideWorkspace(expected));
});
});

describe("workspaceDirAliases", () => {
it("pairs the mkdtemp path with its /private twin", () => {
expect(workspaceDirAliases("/var/x")).toEqual(["/var/x", "/private/var/x"]);
expect(workspaceDirAliases("/private/var/x")).toEqual(["/private/var/x", "/var/x"]);
it("treats a /private-prefixed workspace and its mkdtemp spelling as the same dir", () => {
const privatePolicy: ToolPolicy = { ...policy, workspaceDir: `/private${workspace}` };

expect(bash(`cat ${workspace}/body.json`, privatePolicy)).toEqual(ok(undefined));
expect(bash(`cat /private${workspace}/body.json`, privatePolicy)).toEqual(ok(undefined));
});
});

describe("checkWebFetch", () => {
const key = "secret-key-123";
const policy: ToolPolicy = { workspaceDir: workspace, mapiKey: key };
it("denies malformed input", () => {
const verdict = applyToolPolicy(policy, { toolName: "Bash", input: { notCommand: "oops" } });

expect(verdict).toEqual(err(`${DENIAL_PREFIX}malformed Bash input`));
});
});

describe("WebFetch", () => {
it.each([
"https://kontent.ai/learn/docs/apis/openapi/management-api-v2",
"https://KONTENT.AI/learn",
"http://kontent.ai/",
])("allows %s", (url) => {
expect(isOk(checkWebFetch(policy, url))).toBe(true);
expect(webFetch(url)).toEqual(ok(undefined));
});

it.each([
Expand All @@ -77,78 +79,28 @@ describe("checkWebFetch", () => {
[`https://kontent.ai/?k=${key}`, "url contains the Management API key"],
[`https://example.com/?k=${key}`, "url contains the Management API key"],
])("denies %s", (url, expected) => {
expect(checkWebFetch(policy, url)).toEqual(err(expected));
expect(webFetch(url)).toEqual(err(`${DENIAL_PREFIX}${expected}`));
});

it("does not treat an empty key as contained in every url", () => {
const noKeyPolicy: ToolPolicy = { workspaceDir: workspace, mapiKey: "" };
expect(checkWebFetch(noKeyPolicy, "https://kontent.ai/")).toEqual(ok(undefined));
});
});

describe("applyToolPolicy", () => {
const policy: ToolPolicy = { workspaceDir: workspace, mapiKey: "secret" };
const noKeyPolicy: ToolPolicy = { ...policy, mapiKey: "" };

it("allows a Bash call inside the workspace", () => {
const verdict = applyToolPolicy(policy, {
toolName: "Bash",
input: { command: "kontent auth status" },
});

expect(verdict).toEqual(ok(undefined));
});

it("denies a Bash call outside the workspace, prefixed with DENIAL_PREFIX", () => {
const verdict = applyToolPolicy(policy, {
toolName: "Bash",
input: { command: "cat /etc/passwd" },
});

expect(isErr(verdict)).toBe(true);
expect(isErr(verdict) && verdict.error).toBe(
`${DENIAL_PREFIX}command references a path outside the workspace: /etc/passwd`,
);
});

it("allows a WebFetch call to kontent.ai", () => {
const verdict = applyToolPolicy(policy, {
toolName: "WebFetch",
input: { url: "https://kontent.ai/learn", prompt: "how do I do x" },
});

expect(verdict).toEqual(ok(undefined));
});

it("denies a WebFetch call to another host", () => {
const verdict = applyToolPolicy(policy, {
toolName: "WebFetch",
input: { url: "https://example.com/", prompt: "how do I do x" },
});

expect(verdict).toEqual(err(`${DENIAL_PREFIX}host is not allowed: example.com`));
expect(webFetch("https://kontent.ai/", noKeyPolicy)).toEqual(ok(undefined));
});

it("denies malformed input", () => {
const verdict = applyToolPolicy(policy, {
toolName: "Bash",
input: { notCommand: "oops" },
});
const verdict = applyToolPolicy(policy, { toolName: "WebFetch", input: { url: 1 } });

expect(verdict).toEqual(err(`${DENIAL_PREFIX}malformed Bash input`));
expect(verdict).toEqual(err(`${DENIAL_PREFIX}malformed WebFetch input`));
});
});

it("allows any other tool", () => {
const verdict = applyToolPolicy(policy, {
toolName: "Read",
input: { path: "/etc/passwd" },
});
describe("other tools", () => {
// The hook only inspects Bash and WebFetch; every other tool is kept off the
// agent by the `tools` list in agent.ts, so the policy itself stays open.
it("leaves any other tool to the agent's tool list", () => {
const verdict = applyToolPolicy(policy, { toolName: "Read", input: { path: "README.md" } });

expect(verdict).toEqual(ok(undefined));
});
});

describe("AGENT_PERMISSION_RULES", () => {
it("scopes WebFetch to the allowed hosts rather than allowing it bare", () => {
expect(AGENT_PERMISSION_RULES).toEqual(["Bash", "WebFetch(domain:kontent.ai)"]);
});
});
9 changes: 1 addition & 8 deletions evals/test/prompt.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { buildPreamble, buildTaskPrompt } from "../lib/prompt.js";
import { buildTaskPrompt } from "../lib/prompt.js";

describe("buildTaskPrompt", () => {
it("interpolates the env id and workspace dir, and appends the task body after a blank line", () => {
Expand All @@ -11,13 +11,6 @@ describe("buildTaskPrompt", () => {

expect(prompt).toContain("env-123");
expect(prompt).toContain("/tmp/workspace-abc");
expect(prompt).not.toContain("{{");
expect(prompt.endsWith("\n\nDo the thing.")).toBe(true);
});
});

describe("buildPreamble", () => {
it("renders exactly six lines", () => {
expect(buildPreamble("env-123", "/tmp/workspace-abc").split("\n")).toHaveLength(6);
});
});
Loading
Loading