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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ ENCRYPTION_KEY="ZGV2LW9ubHktZW5jcnlwdGlvbi1rZXktMzItYnl0ZXM="
# SANDBOX_SERVICE_URL="dev"
# SANDBOX_SERVICE_TOKEN=""

# Files, a drive for each member, kept here with every version of each: a directory on this machine
# for their bytes, or a bucket that speaks S3's protocol (FILES_S3_BUCKET and its keys, in the docs).
# FILES_DIR="/tmp/proxy-files"
# Self-hosting reads more; every variable is in the docs' Environment variables page, built from
# packages/api/src/utils/env-schema.ts. `bun run dev` sets NODE_ENV=development on the api.

Expand Down
24 changes: 7 additions & 17 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ services:
CLAUDE_AGENT_SDK_TURNS: ${CLAUDE_AGENT_SDK_TURNS:-}
SANDBOX_SERVICE_URL: ${SANDBOX_SERVICE_URL:-}
SANDBOX_SERVICE_TOKEN: ${SANDBOX_SERVICE_TOKEN:-}
FILES_DIR: ${FILES_DIR:-}
FILES_S3_BUCKET: ${FILES_S3_BUCKET:-}
FILES_S3_ACCESS_KEY_ID: ${FILES_S3_ACCESS_KEY_ID:-}
FILES_S3_SECRET_ACCESS_KEY: ${FILES_S3_SECRET_ACCESS_KEY:-}
FILES_S3_ENDPOINT: ${FILES_S3_ENDPOINT:-}
FILES_S3_REGION: ${FILES_S3_REGION:-}
FILES_S3_PREFIX: ${FILES_S3_PREFIX:-}
FILES_MAX_MB: ${FILES_MAX_MB:-}
TELEMETRY_ENABLED: ${TELEMETRY_ENABLED:-}
logging: *logging

Expand Down
1 change: 1 addition & 0 deletions packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"nodemailer": "^10.0.15",
"quickjs-emscripten-core": "0.32.0",
"resend": "^6.7.0",
"sharp": "^0.35.5",
"sucrase": "3.35.1",
"tldts": "^7.4.19",
"ts-results-es": "^7.1.0",
Expand Down
17 changes: 17 additions & 0 deletions packages/api/src/agent-side/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -783,6 +783,23 @@ describe("an integration's own tools", () => {
expect(logAgentRequest).toHaveBeenCalledTimes(1);
});

test('one the agent lacks the action for runs all the same where the person allowed that very run, as its module says, and is logged as allowed', async () => {
getAgentById.mockResolvedValue(Ok({...agent, grants: []}));
const allowed = mock(async (run: {params: Record<string, unknown>}) => Ok(run.params.days === 30));
apiIntegration.mockImplementation((id) => (id === 'vault' ? {...vaultModule, allowances: {entries_purge: allowed}} : undefined));
purgeEntries.mockResolvedValue(Ok('Purged'));
const {runAgentTool} = await import('./tools');

expect((await runAgentTool(caller, 'vault-1', 'entries_purge', {days: 30})).unwrap()).toEqual({text: 'Purged'});
expect(allowed).toHaveBeenCalledWith({caller, connection: vaultConnection, params: {days: 30}});
expect(logAgentRequest.mock.calls[0]?.[0]).toMatchObject({action: 'entries_purge', outcome: 'allowed'});

// Another run of the same tool is refused as before.
logAgentRequest.mockClear();
expect((await runAgentTool(caller, 'vault-1', 'entries_purge', {days: 7})).unwrapErr().kind).toBe('forbidden');
expect(logAgentRequest.mock.calls[0]?.[0]).toMatchObject({action: 'entries_purge', outcome: 'denied'});
});

test('what the runner failed with is passed on, logged as failed; a tool without a runner is not found', async () => {
countEntries.mockResolvedValue(Err(ApiErr.validationError('Not a type of entry')));
const {runAgentTool} = await import('./tools');
Expand Down
95 changes: 66 additions & 29 deletions packages/api/src/agent-side/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {parseRecordValues} from '../records/record-values';
import {loadConnection} from '../records/target';
import {log as serverLog} from '../observability/log';
import {integrationOf} from '../utils/connection-actions';
import {ensureFilesConnection} from '../native/files/connections';
import {ensureSandboxConnection} from '../native/sandbox/connections';
import type {NativeToolRun} from '../native/tool';
import {requireOpenTask} from './tasks';
Expand Down Expand Up @@ -92,6 +93,7 @@ export function listAgentConnections(signedIn: SignedInAgent): Promise<Result<z.
return Do(async ($) => {
const agent = $(await requireAgent(signedIn));
$(await ensureSandboxConnection({...agentViewer(signedIn), experiments: signedIn.experiments}));
$(await ensureFilesConnection({...agentViewer(signedIn), experiments: signedIn.experiments}));
const connections = $(await listConnectionsWithCredential(agentViewer(signedIn)))
.flatMap((connection) => {
const integration = integrationOf(connection);
Expand Down Expand Up @@ -248,7 +250,7 @@ export function runAgentTool(caller: TaskCaller, connectionId: string, toolName:
const {connection, integration} = $(connectionRead);
const tool = $(requirePresent(findTool(integration, toolName), ApiErr.notFound('tool', toolName)));
if (tool.kind === 'call' || tool.kind === 'native') {
return $(await runParamsTool(caller, agent, connection, integration, tool, params, startedAt));
return $(await runParamsTool({caller, agent, connection, integration, startedAt}, tool, params));
}

const collection = $(requirePresent(findCollection(integration, tool.collectionId), ApiErr.notFound('tool', toolName)));
Expand Down Expand Up @@ -312,36 +314,53 @@ function runNativeTool(integration: Integration, tool: ApiTool, run: NativeToolR
return runner(run);
}

// A tool with params of its own shape: a custom server's, called as it is, its params going to
// the server as given, which checks them against its own schema; or one of the integration's own,
// run by the api. Its answer comes back as text. Logged by the tool's name, with its params,
// allowed, denied or failed; one the agent lacks the action for, or the one it needs beside it, is
// refused as a collection's tool is, saying why.
function runParamsTool(
caller: TaskCaller,
agent: AgentRow,
connection: ConnectionRow & {credential: string | null},
integration: Integration,
tool: ServerTool | ApiTool,
params: unknown,
startedAt: number,
): Promise<Result<ToolResult, ApiError>> {
// Whether the person allowed the run by what they did, whatever the agent's access: for one of
// the integration's own tools whose module says so (native/tool.ts NativeToolAllowance).
function allowedAnyway(integration: Integration, tool: ServerTool | ApiTool, run: NativeToolRun): Promise<Result<boolean, ApiError>> {
const allowance = tool.kind === 'native' ? apiIntegration(integration.id)?.allowances?.[tool.name] : undefined;
if (!allowance) {
return Promise.resolve(Ok(false));
}
return allowance(run);
}

/** What a run is checked against: the agent, for the caller's task, on a connection as the agent reaches it now, since `startedAt`. */
export type RunTarget = {caller: TaskCaller; agent: AgentRow; connection: ConnectionRow & {credential: string | null}; integration: Integration; startedAt: number};

/** The agent and the connection a run of the caller's is for, read at once; the caller's task is the caller's to have checked. */
export function loadRunTarget(caller: TaskCaller, connectionId: string): Promise<Result<RunTarget, ApiError>> {
return Do(async ($) => {
const args = params ?? {};
if (!isPlainObject(args)) {
return $(Err(ApiErr.validationError(`${tool.name} takes an object of params`)));
}
const logged = loggedParams(args);
const startedAt = Date.now();
const [agentRead, connectionRead] = await Promise.all([requireAgent(caller.agent), loadConnection(agentViewer(caller.agent), connectionId)]);
const agent = $(agentRead);
const {connection, integration} = $(connectionRead);
return {caller, agent, connection, integration, startedAt};
});
}

/** What the activity log keeps of a run: the collection and the action it is logged as, the record's title, and the params. */
export type RunEntry = {collection: string; action: string; title: string | null; query: string | null};

/**
* The gate of a tool with params of its own, and of what code in a sandbox does as one does
* (native/files/cli.ts): `work` is done where the agent has the tool's action, and the one it
* needs beside it, or where the person allowed this very run by what they did, as by attaching
* the file it reads. Logged as `entry`: refused, as denied, saying why; failed, with what went
* wrong; or allowed.
*/
export function gateRun<T>(target: RunTarget, tool: ServerTool | ApiTool, params: Record<string, unknown>, entry: RunEntry, work: () => Promise<Result<T, ApiError>>): Promise<Result<T, ApiError>> {
return Do(async ($) => {
const {caller, agent, connection, integration, startedAt} = target;
const logRun = (outcome: Outcome, error?: string) => {
recordToolCall(caller, connection, tool.action, outcome, startedAt, error);
return logAgentRequest({
orgId: caller.agent.orgId,
agentId: caller.agent.agentId,
connectionId: connection.id,
collectionId: TOOLS_COLLECTION_ID,
action: tool.name,
recordTitle: null,
query: logged,
collectionId: entry.collection,
action: entry.action,
recordTitle: entry.title,
query: entry.query,
outcome,
via: caller.via,
taskId: caller.taskId,
Expand All @@ -353,16 +372,34 @@ function runParamsTool(
const unavailable = unavailableActions(connection);
const actions = actionsOf(agent, connection, integration, unavailable);
const missing = [tool.action, tool.requires].find((actionId) => actionId !== undefined && !actions.includes(actionId));
if (missing !== undefined) {
if (missing !== undefined && !$(await allowedAnyway(integration, tool, {caller, connection, params}))) {
$(await logRun('denied'));
return $(Err(actionRefusal(integration, agent, connection, unavailable, missing)));
}
const answer = tool.kind === 'call' ? await callCustomTool(connection, tool.name, args) : await runNativeTool(integration, tool, {caller, connection, params: args});
if (answer.isErr()) {
$(await logRun('failed', failureOf(answer.error)));
const done = await work();
if (done.isErr()) {
$(await logRun('failed', failureOf(done.error)));
}
const text = $(answer);
const value = $(done);
$(await logRun('allowed'));
return value;
});
}

// A tool with params of its own shape: a custom server's, called as it is, its params going to
// the server as given, which checks them against its own schema; or one of the integration's own,
// run by the api. Its answer comes back as text. Logged by the tool's name, with its params.
function runParamsTool(target: RunTarget, tool: ServerTool | ApiTool, params: unknown): Promise<Result<ToolResult, ApiError>> {
return Do(async ($) => {
const args = params ?? {};
if (!isPlainObject(args)) {
return $(Err(ApiErr.validationError(`${tool.name} takes an object of params`)));
}
const {caller, connection, integration} = target;
const entry = {collection: TOOLS_COLLECTION_ID, action: tool.name, title: null, query: loggedParams(args)};
const text = $(
await gateRun(target, tool, args, entry, () => (tool.kind === 'call' ? callCustomTool(connection, tool.name, args) : runNativeTool(integration, tool, {caller, connection, params: args}))),
);
return {text};
});
}
Expand Down
Loading
Loading