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
9 changes: 1 addition & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: git submodule update --init --recursive
- uses: actions/setup-node@v4
with:
node-version: lts/*
cache: npm

- run: cd packages/capnweb && npm ci && npm run build
- run: npm ci
- run: npm run build
- run: cd website && npm ci
Expand All @@ -46,13 +44,8 @@ jobs:
with:
node-version: ${{ matrix.node }}
cache: npm
- name: Install Deno
uses: denoland/setup-deno@v2
with:
deno-version: latest

- run: cd packages/capnweb && npm ci && npm run build
- run: npm ci
- run: npm run build
- run: npm run test
- run: cd examples && npm ci &&npm run ci
- run: cd examples && npm ci && npm run check
2 changes: 0 additions & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: git submodule update --init --recursive
- uses: actions/setup-node@v4
with:
node-version: lts/*
cache: npm
registry-url: https://registry.npmjs.org

- run: cd packages/capnweb && npm ci && npm run build
- run: npm ci
- run: npm run build
- run: ./bin/bump-and-publish.sh
Expand Down
17 changes: 7 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,15 +73,14 @@ class User extends db.Table('users').as('user') {
```typescript
import { google } from '@ai-sdk/google'
import { generateText, stepCountIs } from 'ai'
import { CodeMode, createDenoSandbox } from 'exoagent'
import { codemode } from 'exoagent'

// Create a capability scoped to user_id=1
const userCap = User.on(u => u.id['='](1)).from()

// Wrap with CodeMode for sandboxed execution
const codeMode = new CodeMode(createDenoSandbox())
const codeTool = await codeMode.wrap({
currentUser: () => userCap,
// Wrap with codemode for sandboxed execution
const codeTool = await codemode({
currentUser: userCap,
}, schemaString) // schemaString = the class definitions above as a string

const result = await generateText({
Expand Down Expand Up @@ -115,17 +114,15 @@ npx tsx saas-bot.ts

Note the examples require:
1. NodeJS (runtime)
2. [Deno](https://docs.deno.com/runtime/getting_started/installation/) (sandbox)
3. An LLM API key set via one of the env vars:
2. An LLM API key set via one of the env vars:
- `OPENAI_API_KEY`
- `ANTHROPIC_API_KEY`
- `GOOGLE_GENERATIVE_AI_API_KEY`

## Architecture
ExoAgent sits between your LLM and your infrastructure as a regular tool.
1. **Protocol**: Uses [Cap'n Web](https://github.com/cloudflare/capnweb) (RPC) as the transport.
2. **Runtime**: Runs in a JS code sandbox (user-configured; Deno supported out of the box, more to come).
3. **Query Builder**: Uses a custom capability SQL builder that compiles to safe SQL.
1. **Evaluator**: A custom sandboxed JavaScript evaluator (`exoeval`) that only allows safe operations.
2. **Query Builder**: A capability-based SQL builder that compiles to safe SQL with scoped access.

## ⚠️ Project Status: Experimental (v0.0.x)
ExoAgent is an exploration of capability-based security for LLMs. While the architecture (OCaps + Sandboxing) is theoretically robust, this specific implementation is new and may contain bugs.
Expand Down
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export default antfu(
'style/max-statements-per-line': 'off',
'ts/no-this-alias': 'off',
'antfu/no-top-level-await': 'off',
'test/prefer-lowercase-title': 'off',
},
},
)
4 changes: 2 additions & 2 deletions examples/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
"saas-bot": "tsx saas-bot.ts",
"test": "vitest --run",
"typecheck": "tsc --noEmit",
"ci": "npm run typecheck && npm run test",
"npm-install-test": "TMP=$(mktemp -d --suffix=-exoagent-examples) && cp -r . $TMP && npm --prefix $TMP i exoagent@$(npm show .. version) && npm --prefix $TMP run ci"
"check": "npm run typecheck && npm run test",
"npm-install-test": "TMP=$(mktemp -d --suffix=-exoagent-examples) && cp -r . $TMP && npm --prefix $TMP i exoagent@$(npm show .. version) && npm --prefix $TMP run check"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.23",
Expand Down
10 changes: 5 additions & 5 deletions examples/saas-bot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ describe('saas-bot example e2e', () => {
const model = createMockModel([
{
code: `async ({ organization }) => {
return await organization()
return await organization
.join(({ org }) => org.projects())
.select(({ project }) => ({ name: project.name, status: project.status }))
.execute()
Expand All @@ -34,7 +34,7 @@ describe('saas-bot example e2e', () => {
const model = createMockModel([
{
code: `async ({ organization }) => {
return await organization()
return await organization
.join(({ org }) => org.projects())
.join(({ project }) => project.tasks())
.select(({ project, task }) => ({
Expand Down Expand Up @@ -69,7 +69,7 @@ describe('saas-bot example e2e', () => {
const model = createMockModel([
{
code: `async ({ organization }) => {
return await organization()
return await organization
.join(({ org }) => org.projects())
.join(({ project }) => project.tasks())
.join(({ task }) => task.comments())
Expand Down Expand Up @@ -102,7 +102,7 @@ describe('saas-bot example e2e', () => {
const model = createMockModel([
{
code: `async ({ organization }) => {
return await organization()
return await organization
.join(({ org }) => org.members())
.select(({ member }) => ({ name: member.name, role: member.role }))
.execute()
Expand All @@ -129,7 +129,7 @@ describe('saas-bot example e2e', () => {
const model = createMockModel([
{
code: `async ({ organization }) => {
return await organization()
return await organization
.join(({ org }) => org.projects())
.join(({ project }) => project.tasks())
.select(({ task }) => ({ title: task.title, status: task.status, priority: task.priority }))
Expand Down
15 changes: 7 additions & 8 deletions examples/saas-bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import type { LanguageModel } from 'ai'
import process from 'node:process'
import { generateText, stepCountIs } from 'ai'
import BetterSqlite3 from 'better-sqlite3'
import { CodeMode, createDenoSandbox, tool } from 'exoagent'
import { codemode, tool } from 'exoagent'
import { Database } from 'exoagent/sql'
import { SqliteDialect } from 'kysely'
import { getModel, runRepl } from './utils'
Expand Down Expand Up @@ -200,10 +200,9 @@ async function chat(userPrompt: string, model: LanguageModel, orgId: number = 1)
// Create a capability scoped to the specified organization
const orgCap = Organization.on(o => o.id['='](orgId)).from()

// Wrap with CodeMode for sandboxed execution
const codeMode = new CodeMode(createDenoSandbox())
const codeTool = await codeMode.wrap({
organization: () => orgCap,
// Wrap with codemode for sandboxed execution
const codeTool = await codemode({
organization: orgCap,
}, `class Comment extends db.Table('comments').as('comment') {
id = this.column('id')
taskId = this.column('task_id')
Expand Down Expand Up @@ -281,15 +280,15 @@ class Organization extends db.Table('organizations').as('org') {
You have access to the current organization's data including projects, tasks, and team members.

Use the execute tool to query the database. The API provides:
- organization(): Returns a query builder for the current org
- organization: Returns a query builder for the current org
- org.members(): Returns the org's team members
- org.projects(): Returns the org's projects
- project.tasks(): Returns a project's tasks
- task.comments(): Returns a task's comments

Examples:
- (api) => api.organization().join(({ org }) => org.members()).select(({ member }) => member).execute()
- (api) => api.organization().join(({ org }) => org.projects()).join(({ project }) => project.tasks()).select(({ project, task }) => ({ projectName: project.name, taskName: task.title })).execute()
- (api) => api.organization.join(({ org }) => org.members()).select(({ member }) => member).execute()
- (api) => api.organization.join(({ org }) => org.projects()).join(({ project }) => project.tasks()).select(({ project, task }) => ({ projectName: project.name, taskName: task.title })).execute()

Select must return a row object (not a flat column).

Expand Down
6 changes: 3 additions & 3 deletions examples/simple.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ describe('simple example e2e', () => {
const model = createMockModel([
{
code: `async ({ currentUser }) => {
return await currentUser()
return await currentUser
.join(({ user }) => user.todos())
.select(({ todo }) => ({ title: todo.title, completed: todo.completed }))
.execute()
Expand All @@ -34,7 +34,7 @@ describe('simple example e2e', () => {
const model = createMockModel([
{
code: `async ({ currentUser }) => {
return await currentUser()
return await currentUser
.join(({ user }) => user.todos())
.select(({ todo }) => ({ title: todo.title, completed: todo.completed }))
.where(({ todo }) => todo.completed['='](0))
Expand All @@ -61,7 +61,7 @@ describe('simple example e2e', () => {
const model = createMockModel([
{
code: `async ({ currentUser }) => {
return await currentUser()
return await currentUser
.join(({ user }) => user.todos())
.select(({ user, todo }) => ({ userName: user.name, title: todo.title }))
.execute()
Expand Down
17 changes: 7 additions & 10 deletions examples/simple.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import type { LanguageModel } from 'ai'
import process from 'node:process'
import { generateText, stepCountIs } from 'ai'
import BetterSqlite3 from 'better-sqlite3'
import { CodeMode, createDenoSandbox, tool } from 'exoagent'
import { codemode, tool } from 'exoagent'
import { Database } from 'exoagent/sql'
import { SqliteDialect } from 'kysely'
import { getModel, runRepl } from './utils'
Expand Down Expand Up @@ -88,11 +88,8 @@ async function chat(userPrompt: string, model: LanguageModel, userId: number = 1
// Create a capability scoped to the specified user
const userCap = User.on(u => u.id['='](userId)).from()

// Wrap with CodeMode for sandboxed execution
const codeMode = new CodeMode(createDenoSandbox())
const codeTool = await codeMode.wrap({
currentUser: () => userCap,
}, `class Todo extends db.Table('todos').as('todo') {
// Wrap with codemode for sandboxed execution
const codeTool = await codemode({ currentUser: userCap }, `class Todo extends db.Table('todos').as('todo') {
id = this.column('id')
userId = this.column('user_id')
title = this.column('title')
Expand All @@ -118,14 +115,14 @@ class User extends db.Table('users').as('user') {
system: `You are a helpful assistant that helps users manage their todos.
You have access to the current user's information and their todos.
Use the execute tool to query the database. The API provides:
- currentUser(): Returns a query builder for the current user's data
- currentUser: Returns a query builder for the current user's data
- user.todos(): Returns a query builder for the user's todos.

Example:
- (api) => api.currentUser().join(({ user }) => user.todos()).select(({ todo }) => ({title: todo.title, completed: todo.completed})).execute()
- (api) => api.currentUser().join(({ user }) => user.todos()).where(({ todo }) => todo.completed['='](0)).select(({ todo }) => ({title: todo.title, completed: todo.completed})).execute()
- (api) => api.currentUser.join(({ user }) => user.todos()).select(({ todo }) => ({title: todo.title, completed: todo.completed})).execute()
- (api) => api.currentUser.join(({ user }) => user.todos()).where(({ todo }) => todo.completed['='](0)).select(({ todo }) => ({title: todo.title, completed: todo.completed})).execute()

To do a SELECT *, use this shorthand: (api) => api.currentUser().join(({ user }) => user.todos()).select(({ user }) => user)
To do a SELECT *, use this shorthand: (api) => api.currentUser.join(({ user }) => user.todos()).select(({ user }) => user)
Select must return a row object (not a flat column).

Always use .execute() at the end of your query chains to get results.`,
Expand Down
33 changes: 24 additions & 9 deletions package-lock.json

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

14 changes: 5 additions & 9 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,32 +40,27 @@
],
"scripts": {
"clean": "rm -rf dist",
"build": "npm run clean && npm run build:runtime && npm run build:main && npm run build:capnweb && npm run build:test-deps",
"build": "npm run clean && npm run build:main",
"build:main": "vite build",
"build:runtime": "esbuild src/code-mode-runtime.ts --bundle --format=esm --target=es2022 --outfile=dist/code-mode-runtime.mjs --packages=external",
"build:capnweb": "npm pack ./packages/capnweb --pack-destination dist && mkdir -p dist/capnweb && tar -xzf dist/capnweb-*.tgz --strip-components=1 -C dist/capnweb && rm dist/capnweb-*.tgz",
"build:test-deps": "tsdown --config tsdown.test-deps.config.ts",
"dev": "tsdown --watch",
"lint": "eslint",
"prepublishOnly": "npm run build",
"release": "bumpp",
"start": "tsx src/index.ts",
"pretest": "npm run build:runtime",
"test": "vitest --run",
"typecheck": "tsc",
"ci:capnweb": "cd packages/capnweb && npm ci && npm run build",
"ci:examples": "npm --prefix ./examples run ci",
"ci": "npm run ci:capnweb && npm ci && npm run lint && npm run typecheck && npm run build && npm run test && npm run ci:examples"
"check:examples": "npm --prefix ./examples run check",
"check": "npm ci && npm run lint && npm run typecheck && npm run build && npm run test && npm run check:examples"
},
"peerDependencies": {
"ai": "^6.0.0"
},
"dependencies": {
"camelcase": "^9.0.0",
"capnweb": "file:dist/capnweb",
"json-schema": "^0.4.0",
"json-schema-to-typescript": "^15.0.0",
"kysely": "^0.28.9",
"secure-json-parse": "^4.1.0",
"tiny-invariant": "^1.3.3",
"zod": "^4.2.1",
"zod-to-json-schema": "^3.25.0"
Expand All @@ -77,6 +72,7 @@
"@electric-sql/pglite": "^0.3.15",
"@standard-schema/spec": "^1.1.0",
"@types/node": "25.0.1",
"acorn": "^8.16.0",
"bumpp": "10.3.2",
"eslint": "9.39.2",
"kysely-pglite-dialect": "^1.2.0",
Expand Down
1 change: 0 additions & 1 deletion packages/capnweb
Submodule capnweb deleted from e7025a
Loading