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
57 changes: 52 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,52 @@ Agent-native apps are single-tenant. Each deployment serves one organization. Yo

Per-user data isolation exists for multi-user organizations (via `owner_email` column convention and `AGENT_USER_EMAIL`), but large-scale multi-tenancy across organizations is not the architecture.

## Data Scoping

In production mode, the framework automatically restricts agent SQL queries (via `db-query` and `db-exec`) to the current user's data using temporary views. This is enforced at the SQL level — agents cannot bypass it.

### Per-User Scoping (`owner_email`)

Every template table that stores user-specific data **must** have an `owner_email` text column. The framework:

1. Detects tables with `owner_email` via schema introspection
2. Creates temp views with `WHERE owner_email = <current user>` before each query
3. Auto-injects `owner_email` into INSERT statements

The current user is resolved from `AGENT_USER_EMAIL` (set automatically from the session).

### Per-Org Scoping (`org_id`)

For multi-org apps (e.g., recruiting), tables can also include an `org_id` text column. When `AGENT_ORG_ID` is set:

1. Tables with `org_id` get an additional `WHERE org_id = <current org>` clause
2. When both `owner_email` and `org_id` are present, both filters apply (AND)
3. `org_id` is auto-injected into INSERT statements

Templates enable org scoping by providing a `resolveOrgId` callback in their agent-chat plugin:

```ts
createAgentChatPlugin({
resolveOrgId: async (event) => {
const ctx = await getOrgContext(event);
return ctx.orgId;
},
});
```

### Schema Validation

Run `pnpm action db-check-scoping` to verify all template tables have proper ownership columns. Use `--require-org` for multi-org apps. Tables without scoping columns are accessible to all users.

### Column Conventions

| Column | Purpose | Required |
| ------------- | ----------------------- | ------------------------------- |
| `owner_email` | Per-user data isolation | Yes, for all user-facing tables |
| `org_id` | Per-org data isolation | Yes, for multi-org apps |

**Hard rule: every new template table with user data must have `owner_email`.** Multi-org templates must also include `org_id`.

## A2A Protocol (Agent-to-Agent)

Agents can call other agents using the A2A protocol. From the mail app, you can tag the analytics agent to query data and include results in a draft. An agent discovers what other agents are available, calls them over the protocol, and shows results in the UI.
Expand Down Expand Up @@ -300,11 +346,12 @@ Run with: `pnpm action my-action --name foo`

### Core Actions (available automatically)

| Action | Purpose | Example |
| ----------- | ------------------------------- | -------------------------------------------------- |
| `db-schema` | Show all tables, columns, types | `pnpm action db-schema` |
| `db-query` | Run a SELECT query | `pnpm action db-query --sql "SELECT * FROM forms"` |
| `db-exec` | Run INSERT/UPDATE/DELETE | `pnpm action db-exec --sql "UPDATE forms SET ..."` |
| Action | Purpose | Example |
| ------------------ | -------------------------------- | -------------------------------------------------- |
| `db-schema` | Show all tables, columns, types | `pnpm action db-schema` |
| `db-query` | Run a SELECT query | `pnpm action db-query --sql "SELECT * FROM forms"` |
| `db-exec` | Run INSERT/UPDATE/DELETE | `pnpm action db-exec --sql "UPDATE forms SET ..."` |
| `db-check-scoping` | Validate ownership columns exist | `pnpm action db-check-scoping --require-org` |

Per-user data scoping is automatic in production mode via `AGENT_USER_EMAIL`.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ export function useIntegrationStatus() {
const fetchStatuses = useCallback(async () => {
try {
const res = await fetch("/_agent-native/integrations/status");
if (!res.ok) return;
if (!res.ok) {
if (mountedRef.current) setLoading(false);
return;
}
const data = await res.json();
if (mountedRef.current) {
setStatuses(Array.isArray(data) ? data : []);
Expand Down
208 changes: 208 additions & 0 deletions packages/core/src/scripts/db/check-scoping.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
/**
* Core script: db-check-scoping
*
* Validates that all template tables have the required ownership columns
* (owner_email, org_id) for per-user and per-org data scoping.
*
* Tables without these columns are invisible to the scoping system and
* will be accessible to all users in production mode.
*
* Usage:
* pnpm action db-check-scoping [--db path] [--require-org] [--format json]
*/

import path from "path";
import { createClient } from "@libsql/client";
import { getDatabaseUrl, getDatabaseAuthToken } from "../../db/client.js";
import { parseArgs } from "../utils.js";

function isPostgresUrl(url: string): boolean {
return url.startsWith("postgres://") || url.startsWith("postgresql://");
}

interface TableColumn {
table: string;
column: string;
}

// Core tables that have their own scoping — skip these in validation
const CORE_TABLES = new Set([
"settings",
"application_state",
"oauth_tokens",
"sessions",
// framework internal tables
"resources",
"chat_threads",
"chat_messages",
"chat_tasks",
"recurring_jobs",
// drizzle/migration tables
"__drizzle_migrations",
"_litestream_lock",
"_litestream_seq",
]);

interface ValidationResult {
table: string;
hasOwnerEmail: boolean;
hasOrgId: boolean;
issues: string[];
}

function validate(
allColumns: TableColumn[],
requireOrg: boolean,
): ValidationResult[] {
const columnsByTable = new Map<string, string[]>();
for (const { table, column } of allColumns) {
const cols = columnsByTable.get(table) || [];
cols.push(column);
columnsByTable.set(table, cols);
}

const results: ValidationResult[] = [];

for (const [table, columns] of columnsByTable) {
// Skip core/framework tables
if (CORE_TABLES.has(table)) continue;
// Skip migration-related tables
if (table.startsWith("_")) continue;

const hasOwnerEmail = columns.includes("owner_email");
const hasOrgId = columns.includes("org_id");
const issues: string[] = [];

if (!hasOwnerEmail) {
issues.push("missing owner_email column — not scoped per-user");
}
if (requireOrg && !hasOrgId) {
issues.push("missing org_id column — not scoped per-org");
}

results.push({ table, hasOwnerEmail, hasOrgId, issues });
}

return results;
}

async function discoverColumnsPostgres(pgSql: any): Promise<TableColumn[]> {
const rows: any[] = await pgSql`
SELECT table_name, column_name
FROM information_schema.columns
WHERE table_schema = 'public'
ORDER BY table_name, ordinal_position
`;
return rows.map((r) => ({ table: r.table_name, column: r.column_name }));
}

async function discoverColumnsSqlite(client: any): Promise<TableColumn[]> {
const tablesResult = await client.execute(
`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`,
);
const tables = tablesResult.rows.map((r: any) => (r.name ?? r[0]) as string);

const result: TableColumn[] = [];
for (const table of tables) {
const escaped = table.replace(/"/g, '""');
const colsResult = await client.execute(`PRAGMA table_info("${escaped}")`);
for (const row of colsResult.rows) {
result.push({
table,
column: (row.name ?? row[1]) as string,
});
}
}
return result;
}

export default async function dbCheckScoping(args: string[]): Promise<void> {
const parsed = parseArgs(args);

if (parsed.help === "true") {
console.log(`Usage: pnpm action db-check-scoping [options]

Options:
--db <path> Path to SQLite database (default: data/app.db)
--require-org Also check for org_id column (for multi-org apps)
--format json Output as JSON
--help Show this help message`);
return;
}

const requireOrg = parsed["require-org"] === "true";
const format = parsed.format;

// Resolve database URL
let url: string;
if (parsed.db) {
url = "file:" + path.resolve(parsed.db);
} else if (getDatabaseUrl()) {
url = getDatabaseUrl();
} else {
url = "file:" + path.resolve(process.cwd(), "data", "app.db");
}

let allColumns: TableColumn[];

if (isPostgresUrl(url)) {
const { default: pg } = await import("postgres");
const pgSql = pg(url);
try {
allColumns = await discoverColumnsPostgres(pgSql);
} finally {
await pgSql.end();
}
} else {
const client = createClient({
url,
authToken: getDatabaseAuthToken(),
});
try {
allColumns = await discoverColumnsSqlite(client);
} finally {
client.close();
}
}

const results = validate(allColumns, requireOrg);

if (format === "json") {
console.log(JSON.stringify({ tables: results }, null, 2));
return;
}

const withIssues = results.filter((r) => r.issues.length > 0);
const ok = results.filter((r) => r.issues.length === 0);

if (ok.length > 0) {
console.log("Scoped tables:");
for (const r of ok) {
const scopes = [
r.hasOwnerEmail ? "owner_email" : null,
r.hasOrgId ? "org_id" : null,
]
.filter(Boolean)
.join(", ");
console.log(` ✓ ${r.table} (${scopes})`);
}
console.log();
}

if (withIssues.length > 0) {
console.log("Unscoped tables (WARNING):");
for (const r of withIssues) {
for (const issue of r.issues) {
console.log(` ✗ ${r.table} — ${issue}`);
}
}
console.log();
console.log(
`${withIssues.length} table(s) lack scoping columns. ` +
`In production, agents can see ALL rows in these tables regardless of user/org.`,
);
process.exitCode = 1;
} else {
console.log("All template tables have proper scoping columns.");
}
}
60 changes: 42 additions & 18 deletions packages/core/src/scripts/db/exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
* against a SQLite or Postgres database.
*
* In production mode, temporary views scope UPDATE/DELETE to the current
* user's data (AGENT_USER_EMAIL). For INSERT, the `owner_email` column
* is auto-injected if the target table uses the ownership convention.
* user's data (AGENT_USER_EMAIL / AGENT_ORG_ID). For INSERT, the
* `owner_email` and `org_id` columns are auto-injected if the target
* table uses the ownership convention.
*
* Usage:
* pnpm action db-exec --sql "UPDATE forms SET status='published' WHERE id='abc'" [--db path]
Expand All @@ -27,15 +28,14 @@ function isPostgresUrl(url: string): boolean {
}

/**
* For INSERT statements targeting a table with an owner_email column,
* auto-inject the current user's email if not already present.
* For INSERT statements targeting a table with owner_email / org_id columns,
* auto-inject the current user's email and org ID if not already present.
*
* Handles both forms:
* INSERT INTO table (col1, col2) VALUES (?, ?)
* INSERT INTO table VALUES (...)
* Handles the explicit column list form:
* INSERT INTO table (col1, col2) VALUES (val1, val2)
*/
function injectOwnerEmail(sql: string, scoping: ScopingContext): string {
if (!scoping.active || !scoping.userEmail) return sql;
function injectOwnership(sql: string, scoping: ScopingContext): string {
if (!scoping.active) return sql;

const upper = sql
.replace(/^\s*--[^\n]*\n/gm, "")
Expand All @@ -49,19 +49,43 @@ function injectOwnerEmail(sql: string, scoping: ScopingContext): string {
if (!match) return sql;

const tableName = match[1];
if (!scoping.ownerEmailTables.has(tableName)) return sql;

// Check if owner_email is already in the column list
if (/owner_email/i.test(sql)) return sql;
// Determine which columns to inject
const injections: { col: string; value: string }[] = [];

if (
scoping.userEmail &&
scoping.ownerEmailTables.has(tableName) &&
!/owner_email/i.test(sql)
) {
injections.push({
col: "owner_email",
value: `'${scoping.userEmail.replace(/'/g, "''")}'`,
});
}

if (
scoping.orgId &&
scoping.orgIdTables.has(tableName) &&
!/org_id/i.test(sql)
) {
injections.push({
col: "org_id",
value: `'${scoping.orgId.replace(/'/g, "''")}'`,
});
}

if (injections.length === 0) return sql;

// Try to inject into explicit column list: INSERT INTO t (cols) VALUES (vals)
const colListMatch = sql.match(
/(INSERT\s+INTO\s+["']?\w+["']?\s*)\(([^)]+)\)(\s*VALUES\s*)\(([^)]+)\)/i,
);
if (colListMatch) {
const [, prefix, cols, valueKeyword, vals] = colListMatch;
const escaped = scoping.userEmail.replace(/'/g, "''");
return `${prefix}(${cols}, owner_email)${valueKeyword}(${vals}, '${escaped}')`;
const extraCols = injections.map((i) => i.col).join(", ");
const extraVals = injections.map((i) => i.value).join(", ");
return `${prefix}(${cols}, ${extraCols})${valueKeyword}(${vals}, ${extraVals})`;
}

return sql;
Expand Down Expand Up @@ -185,8 +209,8 @@ Options:
await pgSql.unsafe(stmt);
}

// For INSERT: auto-inject owner_email
const finalSql = injectOwnerEmail(sql, scoping);
// For INSERT: auto-inject owner_email / org_id
const finalSql = injectOwnership(sql, scoping);

const result = await pgSql.unsafe(finalSql);
const rows: Record<string, unknown>[] =
Expand Down Expand Up @@ -221,8 +245,8 @@ Options:
await client.execute(stmt);
}

// For INSERT: auto-inject owner_email
const finalSql = injectOwnerEmail(sql, scoping);
// For INSERT: auto-inject owner_email / org_id
const finalSql = injectOwnership(sql, scoping);

const result = await client.execute(finalSql);

Expand Down
Loading
Loading