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
5 changes: 5 additions & 0 deletions .changeset/ai-toolexecutioncontext-fail-closed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@objectstack/spec': minor
---

Security (#2991): the AI `ToolExecutionContext` contract no longer documents system-level execution as the missing-actor default. A missing `toolExecutionContext` / `actor` now means an unauthenticated (RLS-on, sees-nothing) principal — executors MUST fail closed to anonymous, never fall open to system. System execution becomes an explicit, greppable opt-in via the new `ToolExecutionContext.isSystem?: boolean` field (same convention as `IDataEngine` / `IKnowledgeService`), reserved for trusted server-side invocations and ignored when an `actor` is present. Migration for internal callers that relied on the old omission default (cron, migrations, server jobs): pass `toolExecutionContext: { isSystem: true }` explicitly.
10 changes: 7 additions & 3 deletions content/docs/ai/actions-as-tools.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -228,9 +228,13 @@ authenticated principal from `req.user` — both cookie session
`aiService.chatWithTools(...)` as
`toolExecutionContext: { actor, conversationId, environmentId }`. That threads the
same RLS context through the runtime's built-in data tools and its
`action_<name>` tools. From a custom server route you opt in by passing the actor
explicitly; omit `toolExecutionContext` to keep system-level behaviour (cron
jobs, internal callers).
`action_<name>` tools. From a custom server route you pass the actor explicitly.
Omitting `toolExecutionContext` (or its `actor`) is **not** a system fallback:
the contract fails closed and data tools run as an unauthenticated, RLS-on
principal that sees nothing (#2991). Trusted internal callers (cron jobs,
migrations) that genuinely need full authority opt in explicitly with
`toolExecutionContext: { isSystem: true }` — a deliberate, greppable elevation,
never the consequence of a forgotten field.

```typescript
await aiService.chatWithTools(messages, tools, {
Expand Down
44 changes: 40 additions & 4 deletions packages/spec/src/contracts/ai-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,14 +366,20 @@ export interface ChatWithToolsOptions extends AIRequestOptions {
onToolError?: (toolCall: ToolCallPart, error: string) => 'continue' | 'abort';
/**
* Per-call execution context threaded into every tool handler the
* loop dispatches. The HTTP route should populate this from
* loop dispatches. The HTTP route MUST populate this from
* `req.user` (and any conversation/environment headers) so that
* built-in data tools forward the actor into ObjectQL's
* `ExecutionContext` and row-level security automatically scopes
* what the LLM can see or change.
*
* Optional for backward compatibility — when omitted, tools fall
* back to system-level behaviour.
* Optional at the type level only — a missing context is NOT a
* grant of authority (#2991). When omitted, executors MUST run
* data-touching tools as an unauthenticated (RLS-on, sees-nothing)
* principal, exactly as if an empty context had been passed; they
* MUST NOT fall back to system-level behaviour. Trusted internal
* callers (cron, migrations, server jobs) that genuinely need full
* authority opt in explicitly via
* {@link ToolExecutionContext.isSystem}.
*/
toolExecutionContext?: ToolExecutionContext;
}
Expand All @@ -383,19 +389,49 @@ export interface ChatWithToolsOptions extends AIRequestOptions {
* by {@link ChatWithToolsOptions}. Mirrors {@link ExecutionContext}
* but is tailored to the AI tool boundary (no transaction handle, no
* raw access token — those live on the engine call site).
*
* ## Identity semantics (fail-closed, #2991)
*
* "No identity" is never a grant of authority. Executors MUST derive
* the ObjectQL `ExecutionContext` for data-touching tools as:
*
* - `actor` present → that user's context (RLS scopes reads/writes).
* - `isSystem: true` → system context (RLS bypass) — an explicit,
* greppable elevation, same convention as `IDataEngine` /
* `IKnowledgeService`.
* - neither → an anonymous, unauthenticated context (RLS on, sees
* nothing). NEVER system.
*/
export interface ToolExecutionContext {
/**
* Authenticated end user on whose behalf the LLM is acting.
* Built-in tools promote this into the ObjectQL `ExecutionContext`
* so RLS engages. Omit for internal/system invocations.
* so RLS engages.
*
* A missing actor means an UNAUTHENTICATED caller (#2991):
* executors MUST run data-touching tools with an anonymous
* (RLS-on, sees-nothing) context — never as system. System
* execution is only ever the explicit {@link isSystem} opt-in,
* not the consequence of a forgotten field.
*/
actor?: {
id: string;
name?: string;
positions?: string[];
permissions?: string[];
};
/**
* Explicit, deliberate elevation: run tool handlers with a
* system-level (RLS-bypassing) `ExecutionContext` — the same
* convention as `IDataEngine` / `IKnowledgeService`
* (`isSystem: true`). Reserved for trusted server-side invocations
* (cron, migrations, internal jobs); MUST be set by trusted server
* code only, never derived from request input, and ignored when an
* {@link actor} is present. This flag is the ONLY way to obtain
* system behaviour from the tool loop — a merely absent actor
* fails closed to anonymous instead (#2991).
*/
isSystem?: boolean;
/** Conversation id for trace/HITL correlation. */
conversationId?: string;
/**
Expand Down