Skip to content
This repository was archived by the owner on Apr 17, 2026. It is now read-only.
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
14 changes: 14 additions & 0 deletions .changeset/permission-policy-followup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@agentrail/capabilities": minor
"@agentrail/app": minor
---

Fix and extend the tool permission policy system:

- Add `"strict"` `PermissionMode`: deny-by-default mode where only operations listed in `allow` are permitted. Use for minimal-privilege configurations.
- Add `contentMode: "path" | "command"` parameter to `matchPattern` and `evaluatePolicy`. Bash tools now pass `"command"` so that `*` wildcards match across `/` in command arguments (e.g. `git:add src/main.ts` matches `Bash(git:*)`).
- Fix missing `?` in regex escape list — a literal `?` in a pattern no longer acts as an optional quantifier.
- Fix cross-platform ancestor resolution in `path-safety.ts` using `path.dirname` loop instead of POSIX-specific `split`/`join`.
- Fix `normalizeBashCommand` to handle tab and other whitespace between verb and arguments.
- Export `ContentMatchMode` type from `@agentrail/capabilities`.
- `AgentrailPermissionsConfig.mode` and `agentrail.yaml` now accept `"strict"` as a valid permissions mode.
48 changes: 48 additions & 0 deletions .changeset/tool-permission-policy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
"@agentrail/core": minor
"@agentrail/capabilities": minor
"@agentrail/app": minor
---

Add tool permission policy system

Introduces a structured, rule-based permission layer that sits between the LLM
and tool execution, enabling fine-grained control over which operations agents
are allowed to perform.

### @agentrail/core

- New `PermissionDecision` type (`"allow" | "deny" | "ask"` or object form with optional `reason`).
- New optional `checkPermissions(params)` hook on `RuntimeTool` — called after
`onBeforeToolCall` interceptors but before `validate` and `execute`.
- New `permission_request` `RuntimeEvent` — emitted when `checkPermissions` returns `"ask"`.
- `ToolBuilder` gains a `.checkPermissions()` fluent method.
- `permission_request` is added to `TRACE_PERSISTED_EVENT_TYPES`.

### @agentrail/capabilities

- New `packages/capabilities/src/permissions/` module:
- `ToolPermissionPolicy` / `PermissionRule` / `PermissionMode` types.
- `parseRule` / `parseRules` DSL parser (e.g. `"Bash(git:*)"`, `"Write(/workspace/**)"`)
- `evaluatePolicy` rule engine with priority order: deny → ask → allow → default.
- `isPathSafe` / `workspaceAnchor` path-safety utilities.
- `isDangerousCommand` / `isReadOnlyCommand` shell-safety utilities.
- `CapabilityBuildContext` gains optional `permissionPolicy?: ToolPermissionPolicy`.
- Non-sandboxed `bashTool`, `readTool`, `writeTool`, `editTool` are now created via
factory functions (`createBashTool`, `createReadTool`, `createWriteTool`, `createEditTool`)
that accept optional `rootDir` and `policy` options; the singleton exports are
kept for backward compatibility.
- Sandboxed `createSandboxedBash` accepts an optional `policy` parameter.
- All new symbols are exported from the package root.

### @agentrail/app

- `AgentrailProfileContext` gains optional `permissionPolicy?: ToolPermissionPolicy`.
- `defineProfile` propagates `permissionPolicy` from profile context into
`CapabilityBuildContext`.
- `createAgentApp`, `createStreamRoute`, and `createChatRoute` all accept an
optional `permissionPolicy` option that is forwarded to every request.
- `AgentrailConfig` (YAML config) gains an optional `permissions` block with
`mode`, `allow`, `deny`, and `ask` keys.
- `DefaultCapabilityToolOptions` gains optional `permissionPolicy` forwarded to
sandboxed tools.
92 changes: 92 additions & 0 deletions docs/reference/create-agent-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,98 @@ Health route configuration. By default `createAgentApp` mounts `GET /health` and

---

### `permissionPolicy`

```ts
permissionPolicy?: ToolPermissionPolicy
```

Optional permission policy applied to all sessions served by this app.

When set, tools evaluate the policy via their `checkPermissions` hook before executing.
The policy is forwarded through `AgentrailProfileContext.permissionPolicy` →
`CapabilityBuildContext.permissionPolicy` into each capability's tool set.

```ts
import { parseRules } from "@agentrail/capabilities";

const app = createAgentApp({
dataDir: "./data",
profiles: [myProfile],
permissionPolicy: {
mode: "default",
// Bash rules use "verb:args" DSL — "git:*" matches any git command
allow: parseRules(["Bash(git:*)", "Bash(npm:*)"]),
deny: parseRules(["Bash(rm:*)"]),
ask: parseRules(["Write", "Edit"]),
},
});
```

The Bash DSL uses a `verb:args` form: `"git:*"` matches any command whose first word is `git`
(e.g. `git status`, `git log --oneline`, `git add src/main.ts`). The tool normalises
`"git status"` → `"git:status"` before pattern matching. The `*` wildcard in Bash patterns
matches across `/` so path arguments in commands are matched correctly.

**Permission modes:**

| `mode` | Default outcome | Description |
|--------|----------------|-------------|
| `"default"` | `allow` | Opt-in deny/ask. Only rules explicitly listed in `deny`/`ask` block tool calls. |
| `"strict"` | `deny` | Deny-by-default. Only operations listed in `allow` are permitted; everything else is blocked. Use for minimal-privilege configurations. |
| `"acceptEdits"` | `allow` | Like `default`, but `ask` decisions for `Write`/`Edit` tools are auto-approved. |
| `"dontAsk"` | `allow` | Like `default`, but `ask` decisions are demoted to `deny` (headless environments). |
| `"bypassPermissions"` | `allow` | All checks skipped (trusted automation only). |

**Strict (deny-by-default) allowlist example:**

```ts
import { parseRules } from "@agentrail/capabilities";

const app = createAgentApp({
permissionPolicy: {
mode: "strict", // deny anything not explicitly allowed
allow: parseRules([
"Bash(git:*)", // prefix-match: any content starting with "git:"
"Bash(npm:*)", // prefix-match: any content starting with "npm:"
"Read", // permit all file reads
]),
deny: [],
ask: [],
},
});
```

> **Bash rule caveat:** Bash patterns are **prefix-anchored** (no trailing
> `$`). `Bash(git:*)` matches any normalised command whose first word is
> `git`, but it also matches shell strings that merely *start* with `git:`
> — including chained forms like `git:status; curl evil.com`. Bash rules
> are useful for coarse-grained allow/deny (e.g. block all `rm` calls), but
> they cannot provide strict command confinement. For strong shell
> isolation, run agents in the sandboxed environment.

**Loading from `agentrail.yaml`:**

When `config.permissions` is set, use `configPermissionsToPolicy` to convert the raw YAML config
into a runtime `ToolPermissionPolicy`:

```ts
import { createAgentApp, loadAgentrailConfig, configPermissionsToPolicy } from "@agentrail/app";

const config = loadAgentrailConfig();
const app = createAgentApp({
dataDir: "./data",
profiles: [myProfile],
permissionPolicy: config.permissions
? configPermissionsToPolicy(config.permissions)
: undefined,
});
```

See the [Permissions Guide](/guides/tool-permissions) for the full DSL reference.

---

### `telemetrySink`

```ts
Expand Down
20 changes: 20 additions & 0 deletions docs/reference/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ The most important ones for UI consumers:
| `tool.before` | A tool call is about to be dispatched (after any `onBeforeToolCall` plugin hooks have run) |
| `tool.after` | A tool invocation completes |
| `waiting_for_user_input` | The agent is paused waiting for user input |
| `permission_request` | A tool's `checkPermissions` returned `"ask"`; execution is blocked pending host approval |
| `skill_start` / `skill_end` | A skill sub-agent is invoked |

### Tracing fields on every RuntimeEvent
Expand Down Expand Up @@ -251,6 +252,25 @@ These fields are automatically stamped by `agentLoop` and propagated to sub-agen
> unmodified model output, switch to `rawArgs`. `args` now reflects the effective
> (possibly plugin-modified) arguments that were actually executed.

### `permission_request` field reference

Emitted when a tool's `checkPermissions` hook returns `"ask"`. Execution is
blocked until the host provides an interactive approval mechanism; in the current
release, the tool call is always denied and the model receives an error result.

```ts
{
type: "permission_request";
toolCallId: string;
toolName: string;
/** Optional human-readable reason why approval is being requested. */
reason?: string;
}
```

This event is persisted to the trace log (`TRACE_PERSISTED_EVENT_TYPES`) so that
audit tooling can record which tool calls required permission.

---

## Trace Persistence
Expand Down
7 changes: 7 additions & 0 deletions docs/reference/profile-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,13 @@ interface AgentrailProfileContext {
* AgentRunOptions.chainId so that RuntimeEvent.chainId equals the route-level
* traceId. Sub-agent events inherit the same chainId with depth incremented. */
chainId?: string;
/**
* Active permission policy for this session. When present, all file and
* shell tools — both sandboxed (Bash, Read, Write, Edit) and non-sandboxed
* — evaluate it via `checkPermissions` before executing. Propagated
* automatically by `defineProfile` into `CapabilityBuildContext.permissionPolicy`.
*/
permissionPolicy?: ToolPermissionPolicy;
}

interface AgentrailProfile {
Expand Down
1 change: 1 addition & 0 deletions examples/playground-server/src/routes/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const chat = createChatRoute({
plugins: playgroundPlugins,
resolveProfile: resolvePlaygroundProfile,
handleResolvedRequest: handlePlaygroundDeepResearchMode,
permissionPolicy: config.permissionPolicy,
});

export { chat };
23 changes: 21 additions & 2 deletions examples/playground-server/src/routes/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,13 +196,32 @@ sessions.get("/:sessionId/compacted-messages", async (c) => {
sessions.post("/:sessionId/respond", async (c) => {
const { sessionId } = c.req.param();

let body: { answer?: unknown };
let body: { kind?: unknown; answer?: unknown; decision?: unknown };
try {
body = await c.req.json<{ answer?: unknown }>();
body = await c.req.json<{ kind?: unknown; answer?: unknown; decision?: unknown }>();
} catch {
return c.json({ error: "Invalid JSON body" }, 400);
}

const pendingKind = waitHandleRegistry.getPendingKind(sessionId);

// ── Permission approval ────────────────────────────────────────────────────
if (body.kind === "permission" || pendingKind === "permission") {
const { decision } = body;
if (decision !== "approved" && decision !== "rejected") {
return c.json(
{ error: "Field 'decision' must be \"approved\" or \"rejected\" for permission responses" },
400,
);
}
const resolved = waitHandleRegistry.respondPermission(sessionId, decision);
if (!resolved) {
return c.json({ error: `No pending permission request for session '${sessionId}'` }, 404);
}
return c.json({ ok: true });
}

// ── Question answer (AskUserQuestion tool) ─────────────────────────────────
const { answer } = body;
if (typeof answer !== "string" || answer.trim() === "") {
return c.json({ error: "Field 'answer' is required and must be a non-empty string" }, 400);
Expand Down
14 changes: 14 additions & 0 deletions examples/playground-server/src/routes/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { config } from "@/config.js";
import { orchestrationRegistry, sandboxManager, sessionManager } from "@/context/index.js";
import { playgroundPlugins } from "@/plugins/index.js";
import { resolvePlaygroundProfile } from "@/profiles/default-profile.js";
import { waitHandleRegistry } from "@/wait-handle-registry.js";
import type { WorkflowTraceEventEnvelope } from "@agentrail/app";
import { createFileSystemSessionTraceStore } from "@agentrail/app";
import { createStreamRoute } from "@agentrail/app/advanced";
Expand All @@ -28,6 +29,19 @@ const stream = createStreamRoute({
getOrchestrationManager: ({ tenantId, userId, sessionId, sessionRef }) =>
orchestrationRegistry.getManager({ tenantId, userId, sessionId, sessionRef }),
handleResolvedRequest: handlePlaygroundDeepResearchModeStream,
permissionPolicy: config.permissionPolicy,
createPermissionApprovalHandler: (sessionId) => ({
requestApproval({ toolCallId, toolName, reason, signal }) {
return Promise.race([
waitHandleRegistry.registerPermission(sessionId, toolCallId, toolName, reason),
new Promise<never>((_resolve, reject) => {
signal?.addEventListener("abort", () =>
reject(new Error("Request aborted while waiting for permission approval")),
);
}),
]);
},
}),
onTraceEvent: (ctx, envelope) => {
const traceStore = createFileSystemSessionTraceStore<WorkflowTraceEventEnvelope>(
config.dataDir,
Expand Down
59 changes: 57 additions & 2 deletions examples/playground-server/src/wait-handle-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,37 @@
* Copyright (c) 2026 The Agentrail Authors
*/

interface WaitHandle {
interface QuestionHandle {
kind: "question";
resolve: (answer: string) => void;
reject: (err: Error) => void;
question: string;
registeredAt: number;
}

interface PermissionHandle {
kind: "permission";
resolve: (decision: "approved" | "rejected") => void;
reject: (err: Error) => void;
toolCallId: string;
toolName: string;
reason?: string;
registeredAt: number;
}

type WaitHandle = QuestionHandle | PermissionHandle;

class WaitHandleRegistry {
private readonly handles = new Map<string, WaitHandle>();

// ── Question handles (AskUserQuestion tool) ───────────────────────────────

register(sessionId: string, question: string): Promise<string> {
this.cancel(sessionId);

return new Promise<string>((resolve, reject) => {
this.handles.set(sessionId, {
kind: "question",
resolve,
reject,
question,
Expand All @@ -26,14 +42,49 @@ class WaitHandleRegistry {
});
}

// ── Permission handles (interactive approval) ─────────────────────────────

registerPermission(
sessionId: string,
toolCallId: string,
toolName: string,
reason?: string,
): Promise<"approved" | "rejected"> {
this.cancel(sessionId);

return new Promise<"approved" | "rejected">((resolve, reject) => {
this.handles.set(sessionId, {
kind: "permission",
resolve,
reject,
toolCallId,
toolName,
reason,
registeredAt: Date.now(),
});
});
}

// ── Respond ───────────────────────────────────────────────────────────────

respond(sessionId: string, answer: string): boolean {
const handle = this.handles.get(sessionId);
if (!handle) return false;
if (!handle || handle.kind !== "question") return false;
this.handles.delete(sessionId);
handle.resolve(answer);
return true;
}

respondPermission(sessionId: string, decision: "approved" | "rejected"): boolean {
const handle = this.handles.get(sessionId);
if (!handle || handle.kind !== "permission") return false;
this.handles.delete(sessionId);
handle.resolve(decision);
return true;
}

// ── Utilities ─────────────────────────────────────────────────────────────

cancel(sessionId: string): void {
const handle = this.handles.get(sessionId);
if (handle) {
Expand All @@ -45,6 +96,10 @@ class WaitHandleRegistry {
hasPending(sessionId: string): boolean {
return this.handles.has(sessionId);
}

getPendingKind(sessionId: string): "question" | "permission" | null {
return this.handles.get(sessionId)?.kind ?? null;
}
}

export const waitHandleRegistry = new WaitHandleRegistry();
Loading
Loading