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
2 changes: 1 addition & 1 deletion .github/workflows/run-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ jobs:
- batch: graphile-unit
packages: 'graphile/graphile-plugin-utils graphile/graphile-realtime-subscriptions graphile/graphile-sql-expression-validator graphile/graphile-upload-plugin'
- batch: agentic
packages: 'agentic/protocol agentic/agentic-kit agentic/agent agentic/harness agentic/chat agentic/cli agentic/pi agentic/react agentic/agentic-server agentic/anthropic agentic/openai agentic/ollama'
packages: 'agentic/protocol agentic/agentic-kit agentic/agent agentic/harness agentic/chat agentic/cli agentic/pi agentic/react agentic/agentic-server agentic/anthropic agentic/openai agentic/ollama agentic/run-log agentic/pi-ext-run-log agentic/pi-ext-metered-model agentic/pi-ext-usage-report agentic/pi-ext-gate'
- batch: pgpm-unit
packages: 'pgpm/types pgpm/naming-spec pgpm/diff pgpm/import pgpm/slice pgpm/transform'
- batch: pglite
Expand Down
71 changes: 71 additions & 0 deletions agentic/pi-ext-gate/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<p align="center" width="100%">
<img height="250" src="https://raw.githubusercontent.com/constructive-io/constructive/refs/heads/main/assets/outline-logo.svg" />
</p>

# @agentic-kit/pi-ext-gate

A pi extension that gates tool calls through a **declared policy**, and defers anything the policy will not decide alone to an **approval channel** that can reach a human on another machine.

This is what makes an unattended cloud run safe to start: the run's permissions are data, decided before it begins, and the escape hatch for everything else is an approval that travels over the platform's existing rows/API rather than a socket back into the cluster.

## Policy

A policy is an ordered rule list evaluated with no I/O — so the same value can be asserted in a test, stored on a run row, and rendered in a UI.

```ts
import { gatePolicy } from '@agentic-kit/pi-ext-gate';

const policy = gatePolicy({
rules: [
{ tool: 'bash', decision: 'ask', reason: 'destructive command', match: ({ input }) => /\brm\b/.test(String(input.command)) },
{ tool: 'bash', decision: 'allow' },
{ tool: 'write', decision: 'ask', reason: 'writes to the workspace' },
{ tool: '*', decision: 'allow' }
],
defaultDecision: 'deny'
});
```

First match wins, so order the list most specific first. With no `defaultDecision` the default is `ask`, not `allow`: a tool nobody declared is not implicitly trusted, and denying it outright would strand the run.

## Approvals

```ts
import { createGateExtension, pollingApprovalChannel } from '@agentic-kit/pi-ext-gate';

const gate = createGateExtension({
runId,
policy,
approvals: pollingApprovalChannel({
submit: (request) => api.createApprovalRequest(request), // a row
poll: (request) => api.readApprovalDecision(request), // another row
intervalMs: 1000,
timeoutMs: 15 * 60_000
}),
onDecision: (record) => runLog.append(record)
});

// hand `gate.extension` to pi
```

pi's `tool_call` handler can await, so an `ask` verdict simply suspends that one tool until the channel resolves — the rest of the session is untouched. `gate.pending` exposes the requests currently waiting.

## Behavior

- **Deny → `{ block: true, reason }`.** The reason is the model's *only* explanation for the refusal, so write policy reasons for the model to read.
- **Timeouts deny by default.** An unattended run must not take an unapproved action just because nobody was watching; `onTimeout: 'allow'` opts out.
- **A channel failure is not an approval.** `submit`/`poll` errors propagate and the tool call fails, rather than degrading into a silent allow.
- **Misconfiguration fails at construction:** a policy that can produce `ask` with no `approvals` channel throws immediately instead of on the first sensitive tool call.
- **Every settled decision is emitted** to `onDecision` — the policy verdict, the final decision, the deciding actor, the timestamp — which is what an audit trail or a run log stores.

`staticApprovalChannel({ decision: 'allow' })` is the explicit auto-approve for tests and deliberately unattended runs; it exists so that "approve everything" has to be written down.

## Testing

`pnpm test` — the policy is pure, the channel takes an injected clock/sleep, and the extension is driven through a fake `ExtensionAPI`, so nothing here needs a network or a model.

## Related

- `@agentic-kit/run-log` — append-only run log (`onDecision` records belong there)
- `@agentic-kit/pi-ext-run-log` — mirrors pi session entries into it
- `@agentic-kit/pi-ext-metered-model` / `@agentic-kit/pi-ext-usage-report` — the two metering lanes
96 changes: 96 additions & 0 deletions agentic/pi-ext-gate/__tests__/approval.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { type ApprovalOutcome, type ApprovalRequest, pollingApprovalChannel, staticApprovalChannel } from '../src';

const request: ApprovalRequest = {
runId: 'run-1',
toolCallId: 'call-1',
toolName: 'bash',
input: { command: 'rm -rf build' },
reason: 'destructive command',
requestedAt: '2026-01-01T00:00:00.000Z'
};

describe('pollingApprovalChannel', () => {
it('submits once, then polls until a decision exists', async () => {
const submit = jest.fn().mockResolvedValue(undefined);
const outcome: ApprovalOutcome = { decision: 'allow', actorId: 'user-1' };
const poll = jest
.fn<Promise<ApprovalOutcome | undefined>, [ApprovalRequest]>()
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(outcome);

const channel = pollingApprovalChannel({ submit, poll, sleep: () => Promise.resolve() });

await expect(channel.request(request)).resolves.toEqual(outcome);
expect(submit).toHaveBeenCalledTimes(1);
expect(submit).toHaveBeenCalledWith(request);
expect(poll).toHaveBeenCalledTimes(3);
});

it('waits between polls at the configured interval', async () => {
const sleep = jest.fn().mockResolvedValue(undefined);
const channel = pollingApprovalChannel({
submit: () => Promise.resolve(),
poll: jest.fn().mockResolvedValueOnce(undefined).mockResolvedValueOnce({ decision: 'deny' }),
intervalMs: 250,
sleep
});

await channel.request(request);
expect(sleep).toHaveBeenCalledWith(250);
});

it('denies on timeout, because an unattended run must not act unapproved', async () => {
let clock = 0;
const channel = pollingApprovalChannel({
submit: () => Promise.resolve(),
poll: () => Promise.resolve(undefined),
timeoutMs: 500,
intervalMs: 100,
sleep: () => {
clock += 100;
return Promise.resolve();
},
now: () => clock
});

await expect(channel.request(request)).resolves.toEqual({
decision: 'deny',
reason: 'gate: no decision within 500ms'
});
});

it('can be configured to allow on timeout instead', async () => {
let clock = 0;
const channel = pollingApprovalChannel({
submit: () => Promise.resolve(),
poll: () => Promise.resolve(undefined),
timeoutMs: 100,
onTimeout: 'allow',
sleep: () => {
clock += 100;
return Promise.resolve();
},
now: () => clock
});

await expect(channel.request(request)).resolves.toMatchObject({ decision: 'allow' });
});

it('propagates a submit failure instead of silently waiting forever', async () => {
const channel = pollingApprovalChannel({
submit: () => Promise.reject(new Error('api down')),
poll: jest.fn(),
sleep: () => Promise.resolve()
});

await expect(channel.request(request)).rejects.toThrow('api down');
});
});

describe('staticApprovalChannel', () => {
it('decides every request the same way', async () => {
const channel = staticApprovalChannel({ decision: 'allow', reason: 'auto-approved run' });
await expect(channel.request(request)).resolves.toEqual({ decision: 'allow', reason: 'auto-approved run' });
});
});
182 changes: 182 additions & 0 deletions agentic/pi-ext-gate/__tests__/extension.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';

import {
type ApprovalOutcome,
type ApprovalRequest,
createGateExtension,
type GateDecisionRecord,
staticApprovalChannel
} from '../src';

type Handler = (event: any, ctx: any) => unknown;

const fakePi = () => {
const handlers = new Map<string, Handler>();
const pi = {
on: (event: string, handler: Handler) => {
handlers.set(event, handler);
}
} as unknown as ExtensionAPI;
return {
pi,
toolCall: (toolName: string, input: Record<string, unknown>, toolCallId = 'call-1') =>
handlers.get('tool_call')?.({ type: 'tool_call', toolCallId, toolName, input }, {}) as Promise<
{ block?: boolean; reason?: string } | undefined
>
};
};

describe('createGateExtension', () => {
it('lets an allowed tool run', async () => {
const host = fakePi();
createGateExtension({
runId: 'run-1',
policy: { rules: [{ tool: 'read', decision: 'allow' }], defaultDecision: 'deny' }
}).extension(host.pi);

await expect(host.toolCall('read', { path: 'a.ts' })).resolves.toEqual({});
});

it('blocks a denied tool with the policy reason, which is all the model sees', async () => {
const host = fakePi();
createGateExtension({
runId: 'run-1',
policy: { rules: [{ tool: 'bash', decision: 'deny', reason: 'no shell in this run' }], defaultDecision: 'allow' }
}).extension(host.pi);

await expect(host.toolCall('bash', { command: 'ls' })).resolves.toEqual({
block: true,
reason: 'no shell in this run'
});
});

it('blocks with a generic reason when the policy gave none', async () => {
const host = fakePi();
createGateExtension({ runId: 'run-1', policy: { defaultDecision: 'deny' } }).extension(host.pi);

await expect(host.toolCall('write', { path: 'a.ts' })).resolves.toEqual({
block: true,
reason: 'gate: write is not permitted in this run'
});
});

it('suspends an asked tool call until the channel decides', async () => {
let resolveOutcome: (outcome: ApprovalOutcome) => void = () => undefined;
const seen: ApprovalRequest[] = [];
const gate = createGateExtension({
runId: 'run-1',
policy: { defaultDecision: 'ask', defaultReason: 'needs review' },
approvals: {
request: (req) => {
seen.push(req);
return new Promise<ApprovalOutcome>((resolve) => {
resolveOutcome = resolve;
});
}
}
});
const host = fakePi();
gate.extension(host.pi);

const call = host.toolCall('bash', { command: 'rm -rf build' });
await Promise.resolve();

expect(seen).toHaveLength(1);
expect(seen[0]).toMatchObject({
runId: 'run-1',
toolCallId: 'call-1',
toolName: 'bash',
input: { command: 'rm -rf build' },
reason: 'needs review'
});
expect(gate.pending.get('call-1')).toBeDefined();

resolveOutcome({ decision: 'allow', actorId: 'user-1' });
await expect(call).resolves.toEqual({});
expect(gate.pending.size).toBe(0);
});

it('blocks when the human denies, carrying their reason back to the model', async () => {
const host = fakePi();
createGateExtension({
runId: 'run-1',
policy: { defaultDecision: 'ask' },
approvals: staticApprovalChannel({ decision: 'deny', reason: 'not on production data' })
}).extension(host.pi);

await expect(host.toolCall('bash', { command: 'psql' })).resolves.toEqual({
block: true,
reason: 'not on production data'
});
});

it('stops tracking a request even when the channel fails', async () => {
const gate = createGateExtension({
runId: 'run-1',
policy: { defaultDecision: 'ask' },
approvals: { request: () => Promise.reject(new Error('api down')) }
});
const host = fakePi();
gate.extension(host.pi);

await expect(host.toolCall('bash', {})).rejects.toThrow('api down');
expect(gate.pending.size).toBe(0);
});

it('records every settled decision for the audit trail', async () => {
const records: GateDecisionRecord[] = [];
const host = fakePi();
createGateExtension({
runId: 'run-1',
policy: { rules: [{ tool: 'read', decision: 'allow' }], defaultDecision: 'ask' },
approvals: staticApprovalChannel({ decision: 'deny', reason: 'rejected', actorId: 'user-1' }),
onDecision: (record) => records.push(record),
now: () => new Date('2026-01-01T00:00:00.000Z')
}).extension(host.pi);

await host.toolCall('read', { path: 'a.ts' }, 'call-a');
await host.toolCall('bash', { command: 'ls' }, 'call-b');

expect(records).toEqual([
{
runId: 'run-1',
toolCallId: 'call-a',
toolName: 'read',
input: { path: 'a.ts' },
verdict: { decision: 'allow', rule: { tool: 'read', decision: 'allow' } },
decision: 'allow',
decidedAt: '2026-01-01T00:00:00.000Z'
},
{
runId: 'run-1',
toolCallId: 'call-b',
toolName: 'bash',
input: { command: 'ls' },
verdict: { decision: 'ask' },
decision: 'deny',
reason: 'rejected',
actorId: 'user-1',
decidedAt: '2026-01-01T00:00:00.000Z'
}
]);
});

it('refuses to start a policy that can ask with nowhere to ask', () => {
expect(() => createGateExtension({ runId: 'run-1', policy: {} })).toThrow(/approvals channel is required/);
expect(() =>
createGateExtension({
runId: 'run-1',
policy: { rules: [{ tool: 'bash', decision: 'ask' }], defaultDecision: 'allow' }
})
).toThrow(/approvals channel is required/);
});

it('needs no channel when the policy only allows and denies', () => {
expect(() =>
createGateExtension({
runId: 'run-1',
policy: { rules: [{ tool: 'bash', decision: 'deny' }], defaultDecision: 'allow' }
})
).not.toThrow();
});
});
Loading
Loading