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: 2 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ Both web tools declare output schemas for programmatic callers. Structured fetch

The OpenCode Free provider registers the OpenCode inference endpoint with the public token and reads no endpoint, API-key, or CLI-version environment overrides. It resolves the request version lazily at runtime from the official installer's fixed `https://opencode.ai/update/api/latest/cli/npm` metadata endpoint, falling back to `@opencode/cli`'s npm `latest` metadata. Both sources use credential-free HTTPS, no redirects, a five-second per-source deadline, a 64 KiB response bound, and identity and stable-version validation. No remote URL is followed and no installer or binary is downloaded or executed. A shared lookup serves independently cancelable callers and aborts when its last waiter cancels. Only successful versions are cached in memory for 24 hours; failures are retryable, and no synthetic or bundled version is substituted. Registration and offline model refresh make no version request. A published version is traceable upstream metadata, not a guarantee of third-party-client support or an exemption from service limits.

OpenCode Free discovery follows native Pi's four-hour refresh interval, requiring both `checkedAt` and `lastModified` before skipping network access. The persisted entry keeps the visible live selection in `models` and the complete validated free-metadata projection in the provider-owned `catalog` field, with Pi's standard `etag` and `lastModified` fields. Conditional pi.dev requests send `If-None-Match` only when a nonempty metadata body backs the validator; a `304` reuses that body while still applying the refreshed live selection. This retains metadata for models that were not previously live. A `200` replaces metadata and validators; `404`/`501` record an empty overlay with `lastModified: 0` and clear the validator. Other HTTP failures preserve the cached body and validators, advance `checkedAt`, and surface a bounded refresh error, matching native Pi; without prior `lastModified`, those failures remain immediately retryable. Transport, parsing, CLI-version resolution, timeout, and cancellation failures do not advance freshness. Forced refresh bypasses freshness, never offline mode or cancellation. Snapshots are endpoint-scoped, and memory retains only the latest snapshot, including when persistence is unavailable.

`pi-gate` reads command-text rules from `gate.json` and never remembers dialog approvals. A bounded, non-executing recognizer applies word boundaries to simple literal commands and excludes inert `echo`/`printf` argument text and comments. Restrictive flag and numeric suffixes retain prefix coverage; allow exceptions require complete words and cannot suppress another simple command. Pipelines, redirects, expansions, opaque script arguments, malformed quoting, oversized inputs, and rules containing shell syntax retain conservative substring matching. This recognizer is not a Bash parser or sandbox. Persistent exceptions are explicit `allow` rules. Approval dialogs are serialized, cancellation-aware, and revalidate policy after the user responds; policy changes and session starts invalidate pending dialogs.

## Data flow invariants
Expand Down
13 changes: 12 additions & 1 deletion packages/pi-gate/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.7.1] - 2026-10-25

### Changed

- Create starter configuration when a session starts rather than when the extension is loaded.

### Fixed

- Cancel active approval dialogs and reject queued calls during shutdown, reload, and session replacement.

## [0.7.0] - 2026-10-24

### Changed
Expand Down Expand Up @@ -95,7 +105,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- support `prompt`, `block`, and `allow` actions, with longest-pattern-wins resolution
- block rather than auto-approve in non-UI modes

[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.7.0...HEAD
[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.7.1...HEAD
[0.7.1]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.7.0...pi-gate-v0.7.1
[0.7.0]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.6.1...pi-gate-v0.7.0
[0.6.1]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.6.0...pi-gate-v0.6.1
[0.6.0]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.5.0...pi-gate-v0.6.0
Expand Down
4 changes: 2 additions & 2 deletions packages/pi-gate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Use `pi config` to enable or disable this extension interactively. Run `pi list`

## Configure

Edit `~/.pi/agent/gate.json` to choose which commands need approval. The extension creates this file with starter rules on first load. Changes apply before the next gated call, without restarting Pi. Add the optional `$schema` below for editor completion and validation:
Edit `~/.pi/agent/gate.json` to choose which commands need approval. The extension creates this file with starter rules when the first session starts. Changes apply before the next gated call, without restarting Pi. Add the optional `$schema` below for editor completion and validation:

```json
{
Expand Down Expand Up @@ -46,7 +46,7 @@ pi-gate is a guard rail, not a sandbox. Complex shell syntax, pipelines, redirec

The built-in `bash` tool is gated, and `write`/`edit` calls targeting the gate config require approval. Prompt dialogs offer `Allow` and `Deny`; approvals are never remembered. To stop repeated prompts, add a specific `allow` rule to `gate.json`.

Approval dialogs are serialized for concurrent tool calls; cancellation, session replacement, or a policy change while a dialog is open denies the pending call.
Approval dialogs are serialized for concurrent tool calls; cancellation, shutdown, reload, session replacement, or a policy change while a dialog is open denies the pending call. Calls queued before shutdown or session replacement are also denied.

Long commands are truncated in approval dialogs, but an approved command executes in full. Denied, dismissed, blocked, and timed-out calls stop the turn; other allowed calls in a parallel batch may still run. In non-interactive modes (`-p`, JSON), commands requiring approval are denied.

Expand Down
3 changes: 2 additions & 1 deletion packages/pi-gate/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zeldrisho/pi-gate",
"version": "0.7.0",
"version": "0.7.1",
"description": "Pi extension that gates bash commands and protects its configuration from tool edits",
"keywords": [
"command-blocking",
Expand Down Expand Up @@ -42,6 +42,7 @@
},
"devDependencies": {
"@earendil-works/pi-coding-agent": "catalog:",
"typebox": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vite-plus": "catalog:"
Expand Down
47 changes: 32 additions & 15 deletions packages/pi-gate/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
* Matching is case-insensitive and whitespace-normalized. Allow occurrences
* suppress contained restrictions in the same command; surviving blocks win.
*
* On first run, a configuration with the default prompt timeout and starter
* On first session start, a configuration with the default prompt timeout and starter
* operation rules is written to `~/.pi/agent/gate.json` when neither file exists.
*
* Non-UI modes (print, JSON) never auto-approve. A `prompt` or `block` rule
Expand Down Expand Up @@ -112,14 +112,16 @@ export {

/**
* Gates `bash` calls against user rules and requires approval for protected
* configuration writes and edits. Attempts to create a starter configuration if absent.
* configuration writes and edits. Attempts to create a starter configuration
* at session start if absent.
* Reloads rules before gating and after each prompt; approvals apply per call.
* Dialogs are serialized, and cancellation, session starts, or policy changes
* invalidate an active approval. UI failures reject the affected tool call.
* Dialogs are serialized. Cancellation, shutdown, and session replacement deny
* active and queued gated calls; policy changes invalidate an active approval.
* UI failures reject the affected tool call.
*/
export default function piGate(pi: ExtensionAPI): void {
ensureConfig();
let loadResult = loadConfigResult();
let session = new AbortController();
let policy = JSON.stringify(loadResult);
let revision = 0;
let pending = Promise.resolve();
Expand All @@ -135,8 +137,16 @@ export default function piGate(pi: ExtensionAPI): void {
}
};

pi.on("session_shutdown", () => {
session.abort();
revision += 1;
});

pi.on("session_start", (_event, ctx) => {
session.abort();
session = new AbortController();
revision += 1;
ensureConfig();
refreshConfig();
const path = configPath();
const ruleCount = Object.keys(loadResult.config.operations).length;
Expand All @@ -160,7 +170,14 @@ export default function piGate(pi: ExtensionAPI): void {
});

pi.on("tool_call", (event, ctx) => {
// Capture ownership before queueing so old calls cannot enter a replacement session.
const signal = ctx.signal ? AbortSignal.any([ctx.signal, session.signal]) : session.signal;

// Pi can dispatch sibling tools concurrently; never overlap approval dialogs.
/**
* Returns a terminating block result for denied calls, or undefined to allow;
* UI errors propagate.
*/
const run = async (): Promise<ToolCallEventResult | undefined> => {
let command: string;
let configChange = false;
Expand All @@ -181,6 +198,14 @@ export default function piGate(pi: ExtensionAPI): void {
return undefined;
}

if (signal.aborted) {
return {
block: true,
reason: "pi-gate: approval cancelled; command not run.",
terminate: true,
};
}

refreshConfig();

const match = configChange
Expand Down Expand Up @@ -210,14 +235,6 @@ export default function piGate(pi: ExtensionAPI): void {
};
}

if (ctx.signal?.aborted) {
return {
block: true,
reason: "pi-gate: approval cancelled; command not run.",
terminate: true,
};
}

const promptRevision = revision;

reportHerdrBlocked(pi, ctx, true);
Expand All @@ -234,7 +251,7 @@ export default function piGate(pi: ExtensionAPI): void {
choice = await ctx.ui.select(
`pi-gate: allow this command?\n\n${promptCommand}\n\nMatched rule: ${rule}${configChange ? "" : "\nMatched command text is wrapped in »…«"}`,
["Allow", "Deny"],
{ timeout: timeoutMs, signal: ctx.signal },
{ timeout: timeoutMs, signal },
);
// Pi returns undefined for both dismissal and timeout; elapsed time separates the timeout case.
timedOut = choice === undefined && Date.now() - promptStartedAt >= timeoutMs;
Expand All @@ -244,7 +261,7 @@ export default function piGate(pi: ExtensionAPI): void {

refreshConfig();

if (ctx.signal?.aborted || revision !== promptRevision) {
if (signal.aborted || revision !== promptRevision) {
return {
block: true,
reason:
Expand Down
74 changes: 69 additions & 5 deletions packages/pi-gate/tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ interface FakeUi {
select: (
prompt: string,
options: readonly string[],
settings?: { timeout?: number },
settings?: { timeout?: number; signal?: AbortSignal },
) => Promise<string | undefined>;
}

Expand All @@ -77,6 +77,7 @@ type ToolCallHandler = (

interface RecordedHandlers {
sessionStart: SessionStartHandler | undefined;
sessionShutdown: (() => void) | undefined;
toolCall: ToolCallHandler | undefined;
}

Expand Down Expand Up @@ -141,7 +142,12 @@ function createExtensionContext(
}

function makeExtension(): ExtensionFactory {
const handlers: RecordedHandlers = { sessionStart: undefined, toolCall: undefined };
const handlers: RecordedHandlers = {
sessionStart: undefined,
sessionShutdown: undefined,
toolCall: undefined,
};

const herdrEvents: HerdrBlockedEvent[] = [];

// SAFETY: the test only exercises the `on` method; the rest of ExtensionAPI is unused.
Expand All @@ -155,6 +161,8 @@ function makeExtension(): ExtensionFactory {
if (name === "session_start") {
// SAFETY: the extension registers a SessionStartHandler for `session_start`.
handlers.sessionStart = handler as SessionStartHandler;
} else if (name === "session_shutdown") {
handlers.sessionShutdown = () => handler({}, undefined);
} else if (name === "tool_call") {
// SAFETY: the extension registers a ToolCallHandler for `tool_call`.
handlers.toolCall = handler as ToolCallHandler;
Expand Down Expand Up @@ -292,15 +300,23 @@ describe("legacy configuration fallback", () => {
});

describe("piGate extension", () => {
it("registers exactly session_start and tool_call", () => {
it("registers session lifecycle and tool_call handlers", () => {
const ext = makeExtension();
const { handlers } = ext.install();
// SAFETY: the captured handlers list is keyed by the names the extension registered.
expect(handlers.sessionStart).toBeDefined();
expect(handlers.sessionShutdown).toBeDefined();
expect(handlers.toolCall).toBeDefined();
});

describe("session_start notification", () => {
it("does not create configuration until a session starts", async () => {
const { ctx, handlers } = makeExtension().install();
expect(existsSync(join(workDir, "gate.json"))).toBe(false);
await handlers.sessionStart!({ reason: "startup" }, ctx);
expect(existsSync(join(workDir, "gate.json"))).toBe(true);
});

it("allows commands when no config has ever existed and creation fails", async () => {
const blockedAgentDir = join(workDir, "not-a-directory");
writeFileSync(blockedAgentDir, "not a directory", "utf-8");
Expand Down Expand Up @@ -534,7 +550,7 @@ describe("piGate extension", () => {
prompt:
'pi-gate: allow this command?\n\n »rm -rf« node_modules\n\nMatched rule: "rm -rf": "prompt"\nMatched command text is wrapped in »…«',
options: ["Allow", "Deny"],
settings: { timeout: DEFAULT_PROMPT_TIMEOUT_MS },
settings: { timeout: DEFAULT_PROMPT_TIMEOUT_MS, signal: expect.any(AbortSignal) },
},
]);
});
Expand Down Expand Up @@ -678,6 +694,51 @@ describe("piGate extension", () => {
},
);

it.each(["shutdown", "replacement"])(
"cancels active and queued approvals on %s",
async (kind) => {
setConfig(JSON.stringify({ operations: { sudo: "prompt" } }));
const { ctx, uiState, handlers } = makeExtension().install();
let opened!: () => void;

const opening = new Promise<void>((resolve) => {
opened = resolve;
});

ctx.ui.select = async (_prompt, _options, settings) => {
opened();
const signal = settings?.signal;

if (!signal) throw new Error("Expected a cancellation signal");
await new Promise<void>((resolve) =>
signal.addEventListener("abort", () => resolve(), { once: true }),
);

return "Allow";
};

const first = handlers.toolCall!(bashEvent("sudo echo first"), ctx);
const second = handlers.toolCall!(bashEvent("sudo echo second"), ctx);
await opening;
handlers.sessionShutdown!();
handlers.sessionShutdown!(); // Cleanup is idempotent.

if (kind === "replacement") await handlers.sessionStart!({ reason: "switch" }, ctx);
expect(await first).toMatchObject({ block: true, terminate: true });
expect(await second).toMatchObject({ block: true, terminate: true });
ctx.ui = createUi(uiState, true);
uiState.selectResponse = "Allow";

if (kind === "replacement")
expect(await handlers.toolCall!(bashEvent("sudo echo fresh"), ctx)).toBeUndefined();
else
expect(await handlers.toolCall!(bashEvent("sudo echo stale"), ctx)).toMatchObject({
block: true,
});
expect(uiState.selectCalls).toHaveLength(kind === "replacement" ? 1 : 0);
},
);

it("does not poison queued approvals when a UI request fails", async () => {
setConfig(JSON.stringify({ operations: { sudo: "prompt" } }));
const { ctx, uiState, handlers } = makeExtension().install();
Expand Down Expand Up @@ -721,7 +782,10 @@ describe("piGate extension", () => {
const { ctx, uiState, handlers } = makeExtension().install();
uiState.selectDelayMs = 30;
const result = await handlers.toolCall!(bashEvent("rm -rf node_modules"), ctx);
expect(uiState.selectCalls[0]?.settings).toEqual({ timeout: 20 });
expect(uiState.selectCalls[0]?.settings).toEqual({
timeout: 20,
signal: expect.any(AbortSignal),
});
expect(result).toEqual({
block: true,
reason:
Expand Down
Loading
Loading