Skip to content
Draft
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
19 changes: 15 additions & 4 deletions projects/egress-gate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ commands work from any directory and do not depend on repository-only files:
egress-gate gates list
egress-gate gates schema
egress-gate validate --policy /absolute/path/to/your-policy.yaml
egress-gate serve --listen 127.0.0.1:50051
egress-gate serve --listen 127.0.0.1:50051 --no-require-pi-receipt
```

## Source-checkout quickstart
Expand All @@ -39,7 +39,7 @@ uv run egress-gate gates list
uv run egress-gate gates schema
uv run egress-gate validate \
--policy examples/regex-redaction/egress-gate-config.yaml
uv run egress-gate serve --listen 127.0.0.1:50051
uv run egress-gate serve --listen 127.0.0.1:50051 --no-require-pi-receipt
uv run egress-gate evaluate \
--policy examples/regex-redaction/egress-gate-config.yaml \
--cases examples/regex-redaction/cases.yaml
Expand All @@ -49,6 +49,13 @@ Use `0.0.0.0` only when the OpenShell supervisor must reach the service across
network namespaces. The development server uses plaintext gRPC. Restrict its
listen port to trusted networks.

The CLI requires managed Pi admission receipts by default, coupling receipt
issuance to provider egress verification. The general Gate quickstarts opt out
explicitly. Keep the default, or pass `--require-pi-receipt`, for managed Pi;
use `--no-require-pi-receipt` only for an intentionally unmanaged deployment.
See the [managed Pi example](examples/pi-attested-admission/README.md) for the
matching Pi and OpenShell fork branches, startup contract, and current limits.

## Policy shape

The registry builds an exact strict schema from installed gate types:
Expand Down Expand Up @@ -87,7 +94,7 @@ need initialization, helper bases, or typed resources use the full class-based

```bash
uv run egress-gate --registry my_gates:registry gates list
uv run egress-gate --registry my_gates:registry serve
uv run egress-gate --registry my_gates:registry serve --no-require-pi-receipt
```

OpenShell owns interception, routing, and credential attachment. Egress Gate
Expand All @@ -103,11 +110,14 @@ from egress_gate.service import EgressGateServer
server = EgressGateServer(
create_builtin_registry(),
timeout_middleware_processing=10,
require_pi_receipt=False,
)
server.serve_sync("127.0.0.1:50051")
```

In this example, `timeout_middleware_processing` gives each evaluation 10
Make the `require_pi_receipt` choice explicit in programmatic deployments; set
it to `True` for managed Pi. In this unmanaged example,
`timeout_middleware_processing` gives each evaluation 10
seconds. Omitting it uses the one-second service default. The value is expressed
in seconds, must be at least 10 milliseconds, and must resolve to whole
milliseconds. The service passes one resulting `Timeout` through slot
Expand Down Expand Up @@ -136,6 +146,7 @@ timeout failures must deny.
- [Architecture](https://github.com/NVIDIA/OpenShell-Research/blob/main/projects/egress-gate/docs/architecture/index.md)
- [Limits and failures](https://github.com/NVIDIA/OpenShell-Research/blob/main/projects/egress-gate/docs/reference/limits-and-failures.md)
- [Regex redaction composition](https://github.com/NVIDIA/OpenShell-Research/tree/main/projects/egress-gate/examples/regex-redaction)
- [Pi attested-admission example](examples/pi-attested-admission/README.md)
- [Function-based custom gate](https://github.com/NVIDIA/OpenShell-Research/tree/main/projects/egress-gate/examples/custom-gate)
- [Class-based custom gate](https://github.com/NVIDIA/OpenShell-Research/tree/main/projects/egress-gate/examples/class-based-gate)

Expand Down
61 changes: 61 additions & 0 deletions projects/egress-gate/examples/pi-attested-admission/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Pi deny-or-redact example

This example demonstrates two outcomes for a rendered Pi prompt:

- **deny:** the prompt is not appended to chat history and no provider request
is made;
- **redact:** the replacement is appended to history and the provider receives
that same replacement.

Run the credential-free demonstration from `projects/egress-gate/`:

```shell
uv run python examples/pi-attested-admission/run_example.py
```

Its complete output is intentionally small:

```json
{
"deny": {
"decision": "deny",
"history_unchanged": true,
"provider_unchanged": true
},
"redact": {
"decision": "replace",
"history": ["please [REDACTED]"],
"provider_prompts": ["please [REDACTED]"]
}
}
```

The example uses the real regex policy, admission processor, signed receipt,
provider-request validation, and egress processor. The receipt is internal
plumbing: it proves that the redacted prompt admitted before history append is
the prompt authorized at provider egress.

## Managed Pi setup

Use the matching branches:

- [Pi user-message append hook PR](https://github.com/johnnygreco/pi/pull/1)
- [OpenShell integration branch](https://github.com/johnnygreco/OpenShell/tree/openshell/pi-egress-admission)

Register Egress Gate as an OpenShell supervisor middleware with Pi receipt
enforcement enabled. OpenShell exposes the admission bridge through
`OPENSHELL_PI_CONVERSATION_URL`. Load this directory's extension using Pi's
existing extension option:

```shell
pi --extension ./openshell-input-admission.ts
```

Pi remains unaware of OpenShell. The extension calls the bridge from
`before_user_message_append`: a denial returns `cancel`, while a replacement
returns `transform`. It attaches the resulting receipt to the first provider
request. Missing receipts and currently unsupported continuations fail closed.

This initial integration supports idle, text-only, direct OpenAI Chat
Completions submissions. Images, queued input, retries, compaction, and
automatic continuations after tool calls are deferred.
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
gates:
- name: deny-marker
kind: regex
scan:
kind: body
action:
kind: deny
pattern_catalog:
entities:
- name: unsafe-marker
rules:
- name: exact-deny-marker
pattern: DENY_THIS
confidence: high
- name: replace-marker
kind: regex
scan:
kind: body
action:
kind: replace
template: "[REDACTED]"
pattern_catalog:
entities:
- name: replacement-marker
rules:
- name: exact-replacement-marker
pattern: REDACT_THIS
confidence: high
default_decision: allow
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/**
* OpenShell direct-input admission for Pi.
*
* Load this extension explicitly with Pi's standard --extension option. It
* admits one idle, text-only user submission after rendering and before Pi
* persists it, then attaches the returned receipt to the first provider
* request. Steering, follow-ups, images, compaction, and post-tool
* continuations are unsupported and fail closed.
*/
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";

const BRIDGE_URL_ENV = "OPENSHELL_PI_CONVERSATION_URL";
const RECEIPT_HEADER = "x-openshell-middleware-egress-receipt";
const SCHEMA_VERSION = "openshell.pi-input.v1";
const MAX_RESPONSE_BYTES = 256 * 1024;
const MAX_RECEIPT_BYTES = 8 * 1024;

interface BridgeResponse {
decision: "allow" | "deny";
replacement_body?: number[];
receipt?: number[];
reason_code?: string;
}

interface CandidateEnvelope {
schema_version: typeof SCHEMA_VERSION;
text: string;
}

export default function (pi: ExtensionAPI) {
let pendingReceipt: string | undefined;

pi.on("before_user_message_append", async (event, ctx) => {
try {
pendingReceipt = undefined;
if (!ctx.isIdle() || event.images?.length) {
notifySafely(ctx, "OpenShell admission currently supports only idle, text-only prompts");
return { action: "cancel" };
}
const bridgeUrl = process.env[BRIDGE_URL_ENV];
if (!bridgeUrl) throw new Error(`${BRIDGE_URL_ENV} is required for OpenShell admission`);
const envelope: CandidateEnvelope = { schema_version: SCHEMA_VERSION, text: event.text };
const requestBody = new TextEncoder().encode(JSON.stringify(envelope));
const response = await fetch(bridgeUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
harness_version: "extension-v1",
session_id: ctx.sessionManager.getSessionId(),
submission_id: crypto.randomUUID(),
request_body: Array.from(requestBody),
}),
signal: ctx.signal,
});
if (!response.ok) throw new Error("OpenShell admission is unavailable");
const encoded = new Uint8Array(await response.arrayBuffer());
if (encoded.byteLength > MAX_RESPONSE_BYTES) throw new Error("OpenShell admission response is too large");
const result = parseBridgeResponse(JSON.parse(new TextDecoder().decode(encoded)));
if (result.decision === "deny") {
notifySafely(ctx, `OpenShell denied the prompt (${result.reason_code ?? "policy_denied"})`);
return { action: "cancel" };
}

pendingReceipt = decodeReceipt(result.receipt);
if (!result.replacement_body) return;
const replacement = parseEnvelope(new Uint8Array(result.replacement_body));
return { action: "transform", text: replacement.text };
} catch {
pendingReceipt = undefined;
notifySafely(ctx, "OpenShell admission is unavailable");
return { action: "cancel" };
}
});

pi.on("before_provider_headers", (event) => {
if (!pendingReceipt) throw new Error("OpenShell candidate admission receipt is missing");
if (Object.keys(event.headers).some((name) => name.toLowerCase() === RECEIPT_HEADER)) {
throw new Error("OpenShell receipt header is reserved");
}
event.headers[RECEIPT_HEADER] = pendingReceipt;
pendingReceipt = undefined;
});
}

function notifySafely(ctx: ExtensionContext, message: string): void {
try {
ctx.ui.notify(message, "warning");
} catch {
// Admission remains fail closed when a UI implementation cannot notify.
}
}

function parseBridgeResponse(value: unknown): BridgeResponse {
if (!isRecord(value) || (value.decision !== "allow" && value.decision !== "deny")) {
throw new Error("OpenShell admission returned an invalid response");
}
if (value.decision === "deny") {
if (value.receipt !== undefined || value.replacement_body !== undefined) {
throw new Error("OpenShell admission returned an invalid denial");
}
return {
decision: "deny",
reason_code: typeof value.reason_code === "string" ? value.reason_code : undefined,
};
}
if (!isByteArray(value.receipt) || (value.replacement_body !== undefined && !isByteArray(value.replacement_body))) {
throw new Error("OpenShell admission returned an invalid allow response");
}
return { decision: "allow", receipt: value.receipt, replacement_body: value.replacement_body };
}

function parseEnvelope(body: Uint8Array): CandidateEnvelope {
const value: unknown = JSON.parse(new TextDecoder().decode(body));
if (!isRecord(value) || value.schema_version !== SCHEMA_VERSION || typeof value.text !== "string") {
throw new Error("OpenShell admission returned an invalid replacement");
}
return { schema_version: SCHEMA_VERSION, text: value.text };
}

function decodeReceipt(value: number[] | undefined): string {
if (!value || value.length === 0 || value.length > MAX_RECEIPT_BYTES) {
throw new Error("OpenShell admission receipt is invalid");
}
const receipt = new TextDecoder("ascii", { fatal: true }).decode(new Uint8Array(value));
if (!/^[\x21-\x7e]+$/.test(receipt)) throw new Error("OpenShell admission receipt is invalid");
return receipt;
}

function isByteArray(value: unknown): value is number[] {
return Array.isArray(value) && value.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255);
}

function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object";
}
Loading
Loading