Skip to content

Commit b2a0b30

Browse files
patel-lyzrclaude
andcommitted
feat: gateway-level SRS PII redaction, same policy id, server-side defaults
Redact PII from LLM traffic at the llm-proxy-openai gateway using the agent's bound RAI policy — the same policy id that drives the tool-call gate, with SRS creds falling back to the server's own SRS_BASE_URL/SRS_API_KEY (mirrors the cae337f policy decider fallback). - llm-proxy-openai: RedactionConfig on startProxy; per-request policy id read from the URL path (/rai/<policyId>/v1/messages); request prompts redacted via SRS /v1/rai/inference (stage llm_input), responses deanonymized via the returned redaction_map (reversible); a `block` action short-circuits with a policy refusal. Fail-open by default. - computeragent-server: pass SRS creds into startProxy when configured; applyRedactionBaseUrl() pins ANTHROPIC_BASE_URL=.../rai/<policyId> using the policy id CAS already holds in body.policy, at the run/sandbox/task sites. - agentos SPA: PII guardrail config (enable + per-entity redact/block) in the policy editor, persisted into the same RAI policy via /v1/rai/policies. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent cae337f commit b2a0b30

5 files changed

Lines changed: 360 additions & 15 deletions

File tree

‎agentos/src/api.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,12 +187,31 @@ export interface OPAGuardrailConfig {
187187
mode?: "audit" | "enforce" | "fail_open" | "fail_closed";
188188
timeout_seconds?: number;
189189
}
190+
/** PII action per entity type (SRS RAI `pii_detection`). */
191+
export type PIIAction = "redact" | "block" | "disabled";
192+
193+
/** Custom keyword/regex PII rule. */
194+
export interface CustomPII {
195+
label: string;
196+
replacement: string;
197+
action: PIIAction;
198+
}
199+
200+
/** SRS RAI PII guardrail config — drives gateway-level redaction. */
201+
export interface PIIDetectionConfig {
202+
enabled: boolean;
203+
/** Entity type → action (e.g. `{ EMAIL_ADDRESS: "redact", US_SSN: "block" }`). */
204+
types: Record<string, PIIAction>;
205+
custom_pii?: CustomPII[];
206+
}
207+
190208
export interface PolicyDoc {
191209
_id: string;
192210
name: string;
193211
description: string;
194212
cedar_guardrail?: CedarGuardrailConfig | null;
195213
opa_guardrail?: OPAGuardrailConfig | null;
214+
pii_detection?: PIIDetectionConfig | null;
196215
created_at?: string;
197216
updated_at?: string;
198217
[k: string]: unknown;

‎agentos/src/components/PoliciesPage.tsx‎

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useEffect, useState } from "react";
2-
import { api, type PolicyDoc, type CedarPolicyEntry, type OPAPolicyDoc } from "../api.ts";
2+
import { api, type PolicyDoc, type CedarPolicyEntry, type OPAPolicyDoc, type PIIAction } from "../api.ts";
33
import { useAuth } from "../context/AuthContext.tsx";
44

55
/**
@@ -128,8 +128,29 @@ interface EditorState {
128128
opaHook: "llm_input" | "llm_output" | "tool_input"; // stage the OPA binding runs at
129129
opaMode: "audit" | "enforce" | "fail_open" | "fail_closed";
130130
opaTimeoutSeconds: number;
131+
// PII guardrail — drives gateway-level redaction (SRS RAI pii_detection).
132+
piiEnabled: boolean;
133+
piiActions: Record<string, PIIAction>; // entity type → redact | block | disabled
131134
}
132135

136+
// Curated subset of SRS PIIEntityType (Presidio) — the common ones surfaced in
137+
// the UI. The full set is accepted by SRS; advanced entities can be managed via
138+
// the API. Each maps to an action: Off (disabled) | Redact | Block.
139+
const PII_ENTITIES: ReadonlyArray<{ key: string; label: string }> = [
140+
{ key: "EMAIL_ADDRESS", label: "Email address" },
141+
{ key: "PHONE_NUMBER", label: "Phone number" },
142+
{ key: "PERSON", label: "Person name" },
143+
{ key: "CREDIT_CARD", label: "Credit card" },
144+
{ key: "US_SSN", label: "US SSN" },
145+
{ key: "IBAN_CODE", label: "IBAN" },
146+
{ key: "US_BANK_NUMBER", label: "US bank number" },
147+
{ key: "IP_ADDRESS", label: "IP address" },
148+
{ key: "LOCATION", label: "Location" },
149+
{ key: "CRYPTO", label: "Crypto wallet" },
150+
{ key: "URL", label: "URL" },
151+
{ key: "DATE_TIME", label: "Date / time" },
152+
];
153+
133154
const FRESH_CEDAR_RULE = (): CedarPolicyEntry => ({
134155
id: `rule_${Math.random().toString(36).slice(2, 8)}`,
135156
name: "Rule",
@@ -168,6 +189,13 @@ function PolicyEditor({
168189
"tool_input",
169190
opaMode: initial?.opa_guardrail?.mode ?? "audit",
170191
opaTimeoutSeconds: initial?.opa_guardrail?.timeout_seconds ?? 5.0,
192+
piiEnabled: initial?.pii_detection?.enabled ?? false,
193+
piiActions: Object.fromEntries(
194+
PII_ENTITIES.map((e) => {
195+
const a = initial?.pii_detection?.types?.[e.key];
196+
return [e.key, a === "redact" || a === "block" ? a : "disabled"];
197+
}),
198+
),
171199
}));
172200
const [saving, setSaving] = useState(false);
173201
const [err, setErr] = useState<string | null>(null);
@@ -207,6 +235,15 @@ function PolicyEditor({
207235
mode: s.opaMode,
208236
timeout_seconds: s.opaTimeoutSeconds,
209237
},
238+
pii_detection: {
239+
enabled: s.piiEnabled,
240+
// Only persist entities with an active action; "disabled" ones are dropped.
241+
types: Object.fromEntries(
242+
Object.entries(s.piiActions).filter(([, a]) => a !== "disabled"),
243+
) as Record<string, PIIAction>,
244+
// Preserve any custom rules created via the API (not yet editable here).
245+
custom_pii: initial?.pii_detection?.custom_pii ?? [],
246+
},
210247
};
211248
try {
212249
if (initial) await api.updatePolicy(initial._id, body);
@@ -372,6 +409,38 @@ function PolicyEditor({
372409
</Field>
373410
</Section>
374411

412+
<Section
413+
title="PII redaction"
414+
right={<Toggle checked={s.piiEnabled} onChange={(v) => upd("piiEnabled", v)} />}
415+
>
416+
<p className="text-[11px] text-gray-500 -mt-1">
417+
When enabled, the LLM gateway scrubs these entities from prompts before they
418+
reach the model (and restores them in the response). <span className="text-gray-400">Redact</span> masks
419+
the value; <span className="text-gray-400">Block</span> refuses the request.
420+
</p>
421+
{s.piiEnabled && (
422+
<div className="grid grid-cols-2 gap-x-6 gap-y-2">
423+
{PII_ENTITIES.map((e) => (
424+
<div key={e.key} className="flex items-center justify-between gap-2">
425+
<span className="text-sm">{e.label}</span>
426+
<select
427+
value={s.piiActions[e.key] ?? "disabled"}
428+
onChange={(ev) =>
429+
upd("piiActions", { ...s.piiActions, [e.key]: ev.target.value as PIIAction })
430+
}
431+
disabled={!canWrite}
432+
className="bg-ink-700 border border-ink-600 rounded px-2 py-1 text-sm w-28"
433+
>
434+
<option value="disabled">Off</option>
435+
<option value="redact">Redact</option>
436+
<option value="block">Block</option>
437+
</select>
438+
</div>
439+
))}
440+
</div>
441+
)}
442+
</Section>
443+
375444
<div className="mt-6 flex items-center gap-2">
376445
{canWrite && (
377446
<button

‎examples/computeragent-server.ts‎

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -977,6 +977,7 @@ export class ComputerAgentServer {
977977
const fromHost = process.env.ANTHROPIC_API_KEY;
978978
if (fromHost) envs.ANTHROPIC_API_KEY = fromHost;
979979
}
980+
applyRedactionBaseUrl(envs, body.policy);
980981

981982
const agent = new ComputerAgent({
982983
source,
@@ -1357,6 +1358,7 @@ export class ComputerAgentServer {
13571358
const fromHost = process.env.ANTHROPIC_API_KEY;
13581359
if (fromHost) envs.ANTHROPIC_API_KEY = fromHost;
13591360
}
1361+
applyRedactionBaseUrl(envs, body.policy);
13601362

13611363
const source = applyGitToken(normalizeSource(body.source), body.gitToken);
13621364
// Build the agent but DO NOT call chat() yet — that's the substrate boot
@@ -2064,6 +2066,7 @@ export class ComputerAgentServer {
20642066
const fromHost = process.env.ANTHROPIC_API_KEY;
20652067
if (fromHost) envs.ANTHROPIC_API_KEY = fromHost;
20662068
}
2069+
applyRedactionBaseUrl(envs, body.policy);
20672070

20682071
const agent = new ComputerAgent({
20692072
source,
@@ -2202,6 +2205,30 @@ export class ComputerAgentServer {
22022205

22032206
// ── helpers ──────────────────────────────────────────────────────────────
22042207

2208+
/**
2209+
* Route a run's LLM traffic through the in-process redaction gateway when the
2210+
* agent has a bound policy. Same spirit as the policy decider's server-side
2211+
* fallback: the policy id is the one CAS already holds (`body.policy.policyId`),
2212+
* and the SRS creds the gateway uses fall back to this server's own
2213+
* `SRS_BASE_URL`/`SRS_API_KEY`. We hand the id to the gateway by pinning
2214+
* `ANTHROPIC_BASE_URL` to `…/rai/<policyId>` (the proxy reads it off the path).
2215+
*
2216+
* No-op unless: the proxy is enabled, SRS is configured, a policy id is present,
2217+
* and the caller hasn't already set its own ANTHROPIC_BASE_URL.
2218+
*/
2219+
function applyRedactionBaseUrl(
2220+
envs: Record<string, string>,
2221+
policy?: { policyId?: string } | null,
2222+
): void {
2223+
if (envs.ANTHROPIC_BASE_URL) return; // caller's explicit base wins
2224+
if (process.env.LYZR_PROXY_ENABLED === "0") return; // gateway opted out
2225+
if (!process.env.SRS_BASE_URL || !process.env.SRS_API_KEY) return; // redaction not configured
2226+
const policyId = policy?.policyId;
2227+
if (!policyId) return; // no bound policy → no redaction
2228+
const port = Number(process.env.LYZR_PROXY_PORT) || 8788;
2229+
envs.ANTHROPIC_BASE_URL = `http://127.0.0.1:${port}/rai/${encodeURIComponent(policyId)}`;
2230+
}
2231+
22052232
function normalizeSource(source: IdentitySource | string): IdentitySource {
22062233
if (typeof source !== "string") return source;
22072234
// Heuristic: anything with github.com/owner/repo or ending in .git is a git source.
@@ -2499,24 +2526,39 @@ if (import.meta.url === `file://${process.argv[1]}`) {
24992526
bootDeadlineMs: intEnv("SANDBOX_BOOT_DEADLINE_MS", 60_000),
25002527
};
25012528

2502-
// Optional: in-process Anthropic ↔ OpenAI translator proxy. When
2503-
// LYZR_PROXY_ENABLED=1 we boot @computeragent/llm-proxy-openai on
2504-
// 127.0.0.1:<port> so claude-agent-sdk / deepagents can target Lyzr (or
2505-
// any OpenAI-Chat-Completions backend) by setting `envs.ANTHROPIC_BASE_URL`
2506-
// = "http://127.0.0.1:<port>" on a /run or sandbox chat. Substrates
2507-
// running on the same host can reach the proxy via loopback (bwrap and
2508-
// local share the host's network namespace; e2b cannot — it would need
2509-
// a publicly routable proxy URL, out of scope here).
2529+
// In-process Anthropic ↔ OpenAI translator proxy + SRS PII redaction gateway.
2530+
// Enabled BY DEFAULT — set LYZR_PROXY_ENABLED=0 to opt out. Boots
2531+
// @computeragent/llm-proxy-openai on 127.0.0.1:<port>; policy-bound runs are
2532+
// routed through it (ANTHROPIC_BASE_URL=.../rai/<policyId>) so their prompts
2533+
// are redacted. Substrates on the same host reach it via loopback (bwrap and
2534+
// local share the host's network namespace; e2b cannot — it would need a
2535+
// publicly routable proxy URL, out of scope here). Still requires
2536+
// LYZR_UPSTREAM_TOKEN to actually start (skipped with a warning otherwise).
25102537
//
25112538
// gitagent doesn't need this proxy at all — gitclaw natively speaks OpenAI
25122539
// Chat Completions via GITCLAW_MODEL_BASE_URL + provider:model@baseUrl.
25132540
let lyzrProxyHandle: { port: number; close: () => Promise<void> } | null = null;
2514-
if (process.env.LYZR_PROXY_ENABLED === "1") {
2541+
if (process.env.LYZR_PROXY_ENABLED !== "0") {
25152542
const { startProxy } = await import("@computeragent/llm-proxy-openai");
25162543
const proxyToken = process.env.LYZR_UPSTREAM_TOKEN;
25172544
if (!proxyToken) {
2518-
console.error("[lyzr-proxy] LYZR_PROXY_ENABLED=1 but LYZR_UPSTREAM_TOKEN is not set — skipping");
2545+
console.error("[lyzr-proxy] gateway enabled but LYZR_UPSTREAM_TOKEN is not set — skipping (set LYZR_PROXY_ENABLED=0 to silence)");
25192546
} else {
2547+
// SRS-backed PII redaction at the gateway. Enabled when SRS creds are
2548+
// present; the RAI policy id is supplied per request via the URL path
2549+
// (`ANTHROPIC_BASE_URL=.../rai/<policyId>`), so any agent's bound policy
2550+
// drives its own redaction — nothing is hard-coded here.
2551+
const srsBase = process.env.SRS_BASE_URL;
2552+
const srsKey = process.env.SRS_API_KEY;
2553+
const redaction =
2554+
srsBase && srsKey
2555+
? {
2556+
srsBase,
2557+
srsKey,
2558+
failOpen: process.env.LYZR_PROXY_REDACT_FAIL_OPEN !== "0",
2559+
}
2560+
: undefined;
2561+
if (redaction) console.error("[lyzr-proxy] SRS RAI PII redaction enabled");
25202562
lyzrProxyHandle = await startProxy({
25212563
port: intEnv("LYZR_PROXY_PORT", 8788),
25222564
upstream: {
@@ -2525,6 +2567,7 @@ if (import.meta.url === `file://${process.argv[1]}`) {
25252567
token: proxyToken,
25262568
...(process.env.LYZR_UPSTREAM_MODEL ? { modelOverride: process.env.LYZR_UPSTREAM_MODEL } : {}),
25272569
},
2570+
...(redaction ? { redaction } : {}),
25282571
});
25292572
}
25302573
}

‎packages/llm-proxy-openai/src/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,4 @@
3939
* through their tool surfaces.
4040
*/
4141
export { startProxy } from "./proxy.js";
42-
export type { ProxyOptions, ProxyHandle, UpstreamConfig } from "./proxy.js";
42+
export type { ProxyOptions, ProxyHandle, UpstreamConfig, RedactionConfig } from "./proxy.js";

0 commit comments

Comments
 (0)