From a65ab8dcf295e60eee50179058f267e9fce6f1a8 Mon Sep 17 00:00:00 2001 From: Khush Patel Date: Fri, 5 Jun 2026 01:57:23 +0530 Subject: [PATCH] feat(guardrails): SRS-backed OPA/Cedar per-tool-call enforcement (RAI policies) End-to-end policy enforcement through Lyzr SRS, working under bypassPermissions: - engine-claude-agent-sdk: register a PreToolUse hook when a policy is active so enforcement fires even under --dangerously-skip-permissions (canUseTool is skipped there); the hook routes to onPermissionRequest -> SrsPolicyDecider. - protocol: add EngineContext.policyActive. - harness-server: set policyActive = !!session.policyDecider. - sdk: forward `policy` in the createSession body + ComputerAgentOptions.policy (was silently dropped). - computeragent-server: forward `policy` on /run (previously only /sandboxes). - agentos-server: real SRS reverse-proxy for /policies + /opa-policies (was a 503 stub), Mongo-backed per-agent policy binding (agent_policies), and srsPolicyForAgent attaching the bound policy to sandbox/run bodies. - docker-compose: SRS_BASE_URL/SRS_API_KEY for agentos (reaches SRS over host.docker.internal), otel-collector env. Verified live: OPA (Rego; regex 169.254.0.0/16 + IMDS/SSRF block) and Cedar both deny tool calls; selective allow/deny; toggle; non-policy agents unaffected; metadata-service access blocked. Co-Authored-By: Claude Opus 4.8 (1M context) --- docker-compose.yml | 16 ++ examples/computeragent-server.ts | 3 + packages/agentos-server/src/agent-defs.ts | 27 ++- packages/agentos-server/src/routes/chat.ts | 7 +- .../agentos-server/src/routes/policies.ts | 162 +++++++++++++++--- packages/agentos-server/src/routes/run.ts | 6 +- .../engine-claude-agent-sdk/src/engine.ts | 37 ++++ .../src/services/run-session.ts | 1 + packages/protocol/src/contracts.ts | 8 + packages/sdk/src/computer-agent.ts | 3 + packages/sdk/src/types.ts | 15 ++ 11 files changed, 254 insertions(+), 31 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 928e5ea..90bf7de 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,6 +42,17 @@ services: command: ["--config=/etc/otelcol-contrib/config.yaml"] volumes: - ./examples/otel-collector/otel-collector-config.yaml:/etc/otelcol-contrib/config.yaml:ro + # # The collector config references these via ${env:...}. env_file brings + # # NEW_RELIC_LICENSE_KEY from .env; the rest point at the internal services. + # env_file: + # - .env + # environment: + # CLICKHOUSE_ENDPOINT: tcp://clickhouse:9000?dial_timeout=10s + # CLICKHOUSE_DATABASE: otel + # CLICKHOUSE_USER: default + # CLICKHOUSE_PASSWORD: "" + # # New Relic OTLP gRPC ingest (US). Use otlp.eu01.nr-data.net:4317 for EU. + # NEW_RELIC_OTLP_ENDPOINT: otlp.nr-data.net:4317 depends_on: - clickhouse @@ -81,6 +92,11 @@ services: MONGO_DATABASE: ${MONGO_DATABASE:-computeragent-test} CLICKHOUSE_URL: http://clickhouse:8123 NODE_ENV: production + # SRS (guardrails) runs as a SEPARATE compose stack. It publishes :8500 + # on the host, so we reach it via host.docker.internal (works on Docker + # Desktop / macOS out of the box) — no shared network needed. + SRS_BASE_URL: ${SRS_BASE_URL:-http://host.docker.internal:8500} + SRS_API_KEY: ${SRS_API_KEY:-demo} # The SPA proxies through nginx (same origin), so sameSite=strict is # fine. But the public port is plain HTTP, so secure cookies would # block login. Disable the secure flag unless a TLS proxy is in front. diff --git a/examples/computeragent-server.ts b/examples/computeragent-server.ts index 5db3ef1..1b87983 100644 --- a/examples/computeragent-server.ts +++ b/examples/computeragent-server.ts @@ -256,6 +256,8 @@ interface RunBody { * are written once into the workdir, every engine sees them natively. */ attachments?: Array<{ path: string; content: string; encoding?: "utf8" | "base64" }>; + /** Per-tool-call policy enforcement (forwarded to the harness decider). */ + policy?: { kind: "srs"; endpoint: string; apiKey: string; policyId: string; principalId: string }; } interface ActiveRun { @@ -838,6 +840,7 @@ export class ComputerAgentServer { ...(body.sessionId ? { sessionId: body.sessionId } : {}), ...(body.debug ? { debug: true } : {}), ...(body.sessionStore ? { sessionStore: body.sessionStore as never } : {}), + ...(body.policy ? { policy: body.policy as never } : {}), ...(body.attachments && body.attachments.length > 0 ? { attachments: body.attachments } : {}), diff --git a/packages/agentos-server/src/agent-defs.ts b/packages/agentos-server/src/agent-defs.ts index 971d141..57a76b0 100644 --- a/packages/agentos-server/src/agent-defs.ts +++ b/packages/agentos-server/src/agent-defs.ts @@ -10,7 +10,7 @@ // the registry can be world-readable without leaking provider keys. import { IdentitySource, type IdentitySource as IdentitySourceT } from "@open-gitagent/protocol"; -import { registryColl, type RegistryDoc } from "./mongo.js"; +import { getDb, registryColl, type RegistryDoc } from "./mongo.js"; export interface AgentDef { name: string; @@ -185,6 +185,31 @@ export function runBodyFor(agent: AgentDef, message: string): Record | null> { + const endpoint = process.env["SRS_BASE_URL"]; + if (!endpoint) return null; + const doc = await (await getDb()) + .collection<{ _id: string; policyId: string }>("agent_policies") + .findOne({ _id: agentName }); + if (!doc?.policyId) return null; + return { + kind: "srs", + endpoint, + apiKey: process.env["SRS_API_KEY"] ?? "", + policyId: doc.policyId, + principalId: agentName, + }; +} + export class HttpError extends Error { override readonly name = "HttpError"; constructor(public status: number, message: string) { diff --git a/packages/agentos-server/src/routes/chat.ts b/packages/agentos-server/src/routes/chat.ts index 8c78b5b..9aa5165 100644 --- a/packages/agentos-server/src/routes/chat.ts +++ b/packages/agentos-server/src/routes/chat.ts @@ -12,7 +12,7 @@ import { randomUUID } from "node:crypto"; import { caAuthHeader } from "../auth.js"; import { caBase, pipeUpstream } from "../upstream.js"; import { chatPinsColl, threadsColl } from "../mongo.js"; -import { hasResolvableSource, resolveAgent, sandboxBodyFor, sandboxCapable } from "../agent-defs.js"; +import { hasResolvableSource, resolveAgent, sandboxBodyFor, sandboxCapable, srsPolicyForAgent } from "../agent-defs.js"; export const chatRouter: IRouter = Router(); @@ -64,6 +64,11 @@ chatRouter.post("/agents/:name/chat-sandbox", async (req, res, next) => { const status = (err as any)?.status ?? 503; return { ok: false as const, status, code: "AGENT_CONFIG", detail: (err as Error).message }; } + // Attach the agent's bound SRS policy (if any). The harness enforces it via + // an always-on PreToolUse hook, so the agent keeps running with its normal + // bypassPermissions autonomy — no permission-mode override needed. + const policy = await srsPolicyForAgent(agent.name); + if (policy) body.policy = policy; const r = await fetch(`${caBase()}/sandboxes`, { method: "POST", headers: { "content-type": "application/json", ...caAuthHeader() }, diff --git a/packages/agentos-server/src/routes/policies.ts b/packages/agentos-server/src/routes/policies.ts index e2e0ca4..231c29a 100644 --- a/packages/agentos-server/src/routes/policies.ts +++ b/packages/agentos-server/src/routes/policies.ts @@ -1,41 +1,147 @@ -// Policies tab — SRS (Security/Runtime/Safety) proxy. SRS isn't deployed -// here, so every endpoint returns empty list / 404 / 503 so the UI's empty -// states render cleanly instead of erroring out. When SRS lands, replace -// these handlers with reverse-proxy calls. +// Policies tab — SRS (Security/Runtime/Safety) reverse-proxy. +// +// RAI policies (/policies) and OPA rego policies (/opa-policies) are proxied +// verbatim to SRS, injecting the x-api-key. SRS owns storage + evaluation; +// the SPA was built against SRS's native shapes (_id, cedar_guardrail, +// opa_guardrail), so we relay status + body unchanged. +// +// The per-agent policy *binding* (/agents/:name/policy) is ours, not SRS's — +// it records which RAI policy_id an agent enforces, stored in Mongo. The +// harness reads it (via the run body's `policy` field) and calls SRS's +// /v1/guardrails/evaluate-tool-call per tool call. +// +// If SRS_BASE_URL is unset the proxy routes return 503 SRS_NOT_CONFIGURED so +// the UI's empty states still render. -import { Router, type Router as IRouter } from "express"; +import { Router, type Router as IRouter, type Response } from "express"; +import { getDb } from "../mongo.js"; export const policiesRouter: IRouter = Router(); -policiesRouter.get("/policies", (_req, res) => res.json({ policies: [] })); -policiesRouter.get("/policies/:id", (_req, res) => - res.status(404).json({ error: { code: "NOT_FOUND" } }), +const SRS_BASE = (process.env["SRS_BASE_URL"] ?? "").replace(/\/+$/, ""); +const SRS_KEY = process.env["SRS_API_KEY"] ?? ""; + +function safeParse(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return { raw: text }; + } +} + +/** + * Forward a request to SRS, inject x-api-key, relay status + JSON body. + * + * `opts.fallback` (used for list GETs): when SRS is unset/unreachable/5xx, + * respond 200 with the fallback instead of an error, so the Policies page + * renders its empty state cleanly rather than surfacing "/policies → 502". + * Writes pass no fallback, so create/update/delete still surface the error. + */ +async function srs( + res: Response, + method: string, + path: string, + opts: { body?: unknown; fallback?: unknown } = {}, +): Promise { + const { body, fallback } = opts; + if (!SRS_BASE) { + if (fallback !== undefined) { + res.json(fallback); + return; + } + res.status(503).json({ + error: { code: "SRS_NOT_CONFIGURED", message: "Policy service (SRS) is not configured. Set SRS_BASE_URL." }, + }); + return; + } + try { + const r = await fetch(`${SRS_BASE}${path}`, { + method, + headers: { + "x-api-key": SRS_KEY, + ...(body !== undefined ? { "content-type": "application/json" } : {}), + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }); + if (fallback !== undefined && r.status >= 500) { + res.json(fallback); + return; + } + const text = await r.text(); + res.status(r.status).json(text ? safeParse(text) : {}); + } catch (err) { + if (fallback !== undefined) { + res.json(fallback); + return; + } + res.status(502).json({ error: { code: "SRS_UNREACHABLE", message: (err as Error).message } }); + } +} + +// ── RAI policies → SRS /v1/rai/policies ─────────────────────────────────── +policiesRouter.get("/policies", (_req, res) => + srs(res, "GET", "/v1/rai/policies", { fallback: { policies: [] } }), ); -policiesRouter.post("/policies", (_req, res) => - res.status(503).json({ - error: { code: "SRS_NOT_CONFIGURED", message: "Policy service (SRS) is not deployed in this environment." }, - }), +policiesRouter.get("/policies/:id", (req, res) => + srs(res, "GET", `/v1/rai/policies/${encodeURIComponent(req.params["id"]!)}`), ); -policiesRouter.put("/policies/:id", (_req, res) => - res.status(503).json({ error: { code: "SRS_NOT_CONFIGURED" } }), +policiesRouter.post("/policies", (req, res) => srs(res, "POST", "/v1/rai/policies", { body: req.body ?? {} })); +policiesRouter.put("/policies/:id", (req, res) => + srs(res, "PUT", `/v1/rai/policies/${encodeURIComponent(req.params["id"]!)}`, { body: req.body ?? {} }), ); -policiesRouter.delete("/policies/:id", (_req, res) => - res.status(503).json({ error: { code: "SRS_NOT_CONFIGURED" } }), +policiesRouter.delete("/policies/:id", (req, res) => + srs(res, "DELETE", `/v1/rai/policies/${encodeURIComponent(req.params["id"]!)}`), ); -policiesRouter.get("/agents/:name/policy", (_req, res) => res.json({ binding: null })); -policiesRouter.put("/agents/:name/policy", (_req, res) => res.json({ binding: null })); - -policiesRouter.get("/opa-policies", (_req, res) => res.json({ policies: [] })); -policiesRouter.get("/opa-policies/:id", (_req, res) => - res.status(404).json({ error: { code: "NOT_FOUND" } }), +// ── OPA rego policies → SRS /v1/opa-policies ────────────────────────────── +policiesRouter.get("/opa-policies", (_req, res) => + srs(res, "GET", "/v1/opa-policies", { fallback: { policies: [] } }), ); -policiesRouter.post("/opa-policies", (_req, res) => - res.status(503).json({ error: { code: "SRS_NOT_CONFIGURED" } }), +policiesRouter.get("/opa-policies/:id", (req, res) => + srs(res, "GET", `/v1/opa-policies/${encodeURIComponent(req.params["id"]!)}`), ); -policiesRouter.put("/opa-policies/:id", (_req, res) => - res.status(503).json({ error: { code: "SRS_NOT_CONFIGURED" } }), +policiesRouter.post("/opa-policies", (req, res) => srs(res, "POST", "/v1/opa-policies", { body: req.body ?? {} })); +policiesRouter.put("/opa-policies/:id", (req, res) => + srs(res, "PUT", `/v1/opa-policies/${encodeURIComponent(req.params["id"]!)}`, { body: req.body ?? {} }), ); -policiesRouter.delete("/opa-policies/:id", (_req, res) => - res.status(503).json({ error: { code: "SRS_NOT_CONFIGURED" } }), +policiesRouter.delete("/opa-policies/:id", (req, res) => + srs(res, "DELETE", `/v1/opa-policies/${encodeURIComponent(req.params["id"]!)}`), ); + +// ── Per-agent policy binding (agentos-local, Mongo) ─────────────────────── +interface PolicyBindingDoc { + _id: string; // agent name + policyId: string; + updatedAt: Date; +} + +async function bindingsColl() { + return (await getDb()).collection("agent_policies"); +} + +policiesRouter.get("/agents/:name/policy", async (req, res, next) => { + try { + const doc = await (await bindingsColl()).findOne({ _id: req.params["name"]! }); + res.json({ binding: doc ? { policyId: doc.policyId } : null }); + } catch (err) { + next(err); + } +}); + +policiesRouter.put("/agents/:name/policy", async (req, res, next) => { + try { + const name = req.params["name"]!; + const body = (req.body ?? {}) as Record; + const policyId = typeof body["policy_id"] === "string" ? (body["policy_id"] as string) : null; + const coll = await bindingsColl(); + if (!policyId) { + await coll.deleteOne({ _id: name }); + res.json({ binding: null }); + return; + } + await coll.updateOne({ _id: name }, { $set: { policyId, updatedAt: new Date() } }, { upsert: true }); + res.json({ binding: { policyId } }); + } catch (err) { + next(err); + } +}); diff --git a/packages/agentos-server/src/routes/run.ts b/packages/agentos-server/src/routes/run.ts index 7ac1545..a3f759f 100644 --- a/packages/agentos-server/src/routes/run.ts +++ b/packages/agentos-server/src/routes/run.ts @@ -4,7 +4,7 @@ import { Router, type Router as IRouter } from "express"; import { caAuthHeader } from "../auth.js"; import { caBase, pipeUpstream } from "../upstream.js"; -import { resolveAgent, runBodyFor } from "../agent-defs.js"; +import { resolveAgent, runBodyFor, srsPolicyForAgent } from "../agent-defs.js"; export const runRouter: IRouter = Router(); @@ -22,6 +22,10 @@ runRouter.post("/agents/:name/run", async (req, res, next) => { const status = (err as any)?.status ?? 503; return res.status(status).json({ error: { code: "AGENT_CONFIG", message: (err as Error).message } }); } + // Attach the agent's bound SRS policy (if any). Enforced by the harness's + // always-on PreToolUse hook, so bypassPermissions autonomy is preserved. + const policy = await srsPolicyForAgent(agent.name); + if (policy) body.policy = policy; const upstream = await fetch(`${caBase()}/run`, { method: "POST", diff --git a/packages/engine-claude-agent-sdk/src/engine.ts b/packages/engine-claude-agent-sdk/src/engine.ts index 4405469..86eb458 100644 --- a/packages/engine-claude-agent-sdk/src/engine.ts +++ b/packages/engine-claude-agent-sdk/src/engine.ts @@ -118,6 +118,43 @@ export class ClaudeAgentEngine implements EngineDriver { ...storeOpts, }; + // Policy enforcement that survives bypassPermissions. canUseTool is skipped + // under --dangerously-skip-permissions, so the policy decider wired into + // onPermissionRequest never fires. A PreToolUse hook, by contrast, runs for + // EVERY tool call regardless of permission mode — route it through + // onPermissionRequest (which consults the bound decider → SRS → OPA/Cedar) + // and translate a deny into the hook's deny decision. Only registered when + // a policy is active, so non-policy agents keep autonomous bypass behavior. + if (ctx.policyActive) { + (options as Record)["hooks"] = { + PreToolUse: [ + { + hooks: [ + async (input: unknown, toolUseId?: string) => { + const pre = input as { tool_name?: string; tool_input?: Record }; + const result = await ctx.onPermissionRequest({ + callId: toolUseId ?? `hook-${pre.tool_name ?? "tool"}`, + toolName: pre.tool_name ?? "", + input: pre.tool_input ?? {}, + }); + if (result.behavior === "deny") { + log.info("policy.hook_deny", { sessionId: ctx.sessionId, toolName: pre.tool_name }); + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: result.message ?? "blocked by policy", + }, + }; + } + return {}; + }, + ], + }, + ], + }; + } + try { for await (const message of query({ prompt, options })) { if (ctx.abortSignal.aborted) break; diff --git a/packages/harness-server/src/services/run-session.ts b/packages/harness-server/src/services/run-session.ts index 2c7d79a..530dc1b 100644 --- a/packages/harness-server/src/services/run-session.ts +++ b/packages/harness-server/src/services/run-session.ts @@ -57,6 +57,7 @@ export async function runSession( workdir: session.workdir, envs: session.envs, userMessageQueue: session.userMessages(), + policyActive: !!session.policyDecider, onPermissionRequest: async (req) => { logger.info("session.permission_request", { sessionId: session.sessionId, diff --git a/packages/protocol/src/contracts.ts b/packages/protocol/src/contracts.ts index 5560a2d..35fdd30 100644 --- a/packages/protocol/src/contracts.ts +++ b/packages/protocol/src/contracts.ts @@ -49,6 +49,14 @@ export interface EngineContext { readonly userMessageQueue: AsyncIterable; /** Engine calls this for permission gating; framework handles the round-trip. */ readonly onPermissionRequest: (req: PermissionRequest) => Promise; + /** + * True when a policy decider is bound for this session. Engines whose + * permission path can be bypassed (e.g. claude-agent-sdk under + * `bypassPermissions`, which skips `canUseTool`) should additionally gate + * tools through an always-on mechanism — a `PreToolUse` hook that routes to + * `onPermissionRequest` — so policy enforcement survives bypass mode. + */ + readonly policyActive?: boolean; /** Wired to `POST /cancel`; engine must observe and bail. */ readonly abortSignal: AbortSignal; /** Optional hard cost cap. */ diff --git a/packages/sdk/src/computer-agent.ts b/packages/sdk/src/computer-agent.ts index 4c59232..dd88a8a 100644 --- a/packages/sdk/src/computer-agent.ts +++ b/packages/sdk/src/computer-agent.ts @@ -482,6 +482,9 @@ export class ComputerAgent { if (this.opts.attachments && this.opts.attachments.length > 0) { body.attachments = this.opts.attachments; } + // Forward per-tool-call policy config so the harness builds its decider + // (SrsPolicyDecider). Without this the harness never gates tool calls. + if (this.opts.policy) body.policy = this.opts.policy; const res = await this.fetchImpl(`${harnessUrl}/v1/sessions`, { method: "POST", diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index debc8dd..e8ccd18 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -143,6 +143,21 @@ export interface ComputerAgentOptions { * ] */ readonly attachments?: readonly Attachment[]; + /** + * Optional per-tool-call policy enforcement. When set, the harness builds a + * decider from this config and gates every tool call through it. Currently + * only Lyzr SRS is supported: the harness fetches the RAI policy once, then + * POSTs each tool call to SRS's `/v1/guardrails/evaluate-tool-call` for an + * allow/deny decision (fail-closed). Forwarded verbatim in the createSession + * body; the harness constructs the `SrsPolicyDecider`. + */ + readonly policy?: { + readonly kind: "srs"; + readonly endpoint: string; + readonly apiKey: string; + readonly policyId: string; + readonly principalId: string; + }; /** * Optional telemetry hook. When supplied, the SDK fires `onAgentConstructed` * once at construction, `onChatStart`/`onChatEnd` paired around each chat,