Skip to content

Commit 221c3c5

Browse files
Merge pull request #21 from open-gitagent/feat/rai-guardrails
Feat/rai guardrails
2 parents 9f076c7 + d4b78df commit 221c3c5

11 files changed

Lines changed: 254 additions & 31 deletions

File tree

docker-compose.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,17 @@ services:
4242
command: ["--config=/etc/otelcol-contrib/config.yaml"]
4343
volumes:
4444
- ./examples/otel-collector/otel-collector-config.yaml:/etc/otelcol-contrib/config.yaml:ro
45+
# # The collector config references these via ${env:...}. env_file brings
46+
# # NEW_RELIC_LICENSE_KEY from .env; the rest point at the internal services.
47+
# env_file:
48+
# - .env
49+
# environment:
50+
# CLICKHOUSE_ENDPOINT: tcp://clickhouse:9000?dial_timeout=10s
51+
# CLICKHOUSE_DATABASE: otel
52+
# CLICKHOUSE_USER: default
53+
# CLICKHOUSE_PASSWORD: ""
54+
# # New Relic OTLP gRPC ingest (US). Use otlp.eu01.nr-data.net:4317 for EU.
55+
# NEW_RELIC_OTLP_ENDPOINT: otlp.nr-data.net:4317
4556
depends_on:
4657
- clickhouse
4758

@@ -81,6 +92,11 @@ services:
8192
MONGO_DATABASE: ${MONGO_DATABASE:-computeragent-test}
8293
CLICKHOUSE_URL: http://clickhouse:8123
8394
NODE_ENV: production
95+
# SRS (guardrails) runs as a SEPARATE compose stack. It publishes :8500
96+
# on the host, so we reach it via host.docker.internal (works on Docker
97+
# Desktop / macOS out of the box) — no shared network needed.
98+
SRS_BASE_URL: ${SRS_BASE_URL:-http://host.docker.internal:8500}
99+
SRS_API_KEY: ${SRS_API_KEY:-demo}
84100
# The SPA proxies through nginx (same origin), so sameSite=strict is
85101
# fine. But the public port is plain HTTP, so secure cookies would
86102
# block login. Disable the secure flag unless a TLS proxy is in front.

examples/computeragent-server.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,8 @@ interface RunBody {
256256
* are written once into the workdir, every engine sees them natively.
257257
*/
258258
attachments?: Array<{ path: string; content: string; encoding?: "utf8" | "base64" }>;
259+
/** Per-tool-call policy enforcement (forwarded to the harness decider). */
260+
policy?: { kind: "srs"; endpoint: string; apiKey: string; policyId: string; principalId: string };
259261
}
260262

261263
interface ActiveRun {
@@ -861,6 +863,7 @@ export class ComputerAgentServer {
861863
...(body.sessionId ? { sessionId: body.sessionId } : {}),
862864
...(body.debug ? { debug: true } : {}),
863865
...(body.sessionStore ? { sessionStore: body.sessionStore as never } : {}),
866+
...(body.policy ? { policy: body.policy as never } : {}),
864867
...(body.attachments && body.attachments.length > 0
865868
? { attachments: body.attachments }
866869
: {}),

packages/agentos-server/src/agent-defs.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
// the registry can be world-readable without leaking provider keys.
1111

1212
import { IdentitySource, type IdentitySource as IdentitySourceT } from "@open-gitagent/protocol";
13-
import { registryColl, type RegistryDoc } from "./mongo.js";
13+
import { getDb, registryColl, type RegistryDoc } from "./mongo.js";
1414

1515
export interface AgentDef {
1616
name: string;
@@ -185,6 +185,31 @@ export function runBodyFor(agent: AgentDef, message: string): Record<string, unk
185185
return body;
186186
}
187187

188+
/**
189+
* Build the SRS policy config for an agent if it has a bound RAI policy.
190+
*
191+
* Returns null when SRS isn't configured (`SRS_BASE_URL` unset) or the agent
192+
* has no binding in `agent_policies`. The harness's SrsPolicyDecider uses
193+
* `endpoint` to reach SRS per tool call — `SRS_BASE_URL` is reachable from the
194+
* harness container too (host.docker.internal:8500 on Docker Desktop), so the
195+
* same value works for both agentos and the spawned harness.
196+
*/
197+
export async function srsPolicyForAgent(agentName: string): Promise<Record<string, unknown> | null> {
198+
const endpoint = process.env["SRS_BASE_URL"];
199+
if (!endpoint) return null;
200+
const doc = await (await getDb())
201+
.collection<{ _id: string; policyId: string }>("agent_policies")
202+
.findOne({ _id: agentName });
203+
if (!doc?.policyId) return null;
204+
return {
205+
kind: "srs",
206+
endpoint,
207+
apiKey: process.env["SRS_API_KEY"] ?? "",
208+
policyId: doc.policyId,
209+
principalId: agentName,
210+
};
211+
}
212+
188213
export class HttpError extends Error {
189214
override readonly name = "HttpError";
190215
constructor(public status: number, message: string) {

packages/agentos-server/src/routes/chat.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { randomUUID } from "node:crypto";
1212
import { caAuthHeader } from "../auth.js";
1313
import { caBase, pipeUpstream } from "../upstream.js";
1414
import { chatPinsColl, chatSessionsColl } from "../mongo.js";
15-
import { hasResolvableSource, resolveAgent, sandboxBodyFor, sandboxCapable } from "../agent-defs.js";
15+
import { hasResolvableSource, resolveAgent, sandboxBodyFor, sandboxCapable, srsPolicyForAgent } from "../agent-defs.js";
1616

1717
export const chatRouter: IRouter = Router();
1818

@@ -66,6 +66,11 @@ chatRouter.post("/agents/:name/chat-sandbox", async (req, res, next) => {
6666
const status = (err as any)?.status ?? 503;
6767
return { ok: false as const, status, code: "AGENT_CONFIG", detail: (err as Error).message };
6868
}
69+
// Attach the agent's bound SRS policy (if any). The harness enforces it via
70+
// an always-on PreToolUse hook, so the agent keeps running with its normal
71+
// bypassPermissions autonomy — no permission-mode override needed.
72+
const policy = await srsPolicyForAgent(agent.name);
73+
if (policy) body.policy = policy;
6974
const r = await fetch(`${caBase()}/sandboxes`, {
7075
method: "POST",
7176
headers: { "content-type": "application/json", ...caAuthHeader() },
Lines changed: 134 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,147 @@
1-
// Policies tab — SRS (Security/Runtime/Safety) proxy. SRS isn't deployed
2-
// here, so every endpoint returns empty list / 404 / 503 so the UI's empty
3-
// states render cleanly instead of erroring out. When SRS lands, replace
4-
// these handlers with reverse-proxy calls.
1+
// Policies tab — SRS (Security/Runtime/Safety) reverse-proxy.
2+
//
3+
// RAI policies (/policies) and OPA rego policies (/opa-policies) are proxied
4+
// verbatim to SRS, injecting the x-api-key. SRS owns storage + evaluation;
5+
// the SPA was built against SRS's native shapes (_id, cedar_guardrail,
6+
// opa_guardrail), so we relay status + body unchanged.
7+
//
8+
// The per-agent policy *binding* (/agents/:name/policy) is ours, not SRS's —
9+
// it records which RAI policy_id an agent enforces, stored in Mongo. The
10+
// harness reads it (via the run body's `policy` field) and calls SRS's
11+
// /v1/guardrails/evaluate-tool-call per tool call.
12+
//
13+
// If SRS_BASE_URL is unset the proxy routes return 503 SRS_NOT_CONFIGURED so
14+
// the UI's empty states still render.
515

6-
import { Router, type Router as IRouter } from "express";
16+
import { Router, type Router as IRouter, type Response } from "express";
17+
import { getDb } from "../mongo.js";
718

819
export const policiesRouter: IRouter = Router();
920

10-
policiesRouter.get("/policies", (_req, res) => res.json({ policies: [] }));
11-
policiesRouter.get("/policies/:id", (_req, res) =>
12-
res.status(404).json({ error: { code: "NOT_FOUND" } }),
21+
const SRS_BASE = (process.env["SRS_BASE_URL"] ?? "").replace(/\/+$/, "");
22+
const SRS_KEY = process.env["SRS_API_KEY"] ?? "";
23+
24+
function safeParse(text: string): unknown {
25+
try {
26+
return JSON.parse(text);
27+
} catch {
28+
return { raw: text };
29+
}
30+
}
31+
32+
/**
33+
* Forward a request to SRS, inject x-api-key, relay status + JSON body.
34+
*
35+
* `opts.fallback` (used for list GETs): when SRS is unset/unreachable/5xx,
36+
* respond 200 with the fallback instead of an error, so the Policies page
37+
* renders its empty state cleanly rather than surfacing "/policies → 502".
38+
* Writes pass no fallback, so create/update/delete still surface the error.
39+
*/
40+
async function srs(
41+
res: Response,
42+
method: string,
43+
path: string,
44+
opts: { body?: unknown; fallback?: unknown } = {},
45+
): Promise<void> {
46+
const { body, fallback } = opts;
47+
if (!SRS_BASE) {
48+
if (fallback !== undefined) {
49+
res.json(fallback);
50+
return;
51+
}
52+
res.status(503).json({
53+
error: { code: "SRS_NOT_CONFIGURED", message: "Policy service (SRS) is not configured. Set SRS_BASE_URL." },
54+
});
55+
return;
56+
}
57+
try {
58+
const r = await fetch(`${SRS_BASE}${path}`, {
59+
method,
60+
headers: {
61+
"x-api-key": SRS_KEY,
62+
...(body !== undefined ? { "content-type": "application/json" } : {}),
63+
},
64+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
65+
});
66+
if (fallback !== undefined && r.status >= 500) {
67+
res.json(fallback);
68+
return;
69+
}
70+
const text = await r.text();
71+
res.status(r.status).json(text ? safeParse(text) : {});
72+
} catch (err) {
73+
if (fallback !== undefined) {
74+
res.json(fallback);
75+
return;
76+
}
77+
res.status(502).json({ error: { code: "SRS_UNREACHABLE", message: (err as Error).message } });
78+
}
79+
}
80+
81+
// ── RAI policies → SRS /v1/rai/policies ───────────────────────────────────
82+
policiesRouter.get("/policies", (_req, res) =>
83+
srs(res, "GET", "/v1/rai/policies", { fallback: { policies: [] } }),
1384
);
14-
policiesRouter.post("/policies", (_req, res) =>
15-
res.status(503).json({
16-
error: { code: "SRS_NOT_CONFIGURED", message: "Policy service (SRS) is not deployed in this environment." },
17-
}),
85+
policiesRouter.get("/policies/:id", (req, res) =>
86+
srs(res, "GET", `/v1/rai/policies/${encodeURIComponent(req.params["id"]!)}`),
1887
);
19-
policiesRouter.put("/policies/:id", (_req, res) =>
20-
res.status(503).json({ error: { code: "SRS_NOT_CONFIGURED" } }),
88+
policiesRouter.post("/policies", (req, res) => srs(res, "POST", "/v1/rai/policies", { body: req.body ?? {} }));
89+
policiesRouter.put("/policies/:id", (req, res) =>
90+
srs(res, "PUT", `/v1/rai/policies/${encodeURIComponent(req.params["id"]!)}`, { body: req.body ?? {} }),
2191
);
22-
policiesRouter.delete("/policies/:id", (_req, res) =>
23-
res.status(503).json({ error: { code: "SRS_NOT_CONFIGURED" } }),
92+
policiesRouter.delete("/policies/:id", (req, res) =>
93+
srs(res, "DELETE", `/v1/rai/policies/${encodeURIComponent(req.params["id"]!)}`),
2494
);
2595

26-
policiesRouter.get("/agents/:name/policy", (_req, res) => res.json({ binding: null }));
27-
policiesRouter.put("/agents/:name/policy", (_req, res) => res.json({ binding: null }));
28-
29-
policiesRouter.get("/opa-policies", (_req, res) => res.json({ policies: [] }));
30-
policiesRouter.get("/opa-policies/:id", (_req, res) =>
31-
res.status(404).json({ error: { code: "NOT_FOUND" } }),
96+
// ── OPA rego policies → SRS /v1/opa-policies ──────────────────────────────
97+
policiesRouter.get("/opa-policies", (_req, res) =>
98+
srs(res, "GET", "/v1/opa-policies", { fallback: { policies: [] } }),
3299
);
33-
policiesRouter.post("/opa-policies", (_req, res) =>
34-
res.status(503).json({ error: { code: "SRS_NOT_CONFIGURED" } }),
100+
policiesRouter.get("/opa-policies/:id", (req, res) =>
101+
srs(res, "GET", `/v1/opa-policies/${encodeURIComponent(req.params["id"]!)}`),
35102
);
36-
policiesRouter.put("/opa-policies/:id", (_req, res) =>
37-
res.status(503).json({ error: { code: "SRS_NOT_CONFIGURED" } }),
103+
policiesRouter.post("/opa-policies", (req, res) => srs(res, "POST", "/v1/opa-policies", { body: req.body ?? {} }));
104+
policiesRouter.put("/opa-policies/:id", (req, res) =>
105+
srs(res, "PUT", `/v1/opa-policies/${encodeURIComponent(req.params["id"]!)}`, { body: req.body ?? {} }),
38106
);
39-
policiesRouter.delete("/opa-policies/:id", (_req, res) =>
40-
res.status(503).json({ error: { code: "SRS_NOT_CONFIGURED" } }),
107+
policiesRouter.delete("/opa-policies/:id", (req, res) =>
108+
srs(res, "DELETE", `/v1/opa-policies/${encodeURIComponent(req.params["id"]!)}`),
41109
);
110+
111+
// ── Per-agent policy binding (agentos-local, Mongo) ───────────────────────
112+
interface PolicyBindingDoc {
113+
_id: string; // agent name
114+
policyId: string;
115+
updatedAt: Date;
116+
}
117+
118+
async function bindingsColl() {
119+
return (await getDb()).collection<PolicyBindingDoc>("agent_policies");
120+
}
121+
122+
policiesRouter.get("/agents/:name/policy", async (req, res, next) => {
123+
try {
124+
const doc = await (await bindingsColl()).findOne({ _id: req.params["name"]! });
125+
res.json({ binding: doc ? { policyId: doc.policyId } : null });
126+
} catch (err) {
127+
next(err);
128+
}
129+
});
130+
131+
policiesRouter.put("/agents/:name/policy", async (req, res, next) => {
132+
try {
133+
const name = req.params["name"]!;
134+
const body = (req.body ?? {}) as Record<string, unknown>;
135+
const policyId = typeof body["policy_id"] === "string" ? (body["policy_id"] as string) : null;
136+
const coll = await bindingsColl();
137+
if (!policyId) {
138+
await coll.deleteOne({ _id: name });
139+
res.json({ binding: null });
140+
return;
141+
}
142+
await coll.updateOne({ _id: name }, { $set: { policyId, updatedAt: new Date() } }, { upsert: true });
143+
res.json({ binding: { policyId } });
144+
} catch (err) {
145+
next(err);
146+
}
147+
});

packages/agentos-server/src/routes/run.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import { Router, type Router as IRouter } from "express";
55
import { caAuthHeader } from "../auth.js";
66
import { caBase, pipeUpstream } from "../upstream.js";
7-
import { resolveAgent, runBodyFor } from "../agent-defs.js";
7+
import { resolveAgent, runBodyFor, srsPolicyForAgent } from "../agent-defs.js";
88

99
export const runRouter: IRouter = Router();
1010

@@ -22,6 +22,10 @@ runRouter.post("/agents/:name/run", async (req, res, next) => {
2222
const status = (err as any)?.status ?? 503;
2323
return res.status(status).json({ error: { code: "AGENT_CONFIG", message: (err as Error).message } });
2424
}
25+
// Attach the agent's bound SRS policy (if any). Enforced by the harness's
26+
// always-on PreToolUse hook, so bypassPermissions autonomy is preserved.
27+
const policy = await srsPolicyForAgent(agent.name);
28+
if (policy) body.policy = policy;
2529

2630
const upstream = await fetch(`${caBase()}/run`, {
2731
method: "POST",

packages/engine-claude-agent-sdk/src/engine.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,43 @@ export class ClaudeAgentEngine implements EngineDriver<ClaudeAgentOptions> {
124124
...storeOpts,
125125
};
126126

127+
// Policy enforcement that survives bypassPermissions. canUseTool is skipped
128+
// under --dangerously-skip-permissions, so the policy decider wired into
129+
// onPermissionRequest never fires. A PreToolUse hook, by contrast, runs for
130+
// EVERY tool call regardless of permission mode — route it through
131+
// onPermissionRequest (which consults the bound decider → SRS → OPA/Cedar)
132+
// and translate a deny into the hook's deny decision. Only registered when
133+
// a policy is active, so non-policy agents keep autonomous bypass behavior.
134+
if (ctx.policyActive) {
135+
(options as Record<string, unknown>)["hooks"] = {
136+
PreToolUse: [
137+
{
138+
hooks: [
139+
async (input: unknown, toolUseId?: string) => {
140+
const pre = input as { tool_name?: string; tool_input?: Record<string, unknown> };
141+
const result = await ctx.onPermissionRequest({
142+
callId: toolUseId ?? `hook-${pre.tool_name ?? "tool"}`,
143+
toolName: pre.tool_name ?? "",
144+
input: pre.tool_input ?? {},
145+
});
146+
if (result.behavior === "deny") {
147+
log.info("policy.hook_deny", { sessionId: ctx.sessionId, toolName: pre.tool_name });
148+
return {
149+
hookSpecificOutput: {
150+
hookEventName: "PreToolUse",
151+
permissionDecision: "deny",
152+
permissionDecisionReason: result.message ?? "blocked by policy",
153+
},
154+
};
155+
}
156+
return {};
157+
},
158+
],
159+
},
160+
],
161+
};
162+
}
163+
127164
try {
128165
for await (const message of query({ prompt, options })) {
129166
if (ctx.abortSignal.aborted) break;

packages/harness-server/src/services/run-session.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export async function runSession(
5757
workdir: session.workdir,
5858
envs: session.envs,
5959
userMessageQueue: session.userMessages(),
60+
policyActive: !!session.policyDecider,
6061
onPermissionRequest: async (req) => {
6162
logger.info("session.permission_request", {
6263
sessionId: session.sessionId,

packages/protocol/src/contracts.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,14 @@ export interface EngineContext<TOptions = unknown> {
4949
readonly userMessageQueue: AsyncIterable<UserMessage>;
5050
/** Engine calls this for permission gating; framework handles the round-trip. */
5151
readonly onPermissionRequest: (req: PermissionRequest) => Promise<PermissionResult>;
52+
/**
53+
* True when a policy decider is bound for this session. Engines whose
54+
* permission path can be bypassed (e.g. claude-agent-sdk under
55+
* `bypassPermissions`, which skips `canUseTool`) should additionally gate
56+
* tools through an always-on mechanism — a `PreToolUse` hook that routes to
57+
* `onPermissionRequest` — so policy enforcement survives bypass mode.
58+
*/
59+
readonly policyActive?: boolean;
5260
/** Wired to `POST /cancel`; engine must observe and bail. */
5361
readonly abortSignal: AbortSignal;
5462
/** Optional hard cost cap. */

packages/sdk/src/computer-agent.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,9 @@ export class ComputerAgent {
482482
if (this.opts.attachments && this.opts.attachments.length > 0) {
483483
body.attachments = this.opts.attachments;
484484
}
485+
// Forward per-tool-call policy config so the harness builds its decider
486+
// (SrsPolicyDecider). Without this the harness never gates tool calls.
487+
if (this.opts.policy) body.policy = this.opts.policy;
485488

486489
const res = await this.fetchImpl(`${harnessUrl}/v1/sessions`, {
487490
method: "POST",

0 commit comments

Comments
 (0)