Skip to content
Open
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
16 changes: 16 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions examples/computeragent-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -861,6 +863,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 }
: {}),
Expand Down
27 changes: 26 additions & 1 deletion packages/agentos-server/src/agent-defs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -185,6 +185,31 @@ export function runBodyFor(agent: AgentDef, message: string): Record<string, unk
return body;
}

/**
* Build the SRS policy config for an agent if it has a bound RAI policy.
*
* Returns null when SRS isn't configured (`SRS_BASE_URL` unset) or the agent
* has no binding in `agent_policies`. The harness's SrsPolicyDecider uses
* `endpoint` to reach SRS per tool call — `SRS_BASE_URL` is reachable from the
* harness container too (host.docker.internal:8500 on Docker Desktop), so the
* same value works for both agentos and the spawned harness.
*/
export async function srsPolicyForAgent(agentName: string): Promise<Record<string, unknown> | 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) {
Expand Down
7 changes: 6 additions & 1 deletion packages/agentos-server/src/routes/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { randomUUID } from "node:crypto";
import { caAuthHeader } from "../auth.js";
import { caBase, pipeUpstream } from "../upstream.js";
import { chatPinsColl, chatSessionsColl } 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();

Expand Down Expand Up @@ -66,6 +66,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() },
Expand Down
162 changes: 134 additions & 28 deletions packages/agentos-server/src/routes/policies.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<PolicyBindingDoc>("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<string, unknown>;
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);
}
});
6 changes: 5 additions & 1 deletion packages/agentos-server/src/routes/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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",
Expand Down
37 changes: 37 additions & 0 deletions packages/engine-claude-agent-sdk/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,43 @@ export class ClaudeAgentEngine implements EngineDriver<ClaudeAgentOptions> {
...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<string, unknown>)["hooks"] = {
PreToolUse: [
{
hooks: [
async (input: unknown, toolUseId?: string) => {
const pre = input as { tool_name?: string; tool_input?: Record<string, unknown> };
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;
Expand Down
1 change: 1 addition & 0 deletions packages/harness-server/src/services/run-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions packages/protocol/src/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ export interface EngineContext<TOptions = unknown> {
readonly userMessageQueue: AsyncIterable<UserMessage>;
/** Engine calls this for permission gating; framework handles the round-trip. */
readonly onPermissionRequest: (req: PermissionRequest) => Promise<PermissionResult>;
/**
* 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. */
Expand Down
3 changes: 3 additions & 0 deletions packages/sdk/src/computer-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading