Skip to content

Commit 9b773b7

Browse files
committed
Merge origin/main into chore/oss-cleanup-public
Resolves 9 conflicts arising from PR #10 (policy guardrails) landing on main after this branch was cut. Strategy: - Backend files where main has the real new policy feature (engine.ts, permission-bridge.ts, run-session.ts, sdk/src/types.ts, protocol/src/ policy.ts, harness-server/services/srs-policy-decider.ts): took main's version, then re-applied the customer-name scrub on top (Lyzr SRS mentions, hardcoded srs-dev.test.studio.lyzr.ai URL). - packages/sdk/src/types.ts: kept main's full ComputerAgentOptions (policy field with structured spec) AND added back the telemetry? field from the cleanup branch (AgentTelemetry hook). Both fields coexist. - 5 source files (engine.ts, permission-bridge.ts, types.ts, run-session.ts, srs-policy-decider.ts) had stale 'import from "@computeragent/protocol"' lines from PR #10 — that package name does not exist on public npm (the scope is unowned; d19ada7 renamed the workspace package to @open-gitagent/protocol). Updated all 5 imports to @open-gitagent/protocol, matching what the other 19 workspace packages already use. - examples/agentos-api.ts: union-merged HEAD's cookie-session login + /me / /logout endpoints with main's SRS policy proxy block. Scrubbed LYZR_API_KEY env var → SRS_API_KEY, dropped hardcoded srs-dev.test.studio.lyzr.ai default (now requires explicit SRS_BASE_URL). - agentos files (App.tsx, vite.config.ts): HEAD's shadcn-based version is strictly newer than main's hand-rolled one (Observability rail item, AgentCard import, full ui/* primitives). Took HEAD. Verification: - pnpm -r build green across all 22 packages - 0 hits of {lyzr, nordstrom, clawagent, shreyas-lyzr} in source (excluding gitignored private.md / PLAN.md / docs.md, none of which make the public surface)
2 parents 48a409b + 7544765 commit 9b773b7

8 files changed

Lines changed: 201 additions & 142 deletions

File tree

examples/agentos-api.ts

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { sandboxBodyForBot } from "./slack-bot.ts";
2222
import { AgentLogStore } from "./agent-log-store.ts";
2323
import { ScheduleStore, computeNextRun, describeSchedule, type ScheduleKind } from "./schedule-store.ts";
2424
import { runAgentOnce } from "./scheduler.ts";
25+
import { AgentPolicyStore } from "./agent-policy-store.ts";
2526

2627
// ── Cookie-based session auth ───────────────────────────────────────────────
2728
// HMAC-signed cookie (no DB session table — stateless). Format:
@@ -135,6 +136,7 @@ export interface AgentOSOptions {
135136
readonly agents: readonly AgentDef[];
136137
readonly logStore: AgentLogStore;
137138
readonly scheduleStore?: ScheduleStore;
139+
readonly policyStore?: AgentPolicyStore;
138140
}
139141

140142
interface SessionDoc {
@@ -248,7 +250,7 @@ export function createAgentOSApp(opts: AgentOSOptions): Hono {
248250
const token = signSession(body.user, expMs);
249251
setCookie(c, SESSION_COOKIE, token, {
250252
httpOnly: true,
251-
secure: true, // Caddy terminates TLS so all real-world requests are https
253+
secure: true, // reverse proxy terminates TLS so all real-world requests are https
252254
sameSite: "Strict",
253255
maxAge: SESSION_MAX_AGE_SEC,
254256
path: "/",
@@ -275,6 +277,29 @@ export function createAgentOSApp(opts: AgentOSOptions): Hono {
275277
// Everything else under /agentos/api/* requires auth.
276278
app.use("/agentos/api/*", requireAuth);
277279

280+
// SRS (policy backend) — hoisted so the chat-sandbox handler and the
281+
// policy-proxy routes share the same config.
282+
const srsBase = (process.env.SRS_BASE_URL ?? "").replace(/\/+$/, "");
283+
const srsKey = process.env.SRS_API_KEY ?? "";
284+
const srsHeaders = (extra: Record<string, string> = {}): Record<string, string> => {
285+
const h: Record<string, string> = { ...extra };
286+
if (srsKey) h["x-api-key"] = srsKey;
287+
return h;
288+
};
289+
const srsProxy = async (method: string, path: string, body?: unknown): Promise<Response> => {
290+
if (!srsKey || !srsBase) {
291+
return new Response(JSON.stringify({ error: { code: "SRS_NOT_CONFIGURED", message: "SRS_BASE_URL and SRS_API_KEY must be set" } }), { status: 503, headers: { "content-type": "application/json" } });
292+
}
293+
const init: RequestInit = {
294+
method,
295+
headers: srsHeaders(body !== undefined ? { "content-type": "application/json" } : {}),
296+
};
297+
if (body !== undefined) init.body = JSON.stringify(body);
298+
const r = await fetch(`${srsBase}${path}`, init);
299+
const text = await r.text();
300+
return new Response(text, { status: r.status, headers: { "content-type": r.headers.get("content-type") ?? "application/json" } });
301+
};
302+
278303
// ── Agents list + per-agent stats ──────────────────────────────────────
279304
app.get("/agentos/api/agents", async (c) => {
280305
// Live sandboxes (loopback) — used to mark which agents have warm sessions.
@@ -629,9 +654,24 @@ export function createAgentOSApp(opts: AgentOSOptions): Hono {
629654
}
630655
if (!sessionId) sessionId = `agentos-${agent.name}-${randomUUID().slice(0, 12)}`;
631656

657+
// Lookup policy binding (if any) and translate to a sandbox policy spec.
658+
let policySpec: { kind: "srs"; endpoint: string; apiKey: string; policyId: string; principalId: string } | undefined;
659+
if (opts.policyStore && srsKey) {
660+
const binding = await opts.policyStore.get(agent.name);
661+
if (binding) {
662+
policySpec = {
663+
kind: "srs",
664+
endpoint: srsBase,
665+
apiKey: srsKey,
666+
policyId: binding.policyId,
667+
principalId: `agentos:${agent.name}`,
668+
};
669+
}
670+
}
632671
const sandboxBody = sandboxBodyForBot(
633672
{ name: agent.name, harness: agent.harness, source: agent.source, model: agent.model, extraEnvs: agent.envs, gitToken: agent.gitToken },
634673
sessionId,
674+
policySpec,
635675
);
636676
const r = await fetch(`${caBase}/sandboxes`, {
637677
method: "POST",
@@ -831,6 +871,52 @@ export function createAgentOSApp(opts: AgentOSOptions): Hono {
831871
return c.json({ ok: true, running: true });
832872
});
833873

874+
// ── Policies — proxy SRS so the browser never sees SRS_API_KEY ─────────
875+
//
876+
// The external SRS (Service-RBAC-Service) owns policy CRUD. We forward
877+
// GET/POST/PUT/DELETE on /v1/rai/policies, inject `x-api-key` server-side,
878+
// and pass the body through unchanged. The dropdown on the agent screen
879+
// uses GET /policies to populate; the policy editor uses POST/PUT.
880+
app.get("/agentos/api/policies", async () => srsProxy("GET", "/v1/rai/policies"));
881+
app.get("/agentos/api/policies/:id", async (c) => srsProxy("GET", `/v1/rai/policies/${encodeURIComponent(c.req.param("id"))}`));
882+
app.post("/agentos/api/policies", async (c) => srsProxy("POST", "/v1/rai/policies", await c.req.json().catch(() => ({}))));
883+
app.put("/agentos/api/policies/:id", async (c) => srsProxy("PUT", `/v1/rai/policies/${encodeURIComponent(c.req.param("id"))}`, await c.req.json().catch(() => ({}))));
884+
app.delete("/agentos/api/policies/:id", async (c) => srsProxy("DELETE", `/v1/rai/policies/${encodeURIComponent(c.req.param("id"))}`));
885+
886+
// OPA rego policies — referenced by RAI policies' opa_guardrail.managed_policies[].policy_id.
887+
app.get("/agentos/api/opa-policies", async () => srsProxy("GET", "/v1/opa-policies"));
888+
app.get("/agentos/api/opa-policies/:id", async (c) => srsProxy("GET", `/v1/opa-policies/${encodeURIComponent(c.req.param("id"))}`));
889+
app.post("/agentos/api/opa-policies", async (c) => srsProxy("POST", "/v1/opa-policies", await c.req.json().catch(() => ({}))));
890+
app.put("/agentos/api/opa-policies/:id", async (c) => srsProxy("PUT", `/v1/opa-policies/${encodeURIComponent(c.req.param("id"))}`, await c.req.json().catch(() => ({}))));
891+
app.delete("/agentos/api/opa-policies/:id", async (c) => srsProxy("DELETE", `/v1/opa-policies/${encodeURIComponent(c.req.param("id"))}`));
892+
893+
// ── Agent → policy binding (our own; lives in Mongo, not SRS) ───────────
894+
//
895+
// One policy_id per agent. The runtime reads this at sandbox-create time
896+
// and feeds it into the SrsPolicyDecider that gates every tool call.
897+
app.get("/agentos/api/agents/:name/policy", async (c) => {
898+
if (!opts.policyStore) return c.json({ error: { code: "NO_POLICY_STORE" } }, 503);
899+
if (!byName.has(c.req.param("name"))) return c.json({ error: { code: "UNKNOWN_AGENT" } }, 404);
900+
const b = await opts.policyStore.get(c.req.param("name"));
901+
return c.json({ binding: b });
902+
});
903+
app.put("/agentos/api/agents/:name/policy", async (c) => {
904+
if (!opts.policyStore) return c.json({ error: { code: "NO_POLICY_STORE" } }, 503);
905+
if (!byName.has(c.req.param("name"))) return c.json({ error: { code: "UNKNOWN_AGENT" } }, 404);
906+
const body = await c.req.json().catch(() => ({})) as { policy_id?: string | null };
907+
if (body.policy_id == null) {
908+
await opts.policyStore.delete(c.req.param("name"));
909+
return c.json({ binding: null });
910+
}
911+
const b = await opts.policyStore.set(c.req.param("name"), body.policy_id);
912+
return c.json({ binding: b });
913+
});
914+
app.delete("/agentos/api/agents/:name/policy", async (c) => {
915+
if (!opts.policyStore) return c.json({ error: { code: "NO_POLICY_STORE" } }, 503);
916+
await opts.policyStore.delete(c.req.param("name"));
917+
return c.json({ ok: true });
918+
});
919+
834920
app.get("/agentos/api/health", (c) => c.json({ ok: true, agents: opts.agents.map((a) => a.name) }));
835921

836922
// ── Policies stubs ─────────────────────────────────────────────────────────

examples/slack-bot.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -404,11 +404,23 @@ export interface AgentRuntimeSpec {
404404
* session store, S3 auto-save, TTLs) so the Slack flow and the AgentOS web console
405405
* create identically-configured sandboxes. Secrets stay server-side.
406406
*/
407-
export function sandboxBodyForBot(bot: AgentRuntimeSpec, sessionId: string): Record<string, unknown> {
407+
export interface SandboxPolicySpec {
408+
readonly kind: "srs";
409+
readonly endpoint: string;
410+
readonly apiKey: string;
411+
readonly policyId: string;
412+
readonly principalId: string;
413+
}
414+
415+
export function sandboxBodyForBot(
416+
bot: AgentRuntimeSpec,
417+
sessionId: string,
418+
policy?: SandboxPolicySpec,
419+
): Record<string, unknown> {
408420
const body: Record<string, unknown> = {
409421
source: bot.source,
410422
harness: bot.harness,
411-
runtime: "bwrap",
423+
runtime: process.env.DEFAULT_SANDBOX_RUNTIME ?? "bwrap",
412424
options: { permissionMode: "bypassPermissions", settingSources: ["project"] },
413425
sessionId,
414426
sessionStore: { kind: "mongo" },
@@ -421,6 +433,7 @@ export function sandboxBodyForBot(bot: AgentRuntimeSpec, sessionId: string): Rec
421433
};
422434
if (bot.model) body.model = bot.model;
423435
if (bot.gitToken) body.gitToken = bot.gitToken;
436+
if (policy) body.policy = policy;
424437
return body;
425438
}
426439

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

Lines changed: 10 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type {
88
UserMessage,
99
} from "@open-gitagent/protocol";
1010
import { nopLogger } from "@open-gitagent/protocol";
11-
import { buildCanUseTool } from "./permission-bridge.js";
11+
import { buildCanUseTool, buildPreToolUseHook } from "./permission-bridge.js";
1212
import { deriveEngineUuid } from "./derive-uuid.js";
1313

1414
const CAPABILITIES: EngineCapabilities = {
@@ -106,6 +106,14 @@ export class ClaudeAgentEngine implements EngineDriver<ClaudeAgentOptions> {
106106
includePartialMessages: true,
107107
abortController,
108108
canUseTool: buildCanUseTool(ctx.onPermissionRequest),
109+
// PreToolUse hook — fires even in bypassPermissions mode (where canUseTool
110+
// is skipped) and its deny overrides any other permission decision. The
111+
// harness routes this to its policy decider; without one, the hook
112+
// returns allow and falls through to canUseTool / the default flow.
113+
hooks: {
114+
...((ctx.options as { hooks?: ClaudeAgentOptions["hooks"] }).hooks ?? {}),
115+
PreToolUse: [{ hooks: [buildPreToolUseHook(ctx.onPermissionRequest)] }],
116+
},
109117
...(ctx.budget?.maxUsd !== undefined ? { maxBudgetUsd: ctx.budget.maxUsd } : {}),
110118
...storeOpts,
111119
};
@@ -261,13 +269,7 @@ function signalToController(signal: AbortSignal): AbortController {
261269
*
262270
* Caller envs (api keys, etc.) override these on conflict.
263271
*/
264-
/**
265-
* Pure: snapshot the env vars the spawned harness subprocess needs from the
266-
* parent process. Includes the standard POSIX/XDG essentials plus the AWS
267-
* Bedrock envs the Claude Agent SDK reads when `CLAUDE_CODE_USE_BEDROCK=1`.
268-
* Exported for testability — the engine itself calls it inline.
269-
*/
270-
export function inheritEssentialHostEnv(): Record<string, string> {
272+
function inheritEssentialHostEnv(): Record<string, string> {
271273
const out: Record<string, string> = {};
272274
for (const k of [
273275
"HOME",
@@ -279,21 +281,6 @@ export function inheritEssentialHostEnv(): Record<string, string> {
279281
"CLAUDE_CONFIG_DIR",
280282
"XDG_CONFIG_HOME",
281283
"XDG_DATA_HOME",
282-
// AWS Bedrock — when CLAUDE_CODE_USE_BEDROCK=1, the Claude Agent SDK
283-
// switches transport to Bedrock and uses the standard AWS credential
284-
// chain. On EKS that means the IRSA-projected web-identity token at
285-
// AWS_WEB_IDENTITY_TOKEN_FILE + AWS_ROLE_ARN; locally it's the usual
286-
// AWS_PROFILE / shared-credentials flow. Pass these through so the
287-
// chain can find them inside the spawned harness subprocess.
288-
"CLAUDE_CODE_USE_BEDROCK",
289-
"AWS_REGION",
290-
"AWS_DEFAULT_REGION",
291-
"AWS_BEDROCK_MODEL_ID",
292-
"AWS_ROLE_ARN",
293-
"AWS_WEB_IDENTITY_TOKEN_FILE",
294-
"AWS_PROFILE",
295-
"AWS_SHARED_CREDENTIALS_FILE",
296-
"AWS_CONFIG_FILE",
297284
]) {
298285
const v = process.env[k];
299286
if (v) out[k] = v;

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

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { CanUseTool, PermissionResult } from "@anthropic-ai/claude-agent-sdk";
1+
import type { CanUseTool, HookCallback, PermissionResult } from "@anthropic-ai/claude-agent-sdk";
22
import type { PermissionRequest } from "@open-gitagent/protocol";
33
import { classifyRisk } from "./risk.js";
44

@@ -19,6 +19,43 @@ export function buildCanUseTool(
1919
};
2020
}
2121

22+
/**
23+
* PreToolUse hook bridge — fires for every tool call regardless of
24+
* permissionMode (canUseTool is skipped in bypassPermissions, but
25+
* PreToolUse hooks always run and their deny overrides canUseTool).
26+
*
27+
* The same `onPermissionRequest` callback is reused — the harness uses
28+
* it for policy enforcement (SrsPolicyDecider gates here in
29+
* bypassPermissions sandboxes).
30+
*/
31+
export function buildPreToolUseHook(
32+
onPermissionRequest: (req: PermissionRequest) => Promise<PermissionResult>,
33+
): HookCallback {
34+
return async (input, toolUseID) => {
35+
if (input.hook_event_name !== "PreToolUse") return {};
36+
const callId = toolUseID ?? `call_${cryptoRandomId()}`;
37+
const toolName = input.tool_name;
38+
const toolInput = input.tool_input;
39+
const risk = classifyRisk(toolName, toolInput);
40+
const decision = await onPermissionRequest({ callId, toolName, input: toolInput, risk });
41+
if (decision.behavior === "deny") {
42+
return {
43+
hookSpecificOutput: {
44+
hookEventName: "PreToolUse",
45+
permissionDecision: "deny",
46+
permissionDecisionReason: decision.message ?? "denied by policy",
47+
},
48+
};
49+
}
50+
return {
51+
hookSpecificOutput: {
52+
hookEventName: "PreToolUse",
53+
permissionDecision: "allow",
54+
},
55+
};
56+
};
57+
}
58+
2259
function cryptoRandomId(): string {
2360
return Math.random().toString(36).slice(2, 10);
2461
}

packages/protocol/src/policy.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export interface SrsPolicyConfig {
5454
readonly endpoint: string;
5555
/** x-api-key for SRS. Never leaves the harness. */
5656
readonly apiKey: string;
57-
/** Policy id whose cedar_guardrail + opa_guardrail to apply. */
57+
/** RAI policy_id whose cedar_guardrail + opa_guardrail to apply. */
5858
readonly policyId: string;
5959
/** Forwarded to SRS as the `principal_id` for audit. */
6060
readonly principalId: string;

0 commit comments

Comments
 (0)