Node SDK
`@cirvix/agent-control` wraps your tool-executor boundary and throws a typed `CirvixDenied` your agent can plan around.
Install
$ npm install @cirvix/agent-controlZero runtime dependencies. Node 20 or later. ESM only.
guard.wrap()
import { readFile } from "node:fs/promises";import { guard, parseRules, CirvixDenied, CirvixHeld } from "@cirvix/agent-control"; const rules = parseRules(JSON.parse(await readFile("cirvix.policy.json", "utf8"))); const tools = guard.wrap(myTools, { agent: "pr-triage", environment: process.env.CIRVIX_ENV ?? "local", rules,}); try { await agent.invoke(input);} catch (err) { if (err instanceof CirvixHeld) { console.log(err.approvers); // ["platform-oncall"] — a person can release this } else if (err instanceof CirvixDenied) { console.log(err.policy); // "deny-dotenv-read" console.log(err.remediation); // 'secrets.get("STRIPE_KEY")' console.log(err.decisionId); // pass to `cirvix why` } throw err;}wrap forwards its options straight to the Guard constructor, which takes an in-memory rule array. Passing an option Guard does not have leaves rules empty — and an empty rule set denies everything. Load from disk with parseRules, or use loadPolicy from the testing entry point.What wrap accepts, and what it gives up
It returns the same shape it was given — an object of name → function, an array of tool objects carrying a callable (func, invoke, call, execute, handler, _call, run), or a single function. Descriptions, schemas, and framework metadata are preserved by reference, and fn.name survives, because frameworks introspect both.
What you give up versus the gateway is the property that matters most: wrap governs the tools you hand it. A tool the agent reaches directly is never evaluated. That trade is stated rather than glossed, because an operator who believes they are equivalent will not understand why a call was never seen.
CirvixDenied and CirvixHeld
| Property | Type | Meaning |
|---|---|---|
policy | string \| null | The rule that decided the call |
decisionId | string \| null | Pass to cirvix why for the full record |
reason | string \| null | Human-readable explanation |
remediation | string \| null | A suggested next step, when one exists |
appealable | boolean | Whether re-requesting with an approval could succeed |
approvers | string[] | CirvixHeld only — who can release the call |
They are distinct types because they call for different behaviour: a denial means re-plan, a hold means this exact call may still happen once somebody says yes. Collapsing them teaches agents to treat both as failure.
Testing a policy
import { evaluate } from "@cirvix/agent-control/testing"; test("production writes are held for a human", async () => { const decision = await evaluate({ policyDir: "./policies", agent: "deploy-bot", action: "k8s.apply", resource: "production/checkout", context: { environment: "production" }, }); expect(decision.verdict).toBe("hold"); expect(decision.approvers).toContain("platform-oncall");});evaluate accepts one rule source: rules, policy/policyFile, or policyDir. The default context is permissive on purpose — inside the workspace, no external egress, no secret touched — so a test asserting a denial is denied by the rule it is testing rather than by a restrictive default.
Secret handles
import { SecretsClient } from "@cirvix/agent-control/secrets"; const secrets = new SecretsClient({ apiUrl: process.env.CIRVIX_API_URL, apiKey: process.env.CIRVIX_API_KEY, // cvx_… agent: "billing-bot",}); const handle = await secrets.get("STRIPE_RESTRICTED_KEY");// "sec_handle_9f2c…" — opaque, and useless anywhere the secret does not sanctionSecretsClient is a class, not a module-level singleton. Pass it to a Guard as secrets and substitution happens inside authorize — all or nothing, because partial substitution would send a real credential alongside a literal handle.
Subpath exports
/policy · /guard · /gateway · /daemon · /audit · /secrets · /testing
