Building the enforcement point

Agent Control Room can only govern what goes through it.

An action that can run without asking is not governed, however good the rules are. This page describes the side you build: the platform that runs your agents, holds your data and credentials, and acts only on a valid grant.

Everything on How it fits and the plug-in recipes is a placement of this one pattern — in a backend, a gateway, an MCP proxy or an orchestrator.

The two sides

Decision point — Agent Control Room

Holds the rules, the approval queue, the signed grants and the decision record. Answers one step at a time: allow, refuse, or ask a person first.

Enforcement point — your platform

Runs the agent, holds the data and the credentials, and carries out the work. Acts only on a signed, unexpired grant bound to exactly the work in front of it.

Agent proposes a stepYour gateAgent Control Room decidesGate verifies the grantYour gate runs the stepOutcome reported

Where the facts come from

Behind your platform sit the systems that already know the truth about a step: your identity provider, customer records, account limits. Security architects call these policy information points (PIP). Your gate reads them and sends what it finds as context facts. Agent Control Room never connects to them itself, and the agent's own words never stand in for them.

Five design rules

  1. 1. One gate

    Every privileged step — reading a file, calling a model, writing a record, sending a message, moving money — goes through one wrapper that asks, verifies, acts and reports. There is no second path.

  2. 2. The agent holds no power of its own

    Credentials, tool clients and network access sit behind the gate. The model can propose a step; it can never call the tool directly.

  3. 3. Verify before acting

    Check the signature, expiry, workspace, agent, action and the fingerprint of exactly what is about to run. For a hand-off, check the parent decision too.

  4. 4. Fail closed

    No grant, an expired grant, a fingerprint that does not match or Agent Control Room unreachable: the step does not run.

  5. 5. Close the loop

    Report the outcome after the step. A step held for a person waits and resumes when they answer; it is never retried in a loop.

What can still slip past — and who closes it

These routes are closed by how your platform is built. They are your responsibility, not Agent Control Room's.

Route around the gatePlatform control that closes it
The agent holds an API key or database passwordKeep secrets in the gate's process only; give the agent no environment variables or files containing them.
A tool is registered without the wrapperBuild tool clients only through a factory that requires a verified grant; review new tools in code review.
Side channels: shell, open network, file systemRun agents in a sandbox with no outbound network except the gate; no general-purpose shell.
A step nobody thought to governDefault-deny at the gate: an action without a declared name refuses, and so does an action with no rule.
A grant reused for different workBind each grant to the payload fingerprint and use it once; recompute the fingerprint at the moment of execution.
A retry runs the same step twice — the connection dropped after the downstream system had already actedAgent Control Room makes asking safe to repeat, not acting. Pass the decision id as the downstream system's own idempotency key, or record that the step ran before you retry it.
The agent makes up its own context facts — who it acts for, its role, its limitsBuild context facts in the gate from trusted systems: a validated token from your identity provider, your own records. Never pass along facts the model wrote. Agent Control Room judges the facts it is given; it cannot tell where they came from.

Incentive-proof by construction

Even an agent with strong reasons to cut corners cannot act without a grant, because the ability to act lives outside the agent. Agent Control Room does not try to read intent or detect deception; it makes the honest path the only path that executes.

A reference pattern

The library curator agent in the Complyee Policy Library follows this pattern. For each document it asks before reading the file, asks again before calling the model, and asks a third time before writing the abstract. Each step carries its own grant bound to its own payload, and a refusal at any step stops the run with a recorded reason.

Code skeletons

TypeScript, fictional names, trimmed to the essentials. These are skeletons to adapt and test in your own platform, not a packaged library. Helpers such as acr(), sha256Hex() and b64() are a few lines each; the API reference has the exact formats. For TypeScript MCP servers and Python agents you can instead use the supported @complyee/mcp-guard and complyee-agent-guard packages, which do the same work without assembling the gate by hand.

The gate

Ask, verify, act, report.

// One gate for every privileged step. Nothing else holds the credentials.
type Step<T> = {
  agent: string;              // e.g. "promo-writer"
  action: string;             // e.g. "campaign.publish"
  resourceRef?: string;       // e.g. "campaign:spring-2027"
  payload: unknown;           // exactly what is about to run
  parentDecisionId?: string;  // set when this step was handed on
  context?: Record<string, unknown>; // built by the gate from your information points
                                      // (validated identity token, your own records),
                                      // never copied from what the model wrote
};

export async function withDecision<T>(step: Step<T>, run: () => Promise<T>): Promise<T> {
  const payloadHash = await sha256Hex(canonicalJson(step.payload));
  const request = { ...step, payload: undefined, payloadHash };
  let res = await acr("decideAction", request);

  if (res.decision.effect === "pending") {
    res = await waitForPerson(res.decision.approvalId, request); // poll status, then claim once
  }
  if (res.decision.effect !== "grant") {
    throw new PolicyDenied(res.decision);                 // refused: do nothing
  }
  await verifyGrant(res.decision, { ...step, payloadHash }); // throws on any mismatch

  const report = (outcome: "executed" | "aborted" | "downstream_error") =>
    acr("reportExecutionOutcome", {
      decisionId: res.decision.decisionId,
      payloadHash,                              // must match the hash the grant is bound to
      outcome,
      occurredAt: new Date().toISOString(),
    });

  try {
    const result = await run();
    await report("executed");
    return result;
  } catch (err) {
    await report("downstream_error");          // use "aborted" if you chose not to run it
    throw err;
  }
}

// Calling the gate. Facts come from verified sources, never the LLM prompt:
const context = {
  "requester.role": verifiedUser.role,             // From verified IAM token
  "customer.tenure_years": await getTenure(orgId), // From database/CRM
};
await withDecision(
  { agent: "refund-agent", action: "refund.issue", payload: refund, context },
  () => payments.refund(refund),
);

Checking a grant offline

No call back to Agent Control Room on the hot path.

// Check the grant locally with the published key (fetched once, cached).
// The signature covers the agent, action and payload hash YOU expect, so a grant for other work fails.
async function verifyGrant(d: Grant, expected: Expected) {
  if (Date.parse(d.expiresAt) <= Date.now()) throw new Error("expired");
  const l = d.lineage;                         // null for a root grant
  if ((l?.parentDecisionId ?? "") !== (expected.parentDecisionId ?? "")) throw new Error("wrong lineage");

  const message = ["acr-grant-v3", workspaceId, d.keyId, d.decisionId,
    expected.agent, expected.action,           // who may do what, as this gate knows it
    expected.payloadHash,                      // your own hash, never one read from the answer
    d.expiresAt, l?.parentDecisionId ?? "", l?.rootDecisionId ?? d.decisionId,
    String(l?.depth ?? 0)].join("|");
  const key = await publicKeyFor(d.keyId);     // from getGrantVerificationKey (also gives workspaceId)
  const ok = await crypto.subtle.verify("Ed25519", key, b64(d.publicSignature),
    new TextEncoder().encode(message));
  if (!ok) throw new Error("bad signature");   // other agent, action, payload or lineage, or altered grant
}
// A service handed the grant later checks it the same way, with the agent and action it will serve.
// For high-value steps, also call verifyDecision to catch a grant revoked before expiry.

Or: checking it as a standard token

For platforms that already verify JWTs against a published key set — same grant, standard tooling.

// Optional: your platform already checks JSON Web Tokens. Ask with grantFormat: "jwt";
// a signed grant then also carries decision.grantJwt — the same grant, same key, same expiry.
// jwtVerify and createRemoteJWKSet come from your standard JWT library.
const ACR_KEYS = createRemoteJWKSet(new URL("https://agent-cr.com/.well-known/jwks.json"));

async function verifyGrantJwt(d: Grant, expected: Expected) {
  if (!d.grantJwt) throw new Error("no token");      // refusals and held steps never carry one
  const { payload } = await jwtVerify(d.grantJwt, ACR_KEYS, {
    algorithms: ["EdDSA"],                           // pin it; never accept what the token says
    issuer: "https://agent-cr.com",
    audience: WORKSPACE_ADDRESS,                     // https://<workspace>.agent-cr.com
    subject: expected.agent,
  });                                                // signature, expiry and key rotation handled here
  const acr = payload.acr as GrantClaims;
  if (acr.action !== expected.action) throw new Error("wrong action");
  if (acr.payloadHash !== expected.payloadHash) throw new Error("wrong payload"); // your own hash
  if ((acr.lineage.parentDecisionId ?? "") !== (expected.parentDecisionId ?? "")) throw new Error("wrong lineage");
}
// The signature proves Agent Control Room said yes; the comparisons prove it said yes to this work.
// Same limit as above: a local check cannot see a revocation — call verifyDecision where that matters.

When a person must decide

The exception path.

// A person is asked only where a rule says so. Park the step and resume.
// Poll getApprovalStatus (never decideAction) while pending; once granted,
// repeat the identical decideAction call once to claim the signed, single-use grant.
async function waitForPerson(approvalId: string, request: DecideRequest) {
  for (let delay = 5_000; ; delay = Math.min(delay * 2, 15_000)) {
    await sleep(delay);
    const { approval } = await acr("getApprovalStatus", { approvalId });
    if (approval.status === "pending") continue;
    if (approval.status === "granted") return acr("decideAction", request); // the claim
    return { decision: { effect: "reject", reason: approval.status } };      // refused, expired or consumed
  }
}
// Claim within fifteen minutes of approval, and never change the payload while waiting.

Handing work to another agent

Authority narrows on every hop.

// Handing work to another agent: pass the parent decision, never a credential.
await withDecision(
  { agent: "lead-qualifier", action: "crm.update", payload: lead,
    parentDecisionId: parentGrant.decisionId },
  () => crm.update(lead),
);
// ACR narrows authority on every hop: depth, expiry and amounts can only shrink.

decideActiongetApprovalStatusgetGrantVerificationKeyverifyDecisionreportExecutionOutcome

Before you go live

  • Every privileged step goes through one gate.
  • The agent process holds no credentials, keys or tokens.
  • Tool clients can only be built with a verified grant.
  • Agents run without open network or shell access.
  • The gate refuses when Agent Control Room cannot be reached.
  • Grants are checked for signature, expiry, agent, action and payload fingerprint.
  • Context facts come from trusted systems — a validated identity token or your own records — never from the agent's output.
  • Each grant is used once, for the work it was issued for.
  • A retried step cannot run twice downstream: the call carries its own idempotency key, such as the decision id.
  • Hand-offs pass the parent decision, never a credential.
  • Held steps wait for a person and resume; nothing retries in a loop.
  • Every executed or failed step is reported back.