FabricFabricHarness
Building Agents

Enterprise Controls

Apply tools, policy, approvals, budgets, and instructions at the job definition boundary.

Finite jobs can declare their runtime controls once at the definition boundary. The wrapper applies them whenever run() calls init(), including through CLI, development server, and built artifacts.

Complete governed job

Create .fabricharness/jobs/governed-report.ts:

import { defineTool, job, schema } from '@fabric-harness/sdk';

const lookupAccount = defineTool({
  name: 'lookup_account',
  description: 'Read an account summary.',
  inputSchema: {
    type: 'object',
    additionalProperties: false,
    properties: { accountId: { type: 'string' } },
    required: ['accountId'],
  },
  execute: async ({ accountId }: { accountId: string }) => ({
    accountId,
    status: 'active',
  }),
});

export default job({
  name: 'governed-report',
  description: 'Create a read-only account report.',
  instructions: 'Use only approved account data. Never infer missing values.',
  input: schema.object({ accountId: schema.string() }),
  output: schema.string(),
  triggers: { webhook: true, manual: true },
  tools: [lookupAccount],
  policy: {
    toolPolicy: {
      allow: ['lookup_account'],
      deny: ['write', 'edit', 'bash'],
      requireApproval: ['export_*'],
    },
    network: { mode: 'none' },
    approvals: {
      requiredApprovals: 2,
      risk: 'high',
      defaultTimeoutMs: 5 * 60_000,
    },
  },
  costBudget: {
    perCall: 0.05,
    perSession: 0.50,
    onExceed: 'throw',
  },
  approvals: { timeoutMs: 5 * 60_000 },
  async run({ init, input }) {
    const agent = await init();
    const session = await agent.session();
    return session.prompt(`Create the report for ${input.accountId}.`);
  },
});

Run and inspect it:

fh describe governed-report
fh run governed-report --account-id acct-42

Precedence rules

Definition controls are defaults with security-aware composition:

ControlBehavior when init({...}) also supplies a value
instructionsA call-provided role wins. Otherwise instructions become the agent role.
tools and skillsDefinition and call values are combined.
policyDefinition policy is a security floor. Calls may add denials and approvals, but cannot replace definition allowlists.
costBudgetThe lower per-call and per-session limits win. A call cannot raise the definition budget.
approvals.timeoutMsThe shorter definition/call timeout wins.

For example, this call can narrow the job but cannot enable network or raise its budget:

const agent = await init({
  policy: {
    toolPolicy: { deny: ['lookup_account'] },
  },
  costLimit: { perSession: 0.25 },
});

Adversarial controls

For outbound HTTP, enable DNS answer verification in Node deployments so an allowed hostname cannot resolve to loopback, link-local, metadata, or RFC1918 space:

const policy = {
  network: {
    mode: 'allowlist',
    hosts: ['api.example.com'],
    protocols: ['https:'],
    resolveDns: true,
  },
};

policiedFetch() checks the original URL, each redirect, IP literals, and DNS answers. Production containers should also enforce egress at the network layer because process-level policy cannot prevent code that deliberately bypasses the configured fetch implementation.

Tool aliases declare policyAlias so policy evaluates their canonical capability. For example, a save_file tool with policyAlias: 'write' remains subject to every write deny and approval rule. Raw access through session.sandbox cannot bypass requireApproval; only the exact operation inside the resolved approval scope receives a temporary grant.

Durable attachment materialization defaults to 20 attachments, 10 MiB per decoded attachment, and 25 MiB total. createSubmissionRunner({ attachments: { ... } }) can set lower maxCount, maxAttachmentBytes, and maxTotalBytes values for a route or tenant.

Approval handlers

The definition declares when approval is required and how long it may wait. The host supplies the operator integration:

const agent = await init({
  onApproval: async (request) => {
    const decision = await approvalService.waitForDecision(request);
    return decision; // approved or denied
  },
});

Temporal-backed sessions persist approval waits. Inline sessions require the process to remain available. See Approvals for CLI and API resolution.

Tenant-wide budgets

Use a shared budget store when limits must survive restarts or span replicas:

import { tenantCostLimit } from '@fabric-harness/sdk';
import { postgresCostBudgetStore } from '@fabric-harness/node';

const costLimit = tenantCostLimit('acme', {
  perDayUsd: 50,
  store: postgresCostBudgetStore({ client: pool }),
});

const agent = await init({ tenantId: 'acme', costLimit });

Definition budgets protect one job/session. Shared budget stores enforce organization or tenant ceilings across processes. Postgres and SQLite use atomic reservations: concurrent calls that would cross the ceiling are rejected without committing an over-budget total.

Bind scheduled background jobs to a service principal instead of running them without identity:

await startDevServer({
  scheduler: {
    identity: async (jobName) => ({
      tenantId: 'acme',
      actor: { principal: { id: `scheduler:${jobName}`, type: 'service' } },
    }),
  },
});

Channel adapters derive tenant and actor from the verified provider payload. HTTP principals cannot list, read, abort, delete, or vote on resources owned by another tenant.

See also