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-42Precedence rules
Definition controls are defaults with security-aware composition:
| Control | Behavior when init({...}) also supplies a value |
|---|---|
instructions | A call-provided role wins. Otherwise instructions become the agent role. |
tools and skills | Definition and call values are combined. |
policy | Definition policy is a security floor. Calls may add denials and approvals, but cannot replace definition allowlists. |
costBudget | The lower per-call and per-session limits win. A call cannot raise the definition budget. |
approvals.timeoutMs | The 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.