Skip to content

Policy Engine

The Policy Engine evaluates rules at every decision point in the kernel — tool calls, task claims, and task completions. Its core invariant: a deny decision at any layer is final and cannot be reversed by any other layer.

Policies are organized into four layers, evaluated in fixed order:

workspace → lane → pack → task
LayerWho sets itPurpose
WorkspaceWorkspace administratorOrganization-wide rules (e.g., “no network access”)
LaneLane configurationDomain-specific restrictions (e.g., “this lane is read-only”)
PackPack manifestDomain rules from the pack author (e.g., “gates must pass before completion”)
TaskTask specificationPer-task rules (e.g., “this task cannot modify src/auth/”)

Each layer can contain a default decision and a list of rules.

A rule has four parts:

FieldTypeDescription
idstringUnique identifier (e.g., software-delivery.gate.format)
triggerenumWhen to evaluate: on_tool_request, on_claim, on_completion, on_evidence_added
decisionenumallow or deny
reasonstring?Human-readable explanation

Rules in pack manifests are static — they apply unconditionally when their trigger fires. Runtime rules (set programmatically) can include a when predicate for conditional evaluation (see Conditional Rules with when() below).

The evaluation algorithm processes layers in order:

  1. Start with deny. The effective decision begins as deny (fail-closed).
  2. Apply defaults. The first layer to declare a default_decision sets the baseline. Subsequent layers can only tighten (deny), not loosen, unless explicitly granted allow_loosening.
  3. Match rules. For each layer, find rules whose trigger matches the current context.
  4. Sticky deny. Once any rule emits deny, no subsequent allow rule in any layer can reverse it.
Workspace: default=allow          → effective: allow
Lane:      (no default)           → effective: allow
Pack:      rule deny(on_completion) → effective: deny  ← sticky
Task:      rule allow(on_completion) → REJECTED (cannot loosen a hard deny)
                                    → final: deny

Each trigger fires at a specific lifecycle moment:

TriggerWhen it firesExample use
on_tool_requestEvery tool call, during authorizationBlock specific tools, restrict write operations
on_claimWhen a task is claimed (ready → active)Require approval before work starts
on_completionWhen a task completes (active → done)Enforce gates (format, lint, test)
on_evidence_addedWhen a new evidence record is writtenReserved for future use

In the Software Delivery Pack, quality gates (format, lint, typecheck, test) are implemented as pack-layer policies with trigger: on_completion. When you run pnpm wu:prep or pnpm gates, the system evaluates these policies:

GatePolicy IDTrigger
Format checksoftware-delivery.gate.formaton_completion
Lintsoftware-delivery.gate.linton_completion
Type checksoftware-delivery.gate.typecheckon_completion
Testsoftware-delivery.gate.teston_completion

This means gates are not just CLI commands — they are kernel-enforced policies. An agent cannot skip gates by calling completeTask directly; the policy engine will deny the completion.

The kernel reserves several policy IDs for internal use:

IDPurpose
kernel.policy.allow-allDevelopment-only allow-all hook
kernel.scope.reserved-pathBlocks writes to .lumenflow/**
kernel.scope.boundaryDenies when scope intersection is empty
kernel.reconciliationMarks crash-reconciled evidence entries

Runtime policy rules can include a when predicate — a function that receives the full PolicyEvaluationContext and returns true if the rule should apply. Rules without a when predicate match unconditionally whenever their trigger fires.

const rule: PolicyRule = {
  id: 'my-pack.block-dangerous-paths',
  trigger: 'on_tool_request',
  decision: 'deny',
  reason: 'Writes to /etc are not permitted',
  when: (context) => {
    const args = context.tool_arguments;
    if (!args || typeof args.path !== 'string') return false;
    return args.path.startsWith('/etc');
  },
};

The when predicate receives a PolicyEvaluationContext with these fields:

FieldTypeDescription
triggerstringThe trigger type that fired this evaluation
run_idstringCurrent run identifier
tool_namestring?Name of the tool being called (for on_tool_request)
task_idstring?Active task identifier
lane_idstring?Lane the task belongs to
pack_idstring?Pack that provides the tool
tool_argumentsRecord<string, unknown>?Parsed tool input arguments (see below)

The tool_arguments field provides the parsed input arguments for the tool being called. The kernel extracts this automatically from the raw tool input: if the input is a plain object, it is passed as tool_arguments; otherwise the field is undefined.

This enables argument-level policy decisions — rules that inspect what a tool is doing, not just which tool is being called:

// Block file writes to sensitive directories
const rule: PolicyRule = {
  id: 'security.block-sensitive-writes',
  trigger: 'on_tool_request',
  decision: 'deny',
  reason: 'Cannot write to credentials directory',
  when: (context) => {
    if (context.tool_name !== 'file:write') return false;
    const args = context.tool_arguments;
    if (!args || typeof args.path !== 'string') return false;
    return args.path.includes('.credentials/');
  },
};

Every policy evaluation returns:

  • decision — the final allow or deny
  • decisions[] — every rule that matched, with its individual decision and reason
  • warnings[] — any loosening attempts that were rejected

All of this is recorded in the evidence store as part of the tool trace, providing a complete audit trail of why an action was allowed or denied.

  • Kernel Runtime — Where policy evaluation fits in the tool execution pipeline
  • Scope Intersection — The permission model that runs alongside policies
  • Evidence Store — How policy decisions are recorded
  • Gates — How the Software Delivery Pack uses policies for quality checks