Certified Agent Harness Engineer
Course sections

M5.2

Designing a permission model

925 words · about 6 min

Concept

A permission model maps a proposed action to allow (proceed silently), deny (refuse, tell the model why), or ask (suspend and consult a human). Everything else is machinery for computing that function cheaply and predictably. Three properties make or break it:

  • Fail-closed. If no decision can be reached — no rule matched, approver absent, sandbox unavailable — the answer is deny. Ambiguity resolving to "allow" is how every silent-bypass bug gets written.
  • A closed outcome vocabulary, small and total, so no caller invents a fourth state and no switch silently falls through.
  • Deterministic conflict resolution — precedence as a stated invariant, not an artifact of registration order.

Mechanism: matching, scope, persistence

Mechanism

Matching. Rules match a tool identity plus a predicate over arguments — a command prefix, a path glob, a URL host. Two errors recur. First, matching a model-supplied string without normalizing: rm -rf /tmp/x, rm -rf /tmp/x, and /bin/rm -rf /tmp/x are one action and three strings, so a string-prefix allow-list is an allow-list of spellings. Second, matching a path before canonicalization (M5.4).

Scope. A grant has three independent dimensions, and conflating them is the classic bug: what (this call, this tool, this tool with this argument pattern, anything), where (this workspace, session, machine, user), and how long (this call, this session, until revoked). "Allow npm test" must not become "allow npm anywhere forever." A grant that widens along an axis the user did not choose is privilege escalation even though nothing was exploited.

Persistence. One-shot grants authorize exactly the call in front of the user. Persistent grants are recorded and consulted later — and must be inspectable and revocable, because a user who cannot enumerate what they allowed cannot audit it, and an agent accumulating invisible grants converges on full access one "yes" at a time.

Worked example — reading a real precedence implementation

Worked example

dsh runs permission decisions through its hook protocol, which can receive several independent outputs for one action. The merge is in packages/hooks/hook-protocol/src/merge.ts:

export type MergedDecision = 'allow' | 'ask' | 'deny' | 'none'

function rank(decision: HookOutput['decision']): number {
  switch (decision) {
    case 'deny': case 'block': return 3
    case 'ask': return 2
    case 'approve': case 'allow': return 1
    default: return 0 // no decision
  }
}

Precedence is deny > ask > allow, and because the result is the maximum rank rather than the last writer, no ordering of hooks can turn a deny into an allow. none (rank 0) is a genuine abstention distinct from allow — a hook with no opinion does not thereby grant permission. That distinction is the most important line in the file: collapse them and every hook that fails to produce output becomes an authorizer. Reasons for the winning rank are joined, so the user sees every reason for the outcome.

The decision the pipeline carries is a closed union in packages/core/tools/src/index.ts:

export type PreToolDecision =
  | { kind: 'allow' }
  | { kind: 'deny'; reason: string }
  | { kind: 'ask'; reason?: string }

deny requires a reason while allow carries none. Denial must always be explicable — to the user auditing it, and to the model, which needs to know why so it can choose another approach instead of retrying. The registry's JSDoc states the third case's contract: "ask runs only after an approval service returns allowed-once and otherwise denies."

Tradeoffs

Rich rule languages express real policy but become their own attack surface: a regex a user cannot read is a permission they cannot audit, and a subtly wrong glob is a hole. Coarse models are auditable at a glance and force all-or-nothing choices, which pushes users to "all." Most harnesses land in the middle, and the middle is genuinely unsettled.

How `dsh` does it

How dsh does it

dsh splits the decision across a pipeline rather than one policy engine, with the order documented and generated from code (docs/tool-execution-pipeline.md). tool/call is logged before execution — an action is on the record before it can have effects — then tools/pre-execute runs hooks, permission, and sandbox concerns. The approval consumption site (serviceAsk) maps every state to a decision:

StateResult
No approval service composeddeny — "requires approval (not yet supported)"
No agent to route throughdeny
allowed-onceallow
rejecteddeny — the user rejected it
cancelleddeny, flagged cancelled
unavailabledeny — no approval channel
anything elseassertNever(outcome, 'ApprovalOutcome')

One positive outcome, six negative, and a compile-time exhaustiveness assertion. That last row is the pattern to copy: assertNever turns "someone extended the enum and forgot this switch" from a runtime hole into a build failure.

After approval, monotonic guards (ToolGuard, returning string | undefined) run. Guards can only deny or abstain — the type has no allow result — so no ordering can undo an earlier denial. Same insight as the merge's max-rank, expressed in the type signature instead of the algorithm.

How others differ

Claude Code exposes user-editable allow/ask/deny lists scoped per-project and per-user plus a permission hook — more declarative, trading auditability for the risk that a broad user-written pattern quietly widens access. Codex CLI leans on coarse modes rather than per-rule matching: far easier to reason about, far less expressive. dsh's pipeline of narrow seams optimizes for the property that no plugin author can accidentally create a bypass, at the cost of policy spread across several packages rather than legible in one file.


Check yourself

4 questions on M5.2

Start drill