policy
Decide whether to approve or reject the interrupts an agent raises, without prompting the user every time. A Policy is an ordered list of glob-pattern rules per interrupt effect, evaluated first-match-wins.
cliPolicyHandler is the common entry point: it loads a policy file, prompts the user on each new interrupt, remembers "always" decisions, and replays matching rules so each pattern is only asked about once.
import { cliPolicyHandler } from "std::policy"
node main() {
const home = env("HOME") with approve
// Bind to a variable — the `with` clause only accepts an identifier.
const handler = cliPolicyHandler(
file: "${home}/.myapp/policy.json",
fields: { "std::read": [{ field: "dir", matchSubpaths: true }] },
)
handle {
llm("hi", { tools: [...] })
} with handler
}For a different UI (a web prompt, a Slack bot, a non-interactive CI mode), build your own handler on the pure primitives: checkPolicy, recordRule, recordScopedRule, parsePolicyFile, writePolicyFile, validatePolicy, and buildScopedMatch.
Types
InterruptDataKey
Key of an interrupt's data object (e.g. "dir", "command").
/** Key of an interrupt's `data` object (e.g. `"dir"`, `"command"`). */
export type InterruptDataKey = string(source)
InterruptDataVal
Glob pattern used to match an interrupt-data value. Patterns are picomatch globs. They support *, **, and brace-expansion like {a,b} for unions. A literal string with no glob metacharacters matches only that exact value.
/**
* Glob pattern used to match an interrupt-data value. Patterns are
* picomatch globs. They support `*`, `**`, and brace-expansion like
* `{a,b}` for unions. A literal string with no glob metacharacters
* matches only that exact value.
*/
export type InterruptDataVal = string(source)
InterruptEffect
Identifier for an interrupt's effect (e.g. "std::read", "myapp::deploy").
/**
* Identifier for an interrupt's effect (e.g. `"std::read"`,
* `"myapp::deploy"`).
*/
export type InterruptEffect = string(source)
PolicyRule
One row of a Policy. A rule passes if every key in match is present in the interrupt's data and its value matches the glob pattern. Omit match (or set it to {}) for a catch-all that applies to every interrupt of the parent effect.
A reject rule may carry a rejectMessage: the message the rejection hands back to whoever raised the interrupt. For a rejected tool call this is what the model reads, so it can steer the next attempt — for example, a rule rejecting std::bash with rejectMessage: "Use safeBash instead".
/**
* One row of a `Policy`. A rule passes if every key in `match` is
* present in the interrupt's `data` and its value matches the glob
* pattern. Omit `match` (or set it to `{}`) for a catch-all that
* applies to every interrupt of the parent effect.
*
* A reject rule may carry a `rejectMessage`: the message the rejection
* hands back to whoever raised the interrupt. For a rejected tool call
* this is what the model reads, so it can steer the next attempt — for
* example, a rule rejecting `std::bash` with
* `rejectMessage: "Use safeBash instead"`.
*/
export type PolicyRule = {
match?: Record<InterruptDataKey, InterruptDataVal>;
action: "approve" | "reject" | "propagate";
rejectMessage?: string
}(source)
Policy
A policy: ordered rules per interrupt effect. checkPolicy walks the array for intr.effect in order and returns on the first matching rule. If no rule for the effect exists, evaluation falls through to propagate (i.e. ask the next handler in the chain).
/**
* A policy: ordered rules per interrupt effect. `checkPolicy` walks
* the array for `intr.effect` in order and returns on the first
* matching rule. If no rule for the effect exists, evaluation falls
* through to `propagate` (i.e. ask the next handler in the chain).
*/
export type Policy = Record<InterruptEffect, PolicyRule[]>(source)
Decision
The five answers cliPolicyHandler's prompt accepts:
"approve"/"reject"— one-off (a) / (r)."approve-always"/"reject-always"— (aa) / (rr). Records a catch-all rule for the effect, so future interrupts of this effect resolve without prompting."approve-always-here"— (ap). Records a scoped rule pinned to whichever fields you listed inScopedRuleFieldsfor this effect. Only offered when the effect has an entry in the config.
/**
* The five answers `cliPolicyHandler`'s prompt accepts:
* - `"approve"` / `"reject"` — one-off (a) / (r).
* - `"approve-always"` / `"reject-always"` — (aa) / (rr). Records a
* catch-all rule for the effect, so future interrupts of this effect
* resolve without prompting.
* - `"approve-always-here"` — (ap). Records a scoped rule pinned to
* whichever fields you listed in `ScopedRuleFields` for this effect.
* Only offered when the effect has an entry in the config.
*/
export type Decision =
| "approve"
| "reject"
| "approve-always"
| "approve-always-here"
| "reject-always"(source)
ScopedField
One column in a ScopedRuleFields entry — names an interrupt-data field that the "approve-always-here" rule should pin.
field— the key inintr.datato pin (e.g."dir").matchSubpaths— whentrue, brace-expand the value so the rule matches both the exact value AND any nested path under it. Passtruefor directory-like fields (so approving/tmp/xalso approves/tmp/x/sub/file.txt). Passfalsefor opaque identifiers (commands, IDs, env names) that shouldn't fan out.
/**
* One column in a `ScopedRuleFields` entry — names an interrupt-data
* field that the "approve-always-here" rule should pin.
*
* - `field` — the key in `intr.data` to pin (e.g. `"dir"`).
* - `matchSubpaths` — when `true`, brace-expand the value so the
* rule matches both the exact value AND any nested path under it.
* Pass `true` for directory-like fields (so approving `/tmp/x`
* also approves `/tmp/x/sub/file.txt`). Pass `false` for opaque
* identifiers (commands, IDs, env names) that shouldn't fan out.
*/
export type ScopedField = {
field: string;
matchSubpaths: boolean
}(source)
ScopedRuleFields
Per-effect override consumed by buildScopedMatch and the cliPolicyHandler. Maps an interrupt effect to the fields its "approve-always-here" rule should pin. Effects not present in this map fall back to the scope their effect declaration carries (@always / @alwaysUnder); an empty list turns the (ap) option off for that effect, so the user falls back to (a) / (r) / (aa) / (rr).
Example:
const FIELDS: ScopedRuleFields = {
"std::read": [{ field: "dir", matchSubpaths: true }],
"std::exec": [
{ field: "command", matchSubpaths: false },
{ field: "subcommand", matchSubpaths: false },
],
}/**
* Per-effect override consumed by `buildScopedMatch` and the
* `cliPolicyHandler`. Maps an interrupt effect to the fields its
* "approve-always-here" rule should pin. Effects not present in this
* map fall back to the scope their `effect` declaration carries
* (`@always` / `@alwaysUnder`); an empty list turns the (ap) option
* off for that effect, so the user falls back to (a) / (r) / (aa) / (rr).
*
* Example:
* ```ts
* const FIELDS: ScopedRuleFields = {
* "std::read": [{ field: "dir", matchSubpaths: true }],
* "std::exec": [
* { field: "command", matchSubpaths: false },
* { field: "subcommand", matchSubpaths: false },
* ],
* }
* ```
*/
export type ScopedRuleFields = Record<InterruptEffect, ScopedField[]>(source)
ParsePolicyFailureStatus
export type ParsePolicyFailureStatus =
| "doesnt-exist"
| "read-error"
| "malformed-json"
| "policy-not-valid"(source)
ParsePolicyFailure
export type ParsePolicyFailure = {
status: ParsePolicyFailureStatus
}(source)
Constants
minimalAutoApprovePolicy
export static const minimalAutoApprovePolicy = _minimalAutoApprovePolicy(source)
recommendedAutoApprovePolicy
export static const recommendedAutoApprovePolicy = _recommendedAutoApprovePolicy(source)
approveAllPolicy
export static const approveAllPolicy = _approveAllPolicy(source)
BUILTIN_POLICIES
export static const BUILTIN_POLICIES = _BUILTIN_POLICIES(source)
Functions
withWritesPolicy
withWritesPolicy(baseDir: string)The recommended policy plus file-system and git-write effects, each scoped to baseDir and its children.
Parameters:
| Name | Type | Default |
|---|---|---|
| baseDir | string |
(source)
builtinPolicy
builtinPolicy(name: string, baseDir: string): Policy | nullResolve a built-in policy name to a concrete Policy, scoping cwd-relative variants to baseDir. Returns null for an unknown name.
Parameters:
| Name | Type | Default |
|---|---|---|
| name | string | |
| baseDir | string |
Returns: Policy | null
(source)
builtinPolicyNames
builtinPolicyNames(): string[]The names accepted by builtinPolicy, e.g. for an approval prompt.
Returns: string[]
(source)
checkPolicy
checkPolicy(policy: Record<string, any>, interrupt: Record<string, any>)Evaluate a policy against an interrupt. Returns approve(), reject(), or propagate() based on the first matching rule. A reject carries the rule's rejectMessage as .message when one was set.
@param policy - Ordered rules keyed by interrupt effect; each rule has optional glob-pattern match fields, an action, and (on reject rules) an optional rejectMessage. @param interrupt - The interrupt to evaluate.
Evaluate a policy against a single interrupt. Returns the result of approve(), reject(), or propagate() corresponding to the first matching rule for interrupt.effect. If no rule matches (no rules for the effect, or every rule's match failed), returns propagate() so the next handler in the chain runs.
A reject result carries the matched rule's rejectMessage (when the rule set one) as .message, so a handler can pass it on with reject(decision.message). Returning the result directly from a handler (return checkPolicy(policy, intr)) also carries the message.
Designed for use inside a custom handler. The CLI sugar (cliPolicyHandler) calls this for you.
Parameters:
| Name | Type | Default |
|---|---|---|
| policy | Record<string, any> | |
| interrupt | Record<string, any> |
(source)
validatePolicy
validatePolicy(policy: Record<string, any>): Result<void>Validate that a policy object is well-formed. Returns { success: true } if valid, or { success: false, error } describing the problem.
@param policy - The policy object to validate.
Check that a Policy is structurally valid (every entry is an array of PolicyRule with a recognised action, every match is a flat string→string map, etc.). Returns { success: true } or { success: false, error: string }.
Call before persisting user-supplied or hand-edited policy data; writePolicyFile calls this internally before writing.
Parameters:
| Name | Type | Default |
|---|---|---|
| policy | Record<string, any> |
Returns: Result<void>
(source)
alwaysScopeFor
alwaysScopeFor(effect: string): ScopedField[]The fields an "approve always here" rule pins for effect, from the effect's @always / @alwaysUnder declaration. Empty when the effect declares none.
@param effect - The interrupt effect name, e.g. "std::env".
Parameters:
| Name | Type | Default |
|---|---|---|
| effect | string |
Returns: ScopedField[]
(source)
defaultScopedFields
defaultScopedFields(): ScopedRuleFieldsEvery declared always-scope, keyed by effect. What cliPolicyHandler uses when its caller passes no fields.
Returns: ScopedRuleFields
(source)
buildScopedMatch
buildScopedMatch(
intr: Record<string, any>,
fields: ScopedRuleFields = {},
): Record<string, string>Build a match object for an interrupt, pinned to its scoped fields. Values are escaped so they match literally. Returns {} when the effect has no scope or the interrupt data lacks a scoped field.
@param intr - The interrupt whose data fields to pin. @param fields - Per-effect override of which data fields to pin. Effects not listed use their declared @always scope.
Build the match map for a scoped rule by reading the scoped fields out of intr.data. The returned object is shaped to plug straight into a PolicyRule.match:
const match = buildScopedMatch(intr)
const rule: PolicyRule = { match: match, action: "approve" }Which fields: the fields entry for intr.effect when the caller passed one, otherwise the effect's declared @always scope. For each ScopedField:
- The field's value is read from
intr.dataand escaped, so a value likels *.mdmatches only that exact command, never a glob. - If
matchSubpaths: true, the value is wrapped as"{value,value/**}"so the resulting glob matches both the exact value and any subpath under it. - If
matchSubpaths: false, the escaped value is used as-is.
If any scoped field is absent from intr.data (null / undefined) the result is {}: a rule missing one of its pins would be wider than the user was shown, and a rule with an empty match approves every future interrupt of the effect. An effect with no scope returns {}.
Most callers should use recordScopedRule instead, which calls this internally. buildScopedMatch is exposed for callers assembling rules by hand or implementing a custom UI that needs to preview the match before recording.
Parameters:
| Name | Type | Default |
|---|---|---|
| intr | Record<string, any> | |
| fields | ScopedRuleFields | {} |
Returns: Record<string, string>
(source)
recordRule
recordRule(
policy: Policy,
effect: InterruptEffect,
action: "approve" | "reject",
): PolicyReturn a new policy with a catch-all rule for an effect appended. A single bare rule covers every future interrupt of that effect.
@param policy - The policy to extend (not mutated). @param effect - The interrupt effect the rule applies to. @param action - Whether to approve or reject matching interrupts.
Return a new policy with a catch-all rule ({ action } with no match) for effect appended. Pure — does not mutate the input.
Precedence trap
Evaluation is first-match-wins, so append order matters. A second call for the same effect with a different action is dead code:
let p = recordRule({}, "std::read", "reject")
p = recordRule(p, "std::read", "approve") // never reachedIf you're flipping a previously-recorded decision, decide explicitly: either reset the effect's rules first ({ ...policy, "std::read": [] } and re-record), or hand-edit policy[effect] to replace the offending rule. This function does not try to detect or warn about shadowing on your behalf.
Parameters:
| Name | Type | Default |
|---|---|---|
| policy | Policy | |
| effect | InterruptEffect | |
| action | "approve" | "reject" |
Returns: Policy
(source)
recordScopedRule
recordScopedRule(
policy: Policy,
intr: Record<string, any>,
fields: ScopedRuleFields = {},
): PolicyReturn a new policy with a scoped approve rule prepended for the interrupt's effect. The rule pins the scoped fields, so it approves only future interrupts matching this one's field values. Returns the policy unchanged when there is nothing to pin, because a rule with an empty match would approve every future interrupt of the effect.
@param policy - The policy to extend (not mutated). @param intr - The interrupt whose field values to pin. @param fields - Per-effect override of which data fields to pin. Effects not listed use their declared @always scope.
Return a new policy with a scoped approve rule prepended for intr.effect. The rule's match is built by buildScopedMatch, so it pins whichever fields are configured for the effect. Pure.
Prepended (not appended) so the new, more-specific rule wins over any pre-existing catch-all in first-match-wins order. This makes scoped rules safe to add even if the effect already has a broader rejection: the scoped approval applies first when it matches, otherwise the catch-all takes over.
The action is always "approve", because the (ap) UI affordance only makes sense in the affirmative direction. Build a scoped reject by hand if you need one.
Parameters:
| Name | Type | Default |
|---|---|---|
| policy | Policy | |
| intr | Record<string, any> | |
| fields | ScopedRuleFields | {} |
Returns: Policy
(source)
parsePolicyFile
parsePolicyFile(path: string): Result<Policy, ParsePolicyFailure>Read + parse + validate a policy file from disk. Returns {} on any failure (missing, unreadable, malformed JSON, invalid schema) with a warning to the user.
@param path - The policy file path
Read + JSON-parse + validate a policy file from disk. Returns a Failure naming the exact problem: a missing file, unreadable permissions, malformed JSON, or a schema-validation error. It prints nothing — callers decide how loudly to surface the failure.
Raises std::read (so the caller's handler chain controls whether the read is approved). The CLI handler auto-approves this via with approve.
Parameters:
| Name | Type | Default |
|---|---|---|
| path | string |
Returns: Result<Policy, ParsePolicyFailure>
Throws: std::read
(source)
setPolicy
setPolicy(path: string, policy: Policy)Install policy as the active policy and persist it to path.
Set the policy to be used with the CLI handler returned by cliPolicyHandler. The handler's internal state is module-level, so this sets the policy for the handler to consult on every interrupt. Call this after loading a policy with parsePolicyFile or constructing one by hand.
Parameters:
| Name | Type | Default |
|---|---|---|
| path | string | |
| policy | Policy |
(source)
writePolicyFile
writePolicyFile(path: string, policy: Policy, allowedPaths: string[] = [])Validate and write a policy to a JSON file. Throws if the policy is invalid.
@param path - The destination file path. @param policy - The policy to write. @param allowedPaths - Restrict writes to these path prefixes; empty allows any path.
Validate a Policy and write it as JSON to path. Throws (returns Failure) if validation fails. Invalid policies are never persisted.
allowedPaths is a defense-in-depth allow-list passed straight through to the underlying write. Pass [] (the default) only when the path is trusted. Otherwise restrict it to a known directory like ["/home/you/.myapp"].
Parameters:
| Name | Type | Default |
|---|---|---|
| path | string | |
| policy | Policy | |
| allowedPaths | string[] | [] |
(source)
missingPolicyRules
missingPolicyRules(base: Policy): PolicyThe rules base has that the cliPolicyHandler's active policy lacks, keyed by effect. An effect the active policy already decides with a rule that has no match is left out, because a rule added after it could never match.
@param base - The policy to compare against, such as recommendedAutoApprovePolicy.
A saved policy file is a copy of a built-in policy from the day it was created, plus the user's own "always" answers, so a rule added to the built-in later never reaches it. These two functions let a program show the user what their file lacks and add it when they say so. Neither runs on its own, and nothing is added without being asked for.
Parameters:
| Name | Type | Default |
|---|---|---|
| base | Policy |
Returns: Policy
Throws: std::read
(source)
addPolicyRules
addPolicyRules(additions: Policy)Append rules to the cliPolicyHandler's active policy and write the policy file now. When the write fails, the active policy is left as it was and the failure is returned. The rules go after the ones already there, and the first matching rule wins, so every rule the user has, a reject included, keeps deciding what it decided before.
@param additions - The rules to append, keyed by effect, such as the result of missingPolicyRules.
Parameters:
| Name | Type | Default |
|---|---|---|
| additions | Policy |
Throws: std::read
(source)
flushPolicy
flushPolicy()Write any pending always-rule additions to the policy file now.
Force-write the cliPolicyHandler's in-memory policy to disk now. Use between user turns when you want the last decision of a session persisted. The handler's own auto-flush runs at the top of the next interrupt, so a decision recorded on the final interrupt of a turn won't survive a crash unless you call this.
No-op when there are no pending changes. Auto-approves its own std::write via with approve (you opted in by installing the handler).
(source)
cliPolicyHandler
cliPolicyHandler(
file: string,
fields: ScopedRuleFields = {},
policy: Policy | null = null,
interactive: boolean = true,
): anyCLI sugar for an interactive policy handler. Loads and saves the policy file, prompts the user on new interrupts, records "always" decisions, and returns approve/reject. Install on the outermost handle. Call exactly once per program — internal state is module-level.
@param file - Path to the on-disk policy file. @param fields - Per-effect override of which data fields the approve-always-here rule pins. Effects not listed use the scope their effect declaration carries; an empty list turns the option off for that effect. @param policy - Optional in-memory policy to use directly instead of loading file on startup. @param interactive - Whether a user is at a terminal. When false, an interrupt the policy does not decide is rejected with a reason (surfaced to the raise site, and so to an LLM whose tool raised it) instead of prompting.
Drop-in policy handler for interactive CLI agents. Returns a function ref you bind to a local variable and install on a handle block:
const home = env("HOME") with approve
const handler = cliPolicyHandler(
file: "${home}/.myapp/policy.json",
fields: { "std::read": [{ field: "dir", matchSubpaths: true }] },
)
handle {
// every interrupt raised here is filtered through the handler
} with handlerWhat the handler does:
- Loads the policy file on first invocation. Missing / malformed files are treated as
{}with a warning. - Consults the loaded policy via
checkPolicy. If a rule matches, approves or rejects without prompting. - Prompts the user when no rule applies, showing (a)/(r)/(aa)/(ap)/(rr). The (ap) option appears only when
fieldshas an entry for the interrupt's effect. - Records "always" decisions in memory and flushes them to disk at the top of the next interrupt. Use
flushPolicy()if you need the final decision of a session persisted before process exit.
Singleton state
Internal state (loaded policy, pending-save flag, options) is module-level. Calling cliPolicyHandler more than once in the same program silently overwrites the previous options. Only the last file / fields win. For multi-policy agents, fork the module or use the pure primitives directly.
Bind-to-variable requirement
The with clause only accepts an identifier (not a call expression), so you MUST bind the return value to a const before using it. This also bypasses the typechecker's handler-raises-interrupt rule, which only resolves direct functionRef names. The flip-flag-first pattern inside the handler provides runtime safety.
@param file - Path to the on-disk policy file. Created on first save. The containing directory must already exist. @param fields - Per-effect override of which data fields the (ap) rule pins. Effects not listed use the scope their effect declaration carries; an empty list turns (ap) off for that effect. @param policy - Optional in-memory policy to start from. When provided, the handler uses it directly and does NOT read file on startup (so there is no load-time std::read and no dependency on file existing). New "always" decisions still persist to file. Use for a per-run override that must not be seeded from — or written over — a saved policy on disk. Omit (null) for the normal load-from-file behavior.
Parameters:
| Name | Type | Default |
|---|---|---|
| file | string | |
| fields | ScopedRuleFields | {} |
| policy | Policy | null | null |
| interactive | boolean | true |
Returns: any
(source)