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() {
// Bind to a variable — the `with` clause only accepts an identifier.
const handler = cliPolicyHandler(
file: "${env("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 inmatchis - present in the interrupt's
dataand 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.
/**
* 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.
*/
export type PolicyRule = {
match?: Record<InterruptDataKey, InterruptDataVal>;
action: "approve" | "reject" | "propagate"
}(source)
Policy
- A policy: ordered rules per interrupt effect.
checkPolicywalks - the array for
intr.effectin 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 in
ScopedRuleFieldsfor 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
ScopedRuleFieldsentry — 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.
- Pass
truefor directory-like fields (so approving/tmp/x - also 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 configuration consumed by
buildScopedMatchand the cliPolicyHandler. Maps each interrupt effect to the fields its- "approve-always-here" rule should pin. Effects not present in this
- map don't offer the (ap) prompt option. 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 },- ],
- }
/**
* Per-effect configuration consumed by `buildScopedMatch` and the
* `cliPolicyHandler`. Maps each interrupt effect to the fields its
* "approve-always-here" rule should pin. Effects not present in this
* map don't offer the (ap) prompt option. 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;
error?: string
}(source)
Functions
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.
@param policy - Ordered rules keyed by interrupt effect; each rule has optional glob-pattern match fields and an action. @param interrupt - The interrupt to evaluate.
- Evaluate a policy against a single interrupt. Returns the result
- of
approve(),reject(), orpropagate()corresponding to the - first matching rule for
interrupt.effect. If no rule matches - (no rules for the effect, or every rule's
matchfailed), returns propagate()so the next handler in the chain runs.- 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
Policyis structurally valid (every entry is an - array of
PolicyRulewith a recognisedaction, everymatch - 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;
writePolicyFilecalls this internally before writing.
Parameters:
| Name | Type | Default |
|---|---|---|
| policy | Record<string, any> |
Returns: Result<void>
(source)
buildScopedMatch
buildScopedMatch(intr: Record<string, any>, fields: ScopedRuleFields): Record<string, string>Build a match object for an interrupt, pinned to the configured fields. Returns {} when the effect has no configured fields.
@param intr - The interrupt whose data fields to pin. @param fields - Per-effect config naming which data fields to pin.
- Build the
matchmap for a scoped rule by reading the configured - fields out of
intr.data. The returned object is shaped to plug - straight into a
PolicyRule.match: - ts
- const match = buildScopedMatch(intr, fields)
- const rule: PolicyRule =
- For each
ScopedFieldconfigured forintr.effect: - The field's value is read from
intr.data.
- The field's value is read from
- If
matchSubpaths: true, the value is wrapped as
- If
"{value,value/**}"so the resulting glob matches both the- exact value and any subpath under it.
- If
matchSubpaths: false, the value is used as-is (literal
- If
- match).
- Fields that are absent from
intr.data(null/undefined) - are skipped silently. Effects not present in
fieldsreturn{}. - Most callers should use
recordScopedRuleinstead, which calls - this internally.
buildScopedMatchis 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) foreffectappended. 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:
- ts
- let p = recordRule({}, "std::read", "reject")
- p = recordRule(p, "std::read", "approve") // never reached
- If 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 configured fields, so it approves only future interrupts matching this one's field values.
@param policy - The policy to extend (not mutated). @param intr - The interrupt whose field values to pin. @param fields - Per-effect config naming which data fields to pin.
- Return a new policy with a scoped approve rule prepended for
intr.effect. The rule'smatchis built bybuildScopedMatch,- 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
{} - (an empty policy) on any failure: a missing file, unreadable
- permissions, malformed JSON, or a schema-validation error. It also
- prints a warning so the user knows their saved decisions did not
- carry over.
- 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>
(source)
setPolicy
setPolicy(path: string, policy: Policy)- 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
parsePolicyFileor constructing one by hand.
- returned by
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
Policyand write it as JSON topath. Throws (returns Failure) if validation fails. Invalid policies are never- persisted.
allowedPathsis 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
["${env("HOME")}/.myapp"].
Parameters:
| Name | Type | Default |
|---|---|---|
| path | string | |
| policy | Policy | |
| allowedPaths | string[] | [] |
(source)
flushPolicy
flushPolicy()- 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::writeviawith approve(you opted in by installing the- handler).
(source)
cliPolicyHandler
cliPolicyHandler(file: string, fields: ScopedRuleFields, policy: Policy | null): 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 config controlling the "approve-always-here" prompt option. @param policy - Optional in-memory policy to use directly instead of loading file on startup.
- Drop-in policy handler for interactive CLI agents. Returns a
- function ref you bind to a local variable and install on a
handle - block:
- ts
- const handler = cliPolicyHandler(
- file: "${env("HOME")}/.myapp/policy.json",
- fields: { "std::read": [{ field: "dir", matchSubpaths: true }] },
- )
- handle {
- // every interrupt raised here is filtered through the handler
- } with handler
- What 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
- Consults the loaded policy via
- 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
cliPolicyHandlermore than once in the - same program silently overwrites the previous options. Only the
- last
file/fieldswin. For multi-policy agents, fork the - module or use the pure primitives directly.
Bind-to-variable requirement
- The
withclause 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 config controlling the (ap) prompt
- option. Effects not present here don't offer (ap).
- @param policy - Optional in-memory policy to start from. When
- provided, the handler uses it directly and does NOT read
fileon - startup (so there is no load-time
std::readand no dependency on fileexisting). New "always" decisions still persist tofile.- 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-
filebehavior.
Parameters:
| Name | Type | Default |
|---|---|---|
| file | string | |
| fields | ScopedRuleFields | |
| policy | Policy | null | null |
Returns: any
(source)