Skip to content

Design Decisions

This page documents the key design decisions in runok, their rationale, and the trade-offs involved.

The most fundamental principle in runok’s design: the way you write a rule is exactly how it will be parsed and matched. There is no separate schema language for flag types or argument positions — runok infers parsing behavior directly from the rule pattern itself.

When you write:

rules:
- deny: 'curl -X POST *'

runok infers from this pattern that:

  • -X is a flag that takes a value (because POST follows it)
  • * matches remaining positional arguments

When you write:

rules:
- deny: 'rm -rf *'

runok infers that:

  • -rf is a single combined flag token (not -r + -f)
  • It is boolean (no value follows)

There is no hidden schema or definition block that changes how these patterns behave. The pattern is the specification.

runok deliberately does not split combined short flags like -am, -rf, or -la. The reason:

  • Without knowledge of the actual command’s flag specification, -am could mean -a -m, -a with value m, or a single flag -am.
  • The FlagSchema is inferred from patterns alone — partial information that cannot safely drive splitting decisions.
  • Splitting could cause incorrect matches. For example, if -m is in the FlagSchema, -max would be incorrectly split into -m + ax.

This means deny: "git commit -m 'WIP*'" does not match git commit -am "WIP fix". To cover both, users write separate rules for -m and -am. This is intentional: the pattern says exactly what it matches.

The rule engine scans all patterns to build a FlagSchema automatically:

# From these rules:
rules:
- deny: 'curl -X|--request POST *'
- allow: 'curl -X|--request GET *'
- deny: 'rm -rf /'

runok infers:

  • -X and --request take a value (they are followed by POST/GET in patterns)
  • -rf is boolean (no value follows in the pattern)

This schema is then used to structurally parse input commands: curl -X POST https://example.com is parsed as command=curl, flags={-X: POST}, args=[https://example.com].

Unknown flags (not seen in any pattern) default to boolean, and unrecognized tokens are treated as positional arguments. This ensures runok can handle any command without errors, even those not covered by rules.

  • Pro: No surprises. Rules behave exactly as written, without distant definitions altering semantics.
  • Pro: No learning curve for flag schemas or type annotations.
  • Con: Users must write explicit patterns for each flag combination they want to cover.
  • Con: Cannot express “any flag that takes a value” generically.

When multiple rules match, the most restrictive action always wins: deny > ask > allow > default. This is inspired by AWS IAM policy evaluation.

This priority system ensures that security-critical deny rules cannot be overridden by broader allow rules. It enables a common and safe pattern: “allow broadly, deny specifically.” Rule order in the config file does not matter — a deny rule always takes precedence regardless of where it appears.

Without this guarantee, a user could accidentally allow dangerous commands by placing an allow rule after a deny rule.

The same priority applies to compound commands — a single denied sub-command causes the entire compound command to be rejected.

  • Pro: Secure by default. Deny rules are guaranteed to be enforced.
  • Pro: Rule order does not matter, reducing configuration errors.
  • Con: Cannot express “allow this specific case even though a deny rule matches” without restructuring rules.

See Priority Model for the full priority rules.

Commands like bash -c, sudo, and xargs wrap other commands. Without special handling, sudo rm -rf / would not match deny: "rm -rf /" because the command name is sudo, not rm.

runok solves this by recognizing wrapper patterns (defined in definitions.wrappers) and recursively evaluating the inner command captured by the <cmd> placeholder. Recursion is limited to a depth of 10 to prevent infinite loops.

This ensures that rules apply to what actually runs, not just the outer wrapper command.

A compound command applies each sub-command’s sandbox to that sub-command alone. The Claude Code hook’s updatedInput rewrite replaces each sub-command that needs a sandbox with RUNOK_HOOK_ORIGIN=<token> runok exec --sandbox <preset> -- '<sub-command>', where <sub-command> is that sub-command’s own text — including its KEY=VALUE env prefix and its own redirects — shell-quoted as a single argument. Only the operators joining sub-commands (|, &&, ||, ;) remain in the outer shell, so no sub-command is affected by its neighbours’ policies, and a redirect belonging to the sandboxed sub-command is carried inside its sandbox along with it. Only the hook can apply this: runok exec and runok check each receive the whole input as one process, so they always apply a single policy to the entire compound command instead — the merged policy below, or, when every sub-command resolves to the same preset, that preset directly.

runok falls back to one merged sandbox for the whole compound command — including inside the hook — when a sub-command that needs a sandbox meets any of these conditions: it has no byte range of its own in the input to replace (the case after a parse failure); its range is nested inside another sub-command’s range ($(...), <(...), but not a plain subshell ( ... )); it is a shell builtin that changes shell state (cd, export, source, eval, and similar); or it calls a shell function defined in the same input — replacing either would run it in a child process, which neither carries the state change back to the shell running the rest of the compound command nor has the function definition. The fallback merges the policies by taking the strictest combination: intersection for writable paths, union for denied paths, and AND for network access.

The rationale for the fallback direction: a compound command like npm install && curl https://example.com should not gain filesystem access from the npm install policy when curl has a more restrictive sandbox. When one sandbox has to cover both, no sub-command may weaken the sandbox of another.

For the full merge table and contradiction handling, see Compound Commands: Sandbox policy aggregation.

runok’s configuration system is designed around three tiers of users, inspired by Vim’s approach to extensibility:

Use presets via extends and write simple allow/deny/ask rules using familiar command syntax. No schema definitions needed.

extends:
- 'github:example-org/runok-preset-standard@v1'
rules:
- allow: 'git *'
- deny: 'rm -rf /'

Define custom wrapper commands, use when clauses with CEL expressions, and configure sandbox presets for fine-grained control.

rules:
- ask: 'terraform apply'
when: "env.TF_WORKSPACE == 'production'"

Build custom validators using the JSON-RPC 2.0 extension protocol, and publish presets for the community.

This layered approach ensures that the simple case stays simple, while advanced features are available when needed.