Programmable rules (WebAssembly)

Auto-rules let the daemon answer recurring asks without prompting. Most rules are declarative: a match clause (wrap × argv pattern × ancestor × cwd) plus a fixed approve/deny, created from the Rules view in secreq view. When a policy doesn't fit a match clause ("approve npm publish, but only from my canonical checkouts, and never when an AI agent is driving"), you can write the rule as code: a single function, compiled to a sandboxed WebAssembly module, evaluated by the daemon before the consent prompt.

import { RuleCtx, Decision, approve, pass, deny } from 'secreq-rule';

export function decide(ctx: RuleCtx): Decision {
  if (ctx.wrap == 'gh' && ctx.joinedArgv.startsWith('gh repo delete')) {
    return deny('repo deletes are never auto-approved');
  }
  return pass(); // no opinion; fall through to the prompt
}

When to reach for a wasm rule

Prefer a declarative rule whenever one can express the policy. It's auditable at a glance in rules show, editable in the UI, and can't have bugs. Reach for a wasm rule when the decision needs logic a match clause can't express: combinations ("this argv unless that caller"), negations, computed conditions on several ctx fields at once, or a reason string built from the ask itself. Declarative and wasm rules evaluate together in one pass and compete under the same precedence (see below), so you can freely mix them, including keeping protective declarative denies alongside a programmable approve.

The security model

A rule module is untrusted code that helps decide whether a secret is released. The daemon constrains what it can do structurally, so the limits hold whatever the module contains.

ConstraintHow it is enforced
No I/O of any kindThe only import a module may declare is AssemblyScript's env.abort. Anything else, WASI included, is refused at registration with an error naming the import.
No secret valuesThe ctx carries names: env-var names, or ssh:<key_id> for a signing. No value enters the sandbox.
Bounded timeA fixed fuel budget of 10⁸ instructions per call. An infinite loop stops in well under a second.
Bounded memory64 MiB of guest memory, and 64 KiB for the decision it returns.
No state between asksEvery evaluation instantiates the module fresh.
Only the bytes you registeredRegistration records the module's SHA-256 and re-verifies it on every rules load. A file that changed is refused, and rules list, rules show and the manager say so.
Only the wraps you scoped it toA rule-level wrap allowlist is checked before evaluation. An out-of-scope wasm module is not instantiated.
Only the subjects you trained it onA wasm rule is consulted when its trained subjects overlap the ask, and an approve blesses only that intersection. Several rules may jointly cover a multi-subject ask.

Two behaviors matter when you write one.

Decisions rank deny, then prompt, then approve. A deny from any enabled rule denies the ask, whatever else approved. A prompt() from any enabled rule sends it to the consent window, and no approve applies. Among approves, a wasm rule that returned a decision counts as maximally specific (it made a programmatic decision about this exact ask), and ties break on the lexically smallest rule id.

A failure never becomes an approve. A module that traps, aborts, exhausts its fuel, exceeds a cap, or returns malformed output has no opinion to give, and a rule that cannot be consulted may have been the one that would have denied. So a failure suppresses any competing approve and sends the ask to the consent prompt, which the daemon logs. The same applies to a module refused at load time for a SHA-256 mismatch: tampering with one file must not both disable a guard and leave the thing it guarded auto-approved.

The residual risk is the last row of that table: a hostile module can approve asks for the secrets you trained it on, and nothing else.

What your rule sees and returns

Your rule is one exported function:

export function decide(ctx: RuleCtx): Decision;

RuleCtx (from the secreq-rule package) mirrors the daemon's evaluation context:

FieldTypeMeaning
wrapstringThe wrap being asked for (e.g. gh, npm).
joinedArgvstringJoined argv of the wrapped command (e.g. gh api --get /repos/x).
callersCaller[]Caller chain, nearest-first. Each entry has name (comm), command (joined argv), and exe (absolute path, '' when unknown). name and command are chosen by the process; exe is not. Gate on exe.
cwdstringWorking directory of the requesting process.
secretsstring[]What the ask would release, by name: env-var names for a wrap run, or the single identity ssh:<key_id> for an SSH sign. Names only, never values.

The decision is built with four constructors:

  • approve(): auto-approve the ask without prompting.
  • pass(): no opinion; this rule does not match. Other rules and the interactive prompt still apply.
  • deny(reason): auto-deny. reason is shown to the user (the wrap client prints it to stderr, the consent window shows a toast).
  • prompt(reason): require the consent prompt. No rule may auto-approve this ask.

prompt() covers the case pass() cannot. Passing means "no opinion", so another rule's approve still releases the ask silently. That is right when your rule does not recognise the request, and wrong when it recognises it as one a human should see. Reach for prompt() when a request is not suspicious enough to refuse but too consequential to release unattended:

export function decide(ctx: RuleCtx): Decision {
  if (ctx.joinedArgv.startsWith('npm publish')) {
    return prompt('publishing to a registry');
  }
  return pass();
}

Write reason as the answer to "why am I being asked?".

On the wire this is JSON with snake_case field names (joined_argv, secrets, …) and decisions encoded as "approve", "pass", {"deny": "reason"} or {"prompt": "reason"}. The SDK's build tool generates all of that glue; you only write decide. The exact ABI is in packages/secreq-rule/README.md and packages/secreq/src/wasm_rules.rs if you want to author modules in another language.

Write a rule

Scaffold from the CLI (one command)

secreq rules new-wasm ~/code/my-rule

That writes a package you can build without editing anything else: a package.json with the secreq-rule devDependency resolved, the assembly/rule.ts stub exporting decide(ctx), an as-pect spec beside it, and the test-runner config. The command prints the next three steps — npm install, npm run build, and the rules add-wasm line that registers the result.

Two options change what you get:

  • --from <example> seeds assembly/ from a worked example under packages/secreq-rule/examples/ instead of the stub, so --from npm-publish-guard starts you on the rule the rest of this guide walks through.
  • --sdk <path> points the generated dependency at a packages/secreq-rule of your choosing. The command finds one by walking up from the secreq binary and from your working directory, which covers anyone working out of a checkout; without one it writes the registry package, which does not carry the SDK yet, and says so.

Scaffold from the rule editor (one click)

The Rules view in secreq view writes the same project without a terminal. The "Write a programmatic rule" card at the top scaffolds it under $SECREQ_HOME/rule-drafts/<slug>/ and offers an "Open in editor" split-button:

When a rule needs more than pattern matching, secreq scaffolds a programmable one and hands it to your editor.

The primary action opens the scaffold in your preferred editor; the caret picks from the editors detected on your machine, and your choice is remembered as editor in config.toml so the button defaults to it next time.

Pick from the editors secreq detected. Your choice becomes the default for next time.

Land in your editor, edit decide, then compile and register (below).

What you are editing

Rules are written in AssemblyScript: TypeScript syntax, compiled ahead-of-time to a tiny wasm module with no embedded JS engine. A rule package is four things — assembly/rule.ts, its as-pect spec, the runner config, and a package.json pulling assemblyscript, @as-pect/cli and secreq-rule. Both scaffolds above write all of it.

You write assembly/rule.ts, exporting decide(ctx) and, normally, subjects(). The worked example at packages/secreq-rule/examples/npm-publish-guard/ is a complete, runnable package for this policy: approve npm publish from checkouts under /home/me/oss/, deny it when an agent session appears anywhere in the caller chain, pass on everything else:

import { RuleCtx, Decision, approve, pass, deny } from 'secreq-rule';

const PUBLISH_ROOT = '/home/me/oss/';

export function subjects(): string[] {
  return ['NPM_TOKEN'];
}

export function decide(ctx: RuleCtx): Decision {
  if (ctx.wrap != 'npm') return pass();
  const argv = ctx.joinedArgv;
  if (argv != 'npm publish' && !argv.startsWith('npm publish ')) {
    return pass();
  }
  for (let i = 0; i < ctx.callers.length; i++) {
    const c = ctx.callers[i];
    if (c.name.toLowerCase().includes('claude') || c.command.toLowerCase().includes('claude')) {
      return deny(
        'npm publish from an AI-agent session is never auto-approved ' + '(caller: ' + c.name + ')',
      );
    }
  }
  if (ctx.cwd == '/home/me/oss' || ctx.cwd.startsWith(PUBLISH_ROOT)) {
    return approve();
  }
  return pass();
}

One AssemblyScript caveat: it is a subset of TypeScript. Stick to strings, arrays, and plain loops (as above) and you won't notice; regexes, closures over this, and most of the JavaScript standard library are not available.

Test it

Because a rule is just a function of ctx → decision, it unit-tests cleanly. The SDK's secreq-rule/testing/assembly entry point supplies context/caller builders and decision assertions without adding a test framework dependency to the deployed rule. The example uses as-pect; the spec is compiled to wasm and exercises decide directly:

// assembly/__tests__/rule.spec.ts
import {
  assertDecision,
  caller,
  expectApprove,
  expectDeny,
  expectPass,
  ruleCtx,
} from 'secreq-rule/testing/assembly';
import { decide } from '../rule';

describe('npm-publish-guard', () => {
  it('covers the decision table', () => {
    const shell = [caller('zsh', '-zsh', '/bin/zsh')];
    assertDecision(
      decide(ruleCtx('npm', 'npm publish', '/home/me/oss/lib', shell, ['NPM_TOKEN'])),
      expectApprove(),
    );
    assertDecision(
      decide(ruleCtx('npm', 'npm publish', '/tmp/clone', shell, ['NPM_TOKEN'])),
      expectPass(),
    );
    assertDecision(
      decide(
        ruleCtx(
          'npm',
          'npm publish',
          '/home/me/oss/lib',
          [caller('Claude', 'claude')],
          ['NPM_TOKEN'],
        ),
      ),
      expectDeny('npm publish from an AI-agent session is never auto-approved (caller: Claude)'),
    );
  });
});

Run with npm test, which both scaffolds wire to asp.

The second testing layer drives the compiled artifact through the real snake_case JSON and packed pointer/length ABI:

const { runCases } = require('secreq-rule/testing');

runCases('./rule.wasm', [
  {
    name: 'safe read',
    context: { wrap: 'gh', joinedArgv: 'gh api --get /user' },
    expected: 'approve',
  },
  { name: 'no opinion', context: { wrap: 'npm', joinedArgv: 'npm test' }, expected: 'pass' },
  {
    name: 'human required',
    context: { wrap: 'npm', joinedArgv: 'npm publish' },
    expected: { prompt: 'publishing' },
  },
]);

The Node runner vets the abort-only import names and kinds, uses a fresh instance per case, checks memory before and after calls, enforces the 64 KiB decision cap, and decodes Approve, Pass, Prompt, and Deny. Node's built-in WebAssembly engine cannot meter fuel or constrain an exported memory while a guest call is running, and its reflection API does not expose import function signatures. Those protections remain intentionally host-only; inspect sandboxPosture for the machine-readable differences.

secreq never runs your tests. Testing happens entirely in your package, before you compile; the daemon only ever loads the compiled .wasm module. A rule with no tests will register just as happily; the test suite is your safety net, not secreq's.

Compile it

npx secreq-rule-build assembly/rule.ts -o rule.wasm

secreq-rule-build generates the ABI entry around your decide and optional subjects, compiles with AssemblyScript's stub runtime (no GC), and produces a core wasm module (typically 10–20 KB) whose only import is env.abort. If you hand-implement the ABI instead of exporting decide(ctx), compile with secreq-rule-build --raw.

Register it

Registration goes through the daemon, which vets the module in the sandbox before anything is stored. A module that imports the wrong things, misses an ABI export, or fails instantiation registers nothing:

secreq rules add-wasm rule.wasm --name "npm publish guard" --wrap npm
module declares: NPM_TOKEN
◆  Register with these subjects?
registered wasm rule 'npm publish guard' (3f8a21c09b4d5e6f70a1b2c3)
module stored:  rules/3f8a21c09b4d5e6f70a1b2c3.wasm
sha256:         9c0e0f6c…
wrap scope:     npm
trained on:     NPM_TOKEN
  • --wrap NAME (repeatable) sets the rule's consultation scope. The rule is skipped for every other wrap, before a wasm module is instantiated. Omit it to retain the unscoped behavior. A name may be run, read, a key under [wraps], or ssh:<name> backed by [ssh.<name>]; anything else is an error at registration.
  • subjects(): string[] declares the subjects the module understands. This is a request, not a grant: registration prints it and the operator confirms the effective trained-secrets snapshot. Use --accept-declared for a headless registration.
  • --secret NAME (repeatable) narrows that request. Registration stores the intersection and hard-errors when the two sets are disjoint. A module with no subjects() export can still be registered with explicit --secret flags.
  • Wrap and subject scopes are independent AND gates: both must overlap the ask. An approval blesses only requested subjects in the trained snapshot, and several rules may jointly cover a multi-subject ask. Valid subjects include wrap env keys, ssh:<key_id>, and wrap:<name> for gate-only wraps.
  • --name labels the rule in the UI and audit log (defaults to the module's file name, minus the .wasm extension).
  • The module is copied into the canonical store (~/.secreq/rules/<rule-id>.wasm) and pinned by the sha256 of the vetted bytes. Your original file is no longer consulted; edits to it do nothing until you register a new build.

The persisted wraps field is a consultation gate shared by declarative and wasm rules. It does not replace a declarative rule's match.wrap: wraps decides whether secreq consults the rule at all, while match.wrap remains a clause evaluated after consultation.

Every declared and trained subject is validated against config.toml. Valid subjects are wrap env keys such as NPM_TOKEN, SSH identities such as ssh:deploy, and gate-only wraps such as wrap:op. A name under [secrets] is a value definition, not a matching subject: asks carry the env key that binds it. An empty declaration is always an error. The explicit --all-secrets escape hatch remains available for intentionally unscoped rules.

Without either a module declaration or explicit --secret, registration is refused because an approve() could release subjects it was never trained on. If that is intentional (a deny-only policy is the honest case), opt in with --all-secrets; the optional --wrap gate still limits where it is consulted.

$ secreq rules add-wasm rule.wasm
module declares: NPM_TOKEN◆ Register with these subjects?│ ○ Yes / ● No└
module declares: NPM_TOKEN◇ Register with these subjects?│ No│wasm rule was not registered
A module can request its own subjects, but it cannot grant them. Registration prints the validated set and waits for the operator; use --accept-declared only when that confirmation has already happened out of band.

Inspect, pause, delete

The standard rule verbs apply:

secreq rules                 # list; wasm rules show `wasm` in the decide column
secreq rules show <id|name>  # module path, pinned sha256, integrity status
secreq rules disable <id>    # pause without deleting; enable to resume
secreq rules rm <id>         # delete (also removes the stored module file)

Validate and replay the ruleset

rules stats loads the current config, rules, and wasm modules through the production loaders, then streams historical asks through the same evaluator used by the daemon. It never resolves a secret, contacts a provider, executes a wrap, reads remembered approvals, or mutates daemon state.

secreq rules stats
secreq rules stats --since 2026-07-01 --wrap gh --top 30
secreq rules stats --audit ./fixtures/audit.log --json
secreq rules stats --verify

The report includes current approve/deny/prompt percentages, per-rule attribution, prompt shapes, costly historical rows now automated, recorded decisions for rows that still prompt, scope findings, and refused modules or patterns. --verify compares eligible historical approve+auto and deny+auto rows with their current replay. It exits non-zero for drift, refused/tampered rules, invalid live scope, malformed audit records, or runtime wasm failures. Ordinary uncovered prompts remain informational. Deleted-rule, disabled-rule, pre-creation, and scoped-agent history is classified separately and is not an evaluator failure. Older SSH rows that predate rule attribution are reported separately from malformed rows with a missing rule id. store and scoped-agent records never entered the rules evaluator, so they are not in the replay outcome denominator.

A healthy wasm rule shows:

wrap scope:     npm
decide:         wasm (module decides per ask)
wasm module:    rules/3f8a21c09b4d5e6f70a1b2c3.wasm
wasm sha256:    9c0e0f6c…
declared:       NPM_TOKEN
wasm status:    ok (module loaded and hash-verified)

If the stored module has been deleted, replaced, or corrupted, the rule is refused at load: rules list marks the row with [REFUSED: sha256 mismatch] (or module missing). If a hand-edited update changes the declaration without updating its confirmed snapshot, the marker is [REFUSED: declaration changed]. rules show prints the full reason.

A wasm rule is pinned to the hash of the module you registered. If the file changes, the daemon refuses that rule at its next load and badges it here: it stays in your ruleset but can never fire. Only that rule is refused — the one above it keeps working, so a tampered module cannot switch off the rest of your policy.

A refused rule stays in your ruleset but can never fire. Only that rule is refused. Your other rules keep working, protective denies included, because a tampered module must not be able to switch off the rest of your policy.

Update a rule's module

There is no in-place module update. To ship a new build:

secreq rules add-wasm rule.wasm --name "npm publish guard v2" --wrap npm --accept-declared
secreq rules rm "npm publish guard"       # then retire the old rule

(Registering first means the policy is never gone in between.) Alternatively, stop the daemon (secreq daemon stop), hand-edit ~/.secreq/auto-rules.toml (replace the module file and update both the rule's sha256 and confirmed declared_secrets snapshot), and let the next ask respawn the daemon. A declaration mismatch is refused until it is re-confirmed. The daemon owns rule writes while it runs; hand-edits belong to a stopped daemon.

Operational notes

  • Integrity status reflects the daemon's last rules load. The daemon verifies each module's sha256 when it loads the rules file (at startup, on any rules-file change, and on every rule mutation) and evaluates the verified, in-memory module from then on. A file swapped on disk after that load is therefore never executed: the running daemon keeps evaluating the bytes it verified, and the next load re-checks the hash and refuses the mismatch. Tampering fails closed: it can silence the one rule, loudly, but can't inject code into it.
  • Runtime failures are logged, not hidden. A trap, abort, fuel exhaustion, or malformed decision shows up in the daemon log (secreq daemon log-path) as WARN: wasm rule … errored evaluating wrap …; treating the rule as not matching, falling through to the prompt. If a rule that used to fire silently stopped, look there first.
  • Auto-decisions are audited like any rule hit. Fires appear in the audit log as approve+auto / deny+auto with the rule's id, so you can always trace which module decided.
  • Rule size is bounded. Registration refuses modules over 16 MiB; real rules compile to a few KB.