@wyattjoh/demur
A proof-of-concept destructive-command guard for coding agents.
Package details
Install @wyattjoh/demur from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:@wyattjoh/demur- Package
@wyattjoh/demur- Version
0.7.0- Published
- Sep 21, 2026
- Downloads
- 1,297/mo · 1,297/wk
- Author
- wyattjoh
- License
- MIT
- Types
- extension
- Size
- 229.9 KB
- Dependencies
- 5 dependencies · 1 peer
Pi manifest JSON
{
"extensions": [
"./extensions"
]
}Security note
Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.
README
demur
A proof-of-concept harmful-command guard for coding agents. demur sends a shell
command and limited execution context to TypeSafe System One, then turns six
model judgments into an allow, ask, or deny decision.
[!WARNING] demur is experimental and is not a security boundary. A model can misclassify, behave nondeterministically, or be influenced by attacker-controlled command text. Use it as an additional confirmation layer, not as your only protection against harmful commands.
How it works
For each agent-initiated Bash tool call, demur:
- Collects the command, working directory, host name, and bounded Git facts.
- Requests six judgments in one TypeSafe System One call:
- whether the command executes a destructive operation;
- whether it exposes secrets, credentials, or personal data;
- whether it weakens a security boundary or grants elevated access;
- whether its effects are recoverable;
- whether it targets shared infrastructure; and
- its expected blast radius.
- Applies deterministic thresholds from
src/policy.ts. - Escalates an otherwise allowed destructive command to
askwhen variables, globs, or command substitutions make its real target statically uncertain. - Maps the decision into Pi or Claude Code's permission protocol.
Environment access, Git queries, and network calls are Effect services. The
policy and shell analysis remain pure functions, while src/guard.ts exposes a
Promise boundary for host integrations.
Data disclosure
Every judged command makes a request to TypeSafe. demur sends:
- the complete command string;
- the working directory;
- the requesting host (
piorclaude-code); - the repository root and current branch, when inside Git; and
- counts of modified, untracked, and unpushed changes plus whether an upstream branch exists.
demur does not send file contents, environment-variable values, remote URLs, or its static-analysis result. Command strings and paths can still contain secrets or sensitive names. Review TypeSafe's service terms and data-handling policy before enabling demur in a sensitive repository. Do not run secrets directly in shell arguments when the guard is active.
Pi training capture stores the complete command, exact TypeSafe state, local static-analysis result, model/question/policy versions, exact policy thresholds, operating mode, full verdict and judgments, and resulting host action locally. These records can therefore contain secrets or sensitive names from shell arguments, paths, Git context, and deterministic command analysis. Training capture is off by default and is unavailable while the Pi integration is disabled.
Requirements
- Bun 1.4 or newer
- A TypeSafe System One API key from https://console.typesafe.ai/settings/keys
- Network access to TypeSafe for every judged command
- Pi 0.85.x and/or Claude Code
Install
Install demur's command-line tools and save your TypeSafe API key in the operating system credential store:
bun add --global @wyattjoh/demur
demur auth login
demur auth status
Bun.secrets stores the credential in macOS Keychain, Linux Secret Service, or
Windows Credential Manager. The operating system may request access when the
credential is first used or while its credential store is locked.
For automation or a one-off override, set TYPESAFE_API_KEY before launching
the host agent. An environment value takes precedence over the stored key:
export TYPESAFE_API_KEY="..."
Never commit the key. .env.schema documents the accepted
environment configuration, and local environment files are ignored by Git.
Pi
Install the npm package:
pi install npm:@wyattjoh/demur
Pin a specific release when reproducibility matters:
pi install npm:@wyattjoh/demur@0.7.0
Launch Pi normally after configuring the credential:
pi
The extension intercepts bash tool calls. Because Pi runs extensions under
Node.js while demur uses Bun.secrets, the extension launches a package-local
Bun worker for each judgment. The API key remains inside that worker; only the
command request and resulting verdict cross its local stdio pipes. When training
capture is enabled, the exact model state and local policy evidence return with
the verdict for private local persistence. ask opens
an interactive confirmation dialog; without an interactive UI, demur blocks the
command.
Use /demur to open the extension menu. Its global operating mode is:
enforce(default) appliesallow,ask, anddenydecisions normally.passivestill judges every Bash call and prints the diagnostic, but never prompts or blocks because of the verdict.disabledbypasses the worker and allows Bash calls without judgment.
Training capture can be enabled independently in enforce or passive mode.
It is automatically turned off when the integration is disabled. Pi's bottom
status bar always shows the current mode and whether training is active so every
bypass or local recording state remains visible.
The menu also controls what enforce mode does when demur cannot obtain a trustworthy judgment because of a missing credential, timeout, API error, malformed worker response, or unexpected guard failure:
block(default) fails closed.askrequests interactive confirmation and blocks when no UI is available.allowfails open without confirmation.
Settings are stored globally at $XDG_CONFIG_HOME/demur/config.json, or
~/.config/demur/config.json when XDG_CONFIG_HOME is unset, and apply to
future Pi sessions. Set DEMUR_CONFIG_HOME to use an isolated demur directory;
config.json is read and written directly beneath it. This demur-specific
override takes precedence over the XDG and home-directory locations. While
demur is enforcing, the failure policy never changes a completed deny policy
judgment; those commands remain blocked. Passive mode reports failures but does
not apply the failure policy because it never blocks.
After each run, Pi's interactive UI prints the decision, submitted input-token count, the run's estimated input cost, the accumulated global estimate, and the wall-clock evaluation time in human-readable units. The estimate uses TypeSafe's published Jev price of $0.042 per million input tokens; it is informational rather than an authoritative billing amount. Failure and bypass paths that do not call Jev report that cost is unavailable.
The accumulated estimate is stored at $XDG_STATE_HOME/demur/usage.json, or
~/.local/state/demur/usage.json when XDG_STATE_HOME is unset. A lock
serializes concurrent Pi instances, and each update is written to a temporary
file before an atomic rename so the total cannot be partially written or lose a
concurrent increment. Cost-accounting failures do not change demur's guard
decision; the status reports accumulated unavailable instead.
Training evaluations are appended as private, versioned JSONL records at
$XDG_STATE_HOME/demur/training.jsonl, with the same home-directory fallback.
Set DEMUR_STATE_HOME to place usage.json, training.jsonl, and
training-reviews.jsonl directly beneath an isolated directory instead. This
demur-specific override takes precedence over the XDG and home-directory
locations. Training records are retained until the user removes them. Human
reviews are appended separately; accepted and corrected records are linked by a
stable record ID, leaving the original evidence unchanged. Corrected reviews
also carry a machine-readable reason so recurring question, context, policy, and
service failures can be measured without mining free-form notes. Version-one
records and reviews remain readable. A training-write failure is reported but
never changes whether the command runs.
Pi packages execute with the user's full system permissions. Review this repository before installing it.
For development, clone the repository and install it by local path:
git clone https://github.com/wyattjoh/demur.git
cd demur
bun install --frozen-lockfile
pi install "$PWD"
Claude Code
The global package installation above also provides the Claude Code hook.
Register its executable in ~/.claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "demur-claude-hook"
}
]
}
]
}
}
Launch Claude Code normally after configuring the credential. The adapter emits
Claude Code's hookSpecificOutput.permissionDecision response.
CLI
Launch the central interface, manage the stored credential, or judge a single command without a host integration:
demur
demur auth login
demur auth status
demur auth logout
demur training review
demur training list --status=unreviewed --cwd=/workspace
demur training list --status=deny --json
demur training evaluate --json
demur training review <record-id> --decision=deny --reason=recoverability --note="would destroy work" --json
demur judge "git reset --hard HEAD~3"
Bare demur opens the central OpenTUI interface when stdin and stdout are
interactive. demur training review remains an explicit alias for the same
interface. [ opens Reviews and ] opens Settings. You can also navigate Up
to the top-level section strip, use Left and Right to switch sections, and press
Down or Enter to open one. Settings exposes every option from Pi's /demur
menu—operating mode, training capture,
and failure policy—and atomically saves each change to the same global
configuration file. Use Up and Down to select a setting, Left and Right to
change it in either direction, or Enter/Space to choose its next value.
Disabling demur also turns training capture off, and training remains unavailable
until an active mode is selected.
The Reviews header shows the persisted global estimated cost, and each queue row
shows the model decision in a muted semantic color. It starts in an all view;
Tab and Shift-Tab rotate between all, not reviewed, approved (allow), ask,
and deny views. The queue is focused initially: arrow keys navigate it, Up
from its first result focuses a fuzzy working-directory filter, and another Up
focuses the tab strip. Left and Right select adjacent focused tabs, while Down
returns through the filter to the queue. Right from the queue focuses the
scrollable detail pane.
The detail pane supports arrows or j/k; Left returns to the queue. Page Up
and Page Down page within the focused pane, and queue navigation stops at its
first and last entries. Mouse clicks select tabs, records, the filter, or either
pane; the wheel scrolls the queue and detail pane. Enter selects the original
decision, 1/2/3 choose allow/ask/deny, s
leaves a record for a later pass, and q or Escape stops. A correction first
asks for one structured reason, then accepts an optional note. Previously
reviewed records remain available, and changing an answer appends a review
revision while preserving its visible history. The TUI remains open when a view is empty and
polls training state for newly captured or externally reviewed evaluations.
Flag-based training commands provide the same review operations without the
TUI. demur training list accepts --status and fuzzy --cwd filters. Status
is derived from the latest human review, so allow, ask, and deny select
reviewed records while unreviewed selects records without a review. Record a
new append-only review revision by passing a record ID and a required
--decision=<allow|ask|deny>. Corrections also require --reason with one of
inert-or-read-only, sensitive-data, security-boundary, recoverability,
shared-infrastructure, blast-radius, static-uncertainty, missing-context,
or service-failure; --note remains optional. Add --json to list, evaluate,
or review to emit one versioned JSON document; JSON failures are written to
stdout with a nonzero exit code. Interactive demur training review still opens
the TUI, but without a terminal it requires an explicit list or record-ID review
operation. Reviews remain separate from the original evidence so they can later
be curated into independently licensed eval fixtures.
From a development checkout, bun run judge "<command>" remains available.
Configuration
| Variable | Default | Purpose |
|---|---|---|
TYPESAFE_API_KEY |
stored credential | Optional TypeSafe API credential override. Missing keys fail closed. |
DEMUR_TIMEOUT_MS |
4000 |
Per-attempt model timeout in milliseconds. |
DEMUR_CONFIG_HOME |
XDG/home config | Demur-specific directory containing config.json. |
DEMUR_STATE_HOME |
XDG/home state | Demur-specific directory containing usage and training state. |
DEMUR_DISABLE |
unset | Emergency bypass. 1 or true allows every command. |
Failure posture
demur's core guard fails closed. A missing key, credential-store failure,
timeout, API failure, malformed response, or unexpected guard error returns
deny with a reason that identifies the guard failure rather than presenting it
as a policy judgment. The Claude Code adapter and CLI preserve that verdict.
The Pi extension defaults to enforce mode with the same fail-closed behavior,
but its explicit /demur menu can globally select enforce, passive, or disabled
mode. The failure-policy override applies only in enforce mode when no
trustworthy judgment was produced; it cannot loosen a completed policy denial.
Passive mode always continues after reporting the underlying verdict, while the
bottom status bar keeps the active mode and training state visible.
DEMUR_DISABLE=1 remains the cross-host emergency bypass. It disables judgment
and protection entirely and should remain unset during normal use.
Known limitations
Bun.secretsis experimental, and credential-store availability and prompts vary by operating system configuration.- Model decisions are probabilistic and may vary between identical requests.
- The hard-coded
jev-latestmodel alias may change without a demur release. - Attacker-controlled command text can influence the model.
- Shell expansion, obfuscation, aliases, wrappers, and runtime environment can make a command behave differently from its text.
- Network outages block commands by default; Pi can override that failure
handling from the
/demurmenu. - Every decision adds remote-call latency and may incur provider cost.
- The integrations guard agent-issued Bash tool calls only. They do not guard user shells, other process-launching tools, or commands run outside the host.
- Other Pi extensions loaded after demur can mutate a tool call after it has been judged.
Use operating-system permissions, backups, repository protections, sandboxing, and deterministic policy controls alongside demur.
Project layout
src/questions.ts— the six model judgmentssrc/policy.ts— thresholds andallow/ask/denycompositionsrc/analyze.ts— deterministic shell analysis for the static uncertainty gatesrc/state.ts— bounded environment and Git context collectionsrc/key.ts— environment precedence and operating-system credential storagesrc/guard.internal.ts— Effect-native orchestration and fail-closed recoverysrc/guard.ts— managed runtime and Promise boundarysrc/training-review-model.ts— historical review status and cwd filteringsrc/training-evaluation.ts— offline correction metrics and threshold comparisonssrc/training-review-tui.tsx— interactive OpenTUI training-review queueextensions/demur/— Pitool_callintegration and training-state storagesrc/adapters/claude-code.ts— Claude CodePreToolUseintegrationeval/— safe synthetic contrast cases and the live evaluation runner
Training feedback evaluation
demur training evaluate joins each record with its latest review and replays
stored raw judgments through the shared decide() policy. By default it makes
no TypeSafe requests. The report includes a decision matrix, an asymmetric weighted loss
that penalizes unsafe false allows most heavily, correction counts grouped by
structured reason, replay fidelity, and up to five single-threshold candidates
that improve the observed records.
Candidates are exploratory and are never applied automatically. The command
scores them on the same private records used to discover them, so validate a
candidate on an independent holdout and the synthetic corpus before changing
THRESHOLDS. Newly captured version-two records include the exact model state
and static-gate analysis required for complete replay; legacy records support
policy-only replay.
Pass --replay explicitly to send reviewed version-two records' previously
captured model state to TypeSafe again using the current question set. This is a
cost-bearing operation and re-discloses the stored command and context described
above. It is capped at 20 requests by default; use --limit=<1-100> to choose a
different bound. Commands remain data and are never executed. The replay report shows
improvements, regressions, failures, token usage, and per-record raw judgments;
use it when changing one question at a time.
Synthetic evaluation
The synthetic evaluation measures whether the two policy-qualification questions separate clear positive and negative cases, then verifies that active hazards produce a policy denial. Its 60 commands are hand-authored fixture strings with synthetic names and no secret values. The runner never executes a candidate command. It only sends each string and fixed synthetic context to TypeSafe.
Run one sample per case:
bun run eval:synthetic
Repeat each case (up to 10 samples) to expose model instability:
bun run eval:synthetic --runs=3
The command prints each case's expected and observed classification, exits
nonzero on a miss or provider failure, and writes full evidence to
.scratch/synthetic-eval.json. Repeated runs make additional provider calls and
may incur cost, so the live evaluation is deliberately not part of bun run ci.
Development
bun install --frozen-lockfile
bun run check
bun run test
bun run build
All three checks run together with bun run ci.
See CONTRIBUTING.md before proposing changes and RELEASING.md for the automated release process. Report security issues through SECURITY.md, not a public issue.