@wyattjoh/demur 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Wyatt Johnson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,200 @@
1
+ # demur
2
+
3
+ A proof-of-concept destructive-command guard for coding agents. demur sends a
4
+ shell command and limited execution context to TypeSafe System One, then turns
5
+ four model judgments into an `allow`, `ask`, or `deny` decision.
6
+
7
+ > [!WARNING]
8
+ > demur is experimental and is not a security boundary. A model can
9
+ > misclassify, behave nondeterministically, or be influenced by attacker-controlled
10
+ > command text. Use it as an additional confirmation layer, not as your only
11
+ > protection against destructive commands.
12
+
13
+ ## How it works
14
+
15
+ For each agent-initiated Bash tool call, demur:
16
+
17
+ 1. Collects the command, working directory, host name, and bounded Git facts.
18
+ 2. Requests four judgments in one TypeSafe System One call:
19
+ - whether the command executes a destructive operation;
20
+ - whether its effects are recoverable;
21
+ - whether it targets shared infrastructure; and
22
+ - its expected blast radius.
23
+ 3. Applies deterministic thresholds from [`src/policy.ts`](src/policy.ts).
24
+ 4. Escalates an otherwise allowed destructive command to `ask` when variables,
25
+ globs, or command substitutions make its real target statically uncertain.
26
+ 5. Maps the decision into Pi or Claude Code's permission protocol.
27
+
28
+ Environment access, Git queries, and network calls are Effect services. The
29
+ policy and shell analysis remain pure functions, while `src/guard.ts` exposes a
30
+ Promise boundary for host integrations.
31
+
32
+ ## Data disclosure
33
+
34
+ Every judged command makes a request to TypeSafe. demur sends:
35
+
36
+ - the complete command string;
37
+ - the working directory;
38
+ - the requesting host (`pi` or `claude-code`);
39
+ - the repository root and current branch, when inside Git; and
40
+ - counts of modified, untracked, and unpushed changes plus whether an upstream
41
+ branch exists.
42
+
43
+ demur does not send file contents, environment-variable values, remote URLs, or
44
+ its static-analysis result. Command strings and paths can still contain secrets
45
+ or sensitive names. Review TypeSafe's service terms and data-handling policy
46
+ before enabling demur in a sensitive repository. Do not run secrets directly in
47
+ shell arguments when the guard is active.
48
+
49
+ ## Requirements
50
+
51
+ - [Bun](https://bun.sh/) 1.4 or newer
52
+ - A TypeSafe System One API key from <https://console.typesafe.ai/settings/keys>
53
+ - Network access to TypeSafe for every judged command
54
+ - Pi 0.85.x and/or Claude Code
55
+
56
+ ## Install
57
+
58
+ Export the API key before launching the host agent. You can use your shell,
59
+ `.env.local` with a compatible environment loader, or any secret manager:
60
+
61
+ ```sh
62
+ export TYPESAFE_API_KEY="..."
63
+ ```
64
+
65
+ Never commit the key. [`.env.schema`](.env.schema) documents the accepted
66
+ configuration, and local environment files are ignored by Git.
67
+
68
+ ### Pi
69
+
70
+ Install the npm package:
71
+
72
+ ```sh
73
+ pi install npm:@wyattjoh/demur
74
+ ```
75
+
76
+ Pin a specific release when reproducibility matters:
77
+
78
+ ```sh
79
+ pi install npm:@wyattjoh/demur@0.1.0
80
+ ```
81
+
82
+ Launch Pi from an environment that already contains `TYPESAFE_API_KEY`:
83
+
84
+ ```sh
85
+ pi
86
+ ```
87
+
88
+ The extension intercepts `bash` tool calls. `ask` opens an interactive
89
+ confirmation dialog; without an interactive UI, demur blocks the command.
90
+
91
+ Pi packages execute with the user's full system permissions. Review this
92
+ repository before installing it.
93
+
94
+ For development, clone the repository and install it by local path:
95
+
96
+ ```sh
97
+ git clone https://github.com/wyattjoh/demur.git
98
+ cd demur
99
+ bun install --frozen-lockfile
100
+ pi install "$PWD"
101
+ ```
102
+
103
+ ### Claude Code
104
+
105
+ Register the source adapter in `~/.claude/settings.json`, replacing the path
106
+ with the absolute path to your clone:
107
+
108
+ ```json
109
+ {
110
+ "hooks": {
111
+ "PreToolUse": [
112
+ {
113
+ "matcher": "Bash",
114
+ "hooks": [
115
+ {
116
+ "type": "command",
117
+ "command": "bun /absolute/path/to/demur/src/adapters/claude-code.ts"
118
+ }
119
+ ]
120
+ }
121
+ ]
122
+ }
123
+ }
124
+ ```
125
+
126
+ Launch Claude Code from an environment that already contains
127
+ `TYPESAFE_API_KEY`. The adapter emits Claude Code's
128
+ `hookSpecificOutput.permissionDecision` response.
129
+
130
+ ### CLI
131
+
132
+ Judge a single command without installing a host integration:
133
+
134
+ ```sh
135
+ bun run judge "git reset --hard HEAD~3"
136
+ ```
137
+
138
+ ## Configuration
139
+
140
+ | Variable | Default | Purpose |
141
+ | --- | --- | --- |
142
+ | `TYPESAFE_API_KEY` | required | TypeSafe API credential. Missing keys fail closed. |
143
+ | `DEMUR_TIMEOUT_MS` | `4000` | Per-attempt model timeout in milliseconds. |
144
+ | `DEMUR_DISABLE` | unset | Emergency bypass. `1` or `true` allows every command. |
145
+
146
+ ## Failure posture
147
+
148
+ demur fails closed. A missing key, timeout, API failure, malformed response, or
149
+ unexpected guard error returns `deny` with a reason that identifies the guard
150
+ failure rather than presenting it as a policy judgment.
151
+
152
+ `DEMUR_DISABLE=1` is an explicit emergency bypass. It disables all protection
153
+ and should remain unset during normal use.
154
+
155
+ ## Known limitations
156
+
157
+ - Model decisions are probabilistic and may vary between identical requests.
158
+ - The hard-coded `jev-latest` model alias may change without a demur release.
159
+ - Attacker-controlled command text can influence the model.
160
+ - Shell expansion, obfuscation, aliases, wrappers, and runtime environment can
161
+ make a command behave differently from its text.
162
+ - Network outages block commands unless the emergency bypass is enabled.
163
+ - Every decision adds remote-call latency and may incur provider cost.
164
+ - The integrations guard agent-issued Bash tool calls only. They do not guard
165
+ user shells, other process-launching tools, or commands run outside the host.
166
+ - Other Pi extensions loaded after demur can mutate a tool call after it has been
167
+ judged.
168
+
169
+ Use operating-system permissions, backups, repository protections, sandboxing,
170
+ and deterministic policy controls alongside demur.
171
+
172
+ ## Project layout
173
+
174
+ - `src/questions.ts` — the four model judgments
175
+ - `src/policy.ts` — thresholds and `allow` / `ask` / `deny` composition
176
+ - `src/analyze.ts` — deterministic shell analysis for the static uncertainty gate
177
+ - `src/state.ts` — bounded environment and Git context collection
178
+ - `src/guard.internal.ts` — Effect-native orchestration and fail-closed recovery
179
+ - `src/guard.ts` — managed runtime and Promise boundary
180
+ - `extensions/demur/` — Pi `tool_call` integration
181
+ - `src/adapters/claude-code.ts` — Claude Code `PreToolUse` integration
182
+
183
+ ## Development
184
+
185
+ ```sh
186
+ bun install --frozen-lockfile
187
+ bun run check
188
+ bun run test
189
+ bun run build
190
+ ```
191
+
192
+ All three checks run together with `bun run ci`.
193
+
194
+ See [CONTRIBUTING.md](CONTRIBUTING.md) before proposing changes and
195
+ [RELEASING.md](RELEASING.md) for the automated release process. Report security
196
+ issues through [SECURITY.md](SECURITY.md), not a public issue.
197
+
198
+ ## License
199
+
200
+ [MIT](LICENSE)
@@ -0,0 +1,63 @@
1
+ import {
2
+ isToolCallEventType,
3
+ type ExtensionAPI,
4
+ type ExtensionContext,
5
+ type ToolCallEvent,
6
+ type ToolCallEventResult,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import { guard } from "../../src/guard.ts";
9
+
10
+ /**
11
+ * Handle one `tool_call` event, guarding shell commands only.
12
+ *
13
+ * Exported separately from the extension factory so it can be exercised
14
+ * directly in tests without standing up a Pi runtime.
15
+ *
16
+ * @param event - The tool call Pi is about to execute
17
+ * @param ctx - Extension context, used for the working directory and prompts
18
+ * @returns A block result when the command is denied, otherwise nothing
19
+ */
20
+ export async function handleToolCall(
21
+ event: ToolCallEvent,
22
+ ctx: ExtensionContext,
23
+ ): Promise<ToolCallEventResult | undefined> {
24
+ if (!isToolCallEventType("bash", event)) return undefined;
25
+
26
+ const command = event.input.command ?? "";
27
+ if (command.trim() === "") return undefined;
28
+
29
+ const verdict = await guard(command, ctx.cwd, "pi", ctx.signal);
30
+
31
+ if (verdict.decision === "allow") return undefined;
32
+
33
+ if (verdict.decision === "deny") {
34
+ return { block: true, reason: verdict.reason };
35
+ }
36
+
37
+ // "ask": Pi can put the decision in front of the user, which is strictly
38
+ // better than the agent guessing. Without a UI there is nobody to ask, so the
39
+ // fail-closed posture applies and the command is blocked.
40
+ if (!ctx.hasUI) {
41
+ return {
42
+ block: true,
43
+ reason: `${verdict.reason} No interactive UI available to confirm, so blocking.`,
44
+ };
45
+ }
46
+
47
+ const approved = await ctx.ui.confirm("demur", `${verdict.reason}\n\n${command}\n\nRun it anyway?`);
48
+ if (approved) return undefined;
49
+
50
+ return { block: true, reason: `${verdict.reason} Declined by the user.` };
51
+ }
52
+
53
+ /**
54
+ * Pi extension entry point.
55
+ *
56
+ * Routes every bash tool call through a TypeSafe System One judgment before Pi
57
+ * is allowed to execute it.
58
+ *
59
+ * @param pi - The extension API provided by Pi
60
+ */
61
+ export default function demur(pi: ExtensionAPI): void {
62
+ pi.on("tool_call", handleToolCall);
63
+ }
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "@wyattjoh/demur",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "A proof-of-concept destructive-command guard for coding agents.",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/wyattjoh/demur.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/wyattjoh/demur/issues"
13
+ },
14
+ "homepage": "https://github.com/wyattjoh/demur#readme",
15
+ "keywords": [
16
+ "coding-agent",
17
+ "pi-package",
18
+ "security"
19
+ ],
20
+ "files": [
21
+ "extensions/demur/index.ts",
22
+ "src/adapters/claude-code.ts",
23
+ "src/analyze.ts",
24
+ "src/cli.ts",
25
+ "src/guard.internal.ts",
26
+ "src/guard.ts",
27
+ "src/judge.ts",
28
+ "src/key.ts",
29
+ "src/policy.ts",
30
+ "src/questions.ts",
31
+ "src/state.ts",
32
+ "src/types.ts"
33
+ ],
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "packageManager": "bun@1.4.2",
38
+ "engines": {
39
+ "bun": ">=1.4.0"
40
+ },
41
+ "scripts": {
42
+ "check": "tsc --noEmit",
43
+ "test": "vitest run",
44
+ "build:pi": "bun build extensions/demur/index.ts --target=bun --outfile=dist/demur-guard.ts --format=esm --external @earendil-works/pi-coding-agent",
45
+ "build:claude": "bun build src/adapters/claude-code.ts --target=bun --outfile=dist/demur-hook.js --format=esm",
46
+ "build": "bun run build:pi && bun run build:claude",
47
+ "ci": "bun run check && bun run test && bun run build",
48
+ "judge": "bun run src/cli.ts",
49
+ "prepublishOnly": "bun run ci"
50
+ },
51
+ "pi": {
52
+ "extensions": [
53
+ "./extensions"
54
+ ]
55
+ },
56
+ "dependencies": {
57
+ "@effect/ai-typesafe": "4.0.0-rc.116",
58
+ "effect": "4.0.0-rc.116"
59
+ },
60
+ "devDependencies": {
61
+ "@earendil-works/pi-coding-agent": "0.85.1",
62
+ "@effect/vitest": "4.0.0-rc.116",
63
+ "@types/bun": "1.4.2",
64
+ "typescript": "5.9.3",
65
+ "vitest": "5.0.1"
66
+ },
67
+ "peerDependencies": {
68
+ "@earendil-works/pi-coding-agent": "*"
69
+ }
70
+ }
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env bun
2
+ import { Option, Schema } from "effect";
3
+ import { guard } from "../guard.ts";
4
+
5
+ const PreToolUsePayload = Schema.Struct({
6
+ cwd: Schema.optionalKey(Schema.String),
7
+ tool_name: Schema.optionalKey(Schema.String),
8
+ tool_input: Schema.optionalKey(
9
+ Schema.Struct({ command: Schema.optionalKey(Schema.String) }),
10
+ ),
11
+ });
12
+
13
+ const decodePayload = Schema.decodeUnknownOption(PreToolUsePayload);
14
+
15
+ /**
16
+ * Emit a `PreToolUse` decision on stdout in Claude Code's hook protocol.
17
+ *
18
+ * The hook always exits 0: the decision is carried by the JSON body, not the
19
+ * exit code, and a non-zero exit would be read as a hook malfunction rather
20
+ * than a policy result.
21
+ *
22
+ * @param decision - What Claude Code should do with the tool call
23
+ * @param reason - Text shown to the model and the user
24
+ */
25
+ function emit(decision: "allow" | "deny" | "ask", reason: string): void {
26
+ process.stdout.write(
27
+ `${JSON.stringify({
28
+ hookSpecificOutput: {
29
+ hookEventName: "PreToolUse",
30
+ permissionDecision: decision,
31
+ permissionDecisionReason: reason,
32
+ },
33
+ })}\n`,
34
+ );
35
+ }
36
+
37
+ /**
38
+ * Read the hook payload, judge the command, and print the decision.
39
+ *
40
+ * Only `Bash` tool calls are judged; everything else is passed through
41
+ * untouched so the hook can be registered broadly without cost.
42
+ */
43
+ async function main(): Promise<void> {
44
+ let input: unknown;
45
+ try {
46
+ input = JSON.parse(await Bun.stdin.text());
47
+ } catch {
48
+ // A malformed envelope is a guard failure, and demur fails closed.
49
+ emit("deny", "demur: could not parse the PreToolUse payload.");
50
+ return;
51
+ }
52
+
53
+ const payload = Option.getOrUndefined(decodePayload(input));
54
+ if (payload === undefined) {
55
+ emit("deny", "demur: invalid PreToolUse payload.");
56
+ return;
57
+ }
58
+
59
+ if (payload.tool_name !== "Bash") {
60
+ emit("allow", "demur: not a Bash call.");
61
+ return;
62
+ }
63
+
64
+ const command = payload.tool_input?.command ?? "";
65
+ const cwd = payload.cwd ?? process.cwd();
66
+ const verdict = await guard(command, cwd, "claude-code");
67
+
68
+ emit(verdict.decision, verdict.reason);
69
+ }
70
+
71
+ await main();