@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 +21 -0
- package/README.md +200 -0
- package/extensions/demur/index.ts +63 -0
- package/package.json +70 -0
- package/src/adapters/claude-code.ts +71 -0
- package/src/analyze.ts +621 -0
- package/src/cli.ts +41 -0
- package/src/guard.internal.ts +164 -0
- package/src/guard.ts +84 -0
- package/src/judge.ts +233 -0
- package/src/key.ts +45 -0
- package/src/policy.ts +215 -0
- package/src/questions.ts +70 -0
- package/src/state.ts +287 -0
- package/src/types.ts +153 -0
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();
|