@wyattjoh/demur 0.1.0 → 0.2.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/README.md +39 -17
- package/package.json +5 -1
- package/src/adapters/claude-code.ts +0 -0
- package/src/cli.ts +216 -21
- package/src/judge.ts +19 -6
- package/src/key.ts +210 -9
- package/src/types.ts +1 -0
package/README.md
CHANGED
|
@@ -55,15 +55,28 @@ shell arguments when the guard is active.
|
|
|
55
55
|
|
|
56
56
|
## Install
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
Install demur's command-line tools and save your TypeSafe API key in the
|
|
59
|
+
operating system credential store:
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
bun add --global @wyattjoh/demur
|
|
63
|
+
demur auth login
|
|
64
|
+
demur auth status
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`Bun.secrets` stores the credential in macOS Keychain, Linux Secret Service, or
|
|
68
|
+
Windows Credential Manager. The operating system may request access when the
|
|
69
|
+
credential is first used or while its credential store is locked.
|
|
70
|
+
|
|
71
|
+
For automation or a one-off override, set `TYPESAFE_API_KEY` before launching
|
|
72
|
+
the host agent. An environment value takes precedence over the stored key:
|
|
60
73
|
|
|
61
74
|
```sh
|
|
62
75
|
export TYPESAFE_API_KEY="..."
|
|
63
76
|
```
|
|
64
77
|
|
|
65
78
|
Never commit the key. [`.env.schema`](.env.schema) documents the accepted
|
|
66
|
-
configuration, and local environment files are ignored by Git.
|
|
79
|
+
environment configuration, and local environment files are ignored by Git.
|
|
67
80
|
|
|
68
81
|
### Pi
|
|
69
82
|
|
|
@@ -76,10 +89,10 @@ pi install npm:@wyattjoh/demur
|
|
|
76
89
|
Pin a specific release when reproducibility matters:
|
|
77
90
|
|
|
78
91
|
```sh
|
|
79
|
-
pi install npm:@wyattjoh/demur@0.1.
|
|
92
|
+
pi install npm:@wyattjoh/demur@0.1.1
|
|
80
93
|
```
|
|
81
94
|
|
|
82
|
-
Launch Pi
|
|
95
|
+
Launch Pi normally after configuring the credential:
|
|
83
96
|
|
|
84
97
|
```sh
|
|
85
98
|
pi
|
|
@@ -102,8 +115,8 @@ pi install "$PWD"
|
|
|
102
115
|
|
|
103
116
|
### Claude Code
|
|
104
117
|
|
|
105
|
-
|
|
106
|
-
|
|
118
|
+
The global package installation above also provides the Claude Code hook.
|
|
119
|
+
Register its executable in `~/.claude/settings.json`:
|
|
107
120
|
|
|
108
121
|
```json
|
|
109
122
|
{
|
|
@@ -114,7 +127,7 @@ with the absolute path to your clone:
|
|
|
114
127
|
"hooks": [
|
|
115
128
|
{
|
|
116
129
|
"type": "command",
|
|
117
|
-
"command": "
|
|
130
|
+
"command": "demur-claude-hook"
|
|
118
131
|
}
|
|
119
132
|
]
|
|
120
133
|
}
|
|
@@ -123,37 +136,45 @@ with the absolute path to your clone:
|
|
|
123
136
|
}
|
|
124
137
|
```
|
|
125
138
|
|
|
126
|
-
Launch Claude Code
|
|
127
|
-
|
|
128
|
-
`hookSpecificOutput.permissionDecision` response.
|
|
139
|
+
Launch Claude Code normally after configuring the credential. The adapter emits
|
|
140
|
+
Claude Code's `hookSpecificOutput.permissionDecision` response.
|
|
129
141
|
|
|
130
142
|
### CLI
|
|
131
143
|
|
|
132
|
-
|
|
144
|
+
Manage the stored credential or judge a single command without a host
|
|
145
|
+
integration:
|
|
133
146
|
|
|
134
147
|
```sh
|
|
135
|
-
|
|
148
|
+
demur auth login
|
|
149
|
+
demur auth status
|
|
150
|
+
demur auth logout
|
|
151
|
+
demur judge "git reset --hard HEAD~3"
|
|
136
152
|
```
|
|
137
153
|
|
|
154
|
+
From a development checkout, `bun run judge "<command>"` remains available.
|
|
155
|
+
|
|
138
156
|
## Configuration
|
|
139
157
|
|
|
140
158
|
| Variable | Default | Purpose |
|
|
141
159
|
| --- | --- | --- |
|
|
142
|
-
| `TYPESAFE_API_KEY` |
|
|
160
|
+
| `TYPESAFE_API_KEY` | stored credential | Optional TypeSafe API credential override. Missing keys fail closed. |
|
|
143
161
|
| `DEMUR_TIMEOUT_MS` | `4000` | Per-attempt model timeout in milliseconds. |
|
|
144
162
|
| `DEMUR_DISABLE` | unset | Emergency bypass. `1` or `true` allows every command. |
|
|
145
163
|
|
|
146
164
|
## Failure posture
|
|
147
165
|
|
|
148
|
-
demur fails closed. A missing key,
|
|
149
|
-
unexpected guard error returns `deny` with a
|
|
150
|
-
failure rather than presenting it as a policy
|
|
166
|
+
demur fails closed. A missing key, credential-store failure, timeout, API
|
|
167
|
+
failure, malformed response, or unexpected guard error returns `deny` with a
|
|
168
|
+
reason that identifies the guard failure rather than presenting it as a policy
|
|
169
|
+
judgment.
|
|
151
170
|
|
|
152
171
|
`DEMUR_DISABLE=1` is an explicit emergency bypass. It disables all protection
|
|
153
172
|
and should remain unset during normal use.
|
|
154
173
|
|
|
155
174
|
## Known limitations
|
|
156
175
|
|
|
176
|
+
- `Bun.secrets` is experimental, and credential-store availability and prompts
|
|
177
|
+
vary by operating system configuration.
|
|
157
178
|
- Model decisions are probabilistic and may vary between identical requests.
|
|
158
179
|
- The hard-coded `jev-latest` model alias may change without a demur release.
|
|
159
180
|
- Attacker-controlled command text can influence the model.
|
|
@@ -175,6 +196,7 @@ and deterministic policy controls alongside demur.
|
|
|
175
196
|
- `src/policy.ts` — thresholds and `allow` / `ask` / `deny` composition
|
|
176
197
|
- `src/analyze.ts` — deterministic shell analysis for the static uncertainty gate
|
|
177
198
|
- `src/state.ts` — bounded environment and Git context collection
|
|
199
|
+
- `src/key.ts` — environment precedence and operating-system credential storage
|
|
178
200
|
- `src/guard.internal.ts` — Effect-native orchestration and fail-closed recovery
|
|
179
201
|
- `src/guard.ts` — managed runtime and Promise boundary
|
|
180
202
|
- `extensions/demur/` — Pi `tool_call` integration
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wyattjoh/demur",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A proof-of-concept destructive-command guard for coding agents.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
"url": "https://github.com/wyattjoh/demur/issues"
|
|
13
13
|
},
|
|
14
14
|
"homepage": "https://github.com/wyattjoh/demur#readme",
|
|
15
|
+
"bin": {
|
|
16
|
+
"demur": "src/cli.ts",
|
|
17
|
+
"demur-claude-hook": "src/adapters/claude-code.ts"
|
|
18
|
+
},
|
|
15
19
|
"keywords": [
|
|
16
20
|
"coding-agent",
|
|
17
21
|
"pi-package",
|
|
File without changes
|
package/src/cli.ts
CHANGED
|
@@ -1,41 +1,236 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
import { Predicate } from "effect";
|
|
2
3
|
import { guard } from "./guard.ts";
|
|
4
|
+
import {
|
|
5
|
+
deleteApiKey,
|
|
6
|
+
resolveApiKey,
|
|
7
|
+
storeApiKey,
|
|
8
|
+
type ResolvedApiKey,
|
|
9
|
+
} from "./key.ts";
|
|
10
|
+
import type { Verdict } from "./types.ts";
|
|
11
|
+
|
|
12
|
+
const USAGE = `Usage:
|
|
13
|
+
demur auth login
|
|
14
|
+
demur auth status
|
|
15
|
+
demur auth logout
|
|
16
|
+
demur judge "<command>" [--cwd=<path>]`;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Injectable process boundaries used by the command-line interface.
|
|
20
|
+
*/
|
|
21
|
+
export type CliDependencies = {
|
|
22
|
+
judge(command: string, cwd: string): Promise<Verdict>;
|
|
23
|
+
resolveApiKey(): Promise<ResolvedApiKey | undefined>;
|
|
24
|
+
storeApiKey(value: string): Promise<void>;
|
|
25
|
+
deleteApiKey(): Promise<boolean>;
|
|
26
|
+
readSecret(prompt: string): Promise<string>;
|
|
27
|
+
cwd(): string;
|
|
28
|
+
stdout(message: string): void;
|
|
29
|
+
stderr(message: string): void;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const defaultDependencies: CliDependencies = {
|
|
33
|
+
judge: (command, cwd) => guard(command, cwd, "cli"),
|
|
34
|
+
resolveApiKey,
|
|
35
|
+
storeApiKey,
|
|
36
|
+
deleteApiKey,
|
|
37
|
+
readSecret,
|
|
38
|
+
cwd: () => process.cwd(),
|
|
39
|
+
stdout: (message) => console.log(message),
|
|
40
|
+
stderr: (message) => console.error(message),
|
|
41
|
+
};
|
|
3
42
|
|
|
4
43
|
/**
|
|
5
|
-
*
|
|
6
|
-
* judgments behind it.
|
|
44
|
+
* Run demur's command-line interface with explicit process dependencies.
|
|
7
45
|
*
|
|
8
|
-
*
|
|
46
|
+
* @param args - Command-line arguments after the executable name
|
|
47
|
+
* @param dependencies - Guard, credential, terminal, and process boundaries
|
|
48
|
+
* @returns The process exit code
|
|
9
49
|
*/
|
|
10
|
-
async function
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
50
|
+
export async function runCli(
|
|
51
|
+
args: ReadonlyArray<string>,
|
|
52
|
+
dependencies: CliDependencies = defaultDependencies,
|
|
53
|
+
): Promise<number> {
|
|
54
|
+
try {
|
|
55
|
+
if (args[0] === "auth") {
|
|
56
|
+
return await runAuth(args.slice(1), dependencies);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (args[0] === "help" || args[0] === "--help" || args[0] === "-h") {
|
|
60
|
+
dependencies.stdout(USAGE);
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const judgeArgs = args[0] === "judge" ? args.slice(1) : args;
|
|
65
|
+
return await runJudge(judgeArgs, dependencies);
|
|
66
|
+
} catch (error: unknown) {
|
|
67
|
+
dependencies.stderr(`demur: ${errorDetail(error)}`);
|
|
68
|
+
return 1;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function runAuth(
|
|
73
|
+
args: ReadonlyArray<string>,
|
|
74
|
+
dependencies: CliDependencies,
|
|
75
|
+
): Promise<number> {
|
|
76
|
+
const command = args[0];
|
|
77
|
+
|
|
78
|
+
if (command === "login" && args.length === 1) {
|
|
79
|
+
const resolved = await dependencies.resolveApiKey();
|
|
80
|
+
const value =
|
|
81
|
+
resolved?.source === "environment"
|
|
82
|
+
? resolved.value
|
|
83
|
+
: await dependencies.readSecret("TypeSafe API key: ");
|
|
84
|
+
|
|
85
|
+
if (value.trim() === "") {
|
|
86
|
+
dependencies.stderr("demur: the TypeSafe API key cannot be empty.");
|
|
87
|
+
return 2;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
await dependencies.storeApiKey(value);
|
|
91
|
+
dependencies.stdout(
|
|
92
|
+
resolved?.source === "environment"
|
|
93
|
+
? "Stored TYPESAFE_API_KEY in the operating system credential store."
|
|
94
|
+
: "Stored the TypeSafe API key in the operating system credential store.",
|
|
95
|
+
);
|
|
96
|
+
if (resolved?.source === "environment") {
|
|
97
|
+
dependencies.stdout(
|
|
98
|
+
"TYPESAFE_API_KEY remains the active override until it is unset.",
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (command === "status" && args.length === 1) {
|
|
105
|
+
const resolved = await dependencies.resolveApiKey();
|
|
106
|
+
if (resolved === undefined) {
|
|
107
|
+
dependencies.stderr("No TypeSafe API key is configured.");
|
|
108
|
+
return 1;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
dependencies.stdout(
|
|
112
|
+
resolved.source === "environment"
|
|
113
|
+
? "A TypeSafe API key is configured through TYPESAFE_API_KEY."
|
|
114
|
+
: "A TypeSafe API key is stored in the operating system credential store.",
|
|
115
|
+
);
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (command === "logout" && args.length === 1) {
|
|
120
|
+
const resolved = await dependencies.resolveApiKey();
|
|
121
|
+
const deleted = await dependencies.deleteApiKey();
|
|
122
|
+
dependencies.stdout(
|
|
123
|
+
deleted
|
|
124
|
+
? "Deleted the stored TypeSafe API key."
|
|
125
|
+
: "No stored TypeSafe API key was found.",
|
|
126
|
+
);
|
|
127
|
+
if (resolved?.source === "environment") {
|
|
128
|
+
dependencies.stdout(
|
|
129
|
+
"TYPESAFE_API_KEY remains configured and must be unset separately.",
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
return 0;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
dependencies.stderr(USAGE);
|
|
136
|
+
return 2;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function runJudge(
|
|
140
|
+
args: ReadonlyArray<string>,
|
|
141
|
+
dependencies: CliDependencies,
|
|
142
|
+
): Promise<number> {
|
|
143
|
+
const cwdArg = args.find((arg) => arg.startsWith("--cwd="));
|
|
144
|
+
const command = args.filter((arg) => !arg.startsWith("--")).join(" ");
|
|
14
145
|
|
|
15
146
|
if (command.trim() === "") {
|
|
16
|
-
|
|
17
|
-
|
|
147
|
+
dependencies.stderr(USAGE);
|
|
148
|
+
return 2;
|
|
18
149
|
}
|
|
19
150
|
|
|
20
|
-
const cwd = cwdArg?.slice("--cwd=".length)
|
|
21
|
-
const verdict = await
|
|
151
|
+
const cwd = cwdArg?.slice("--cwd=".length) || dependencies.cwd();
|
|
152
|
+
const verdict = await dependencies.judge(command, cwd);
|
|
22
153
|
|
|
23
154
|
const mark = { allow: "✓", ask: "?", deny: "✗" }[verdict.decision];
|
|
24
|
-
|
|
155
|
+
dependencies.stdout(
|
|
156
|
+
`${mark} ${verdict.decision.toUpperCase()} ${verdict.reason}`,
|
|
157
|
+
);
|
|
25
158
|
|
|
26
159
|
if (verdict.judgments !== undefined) {
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
160
|
+
const judgments = verdict.judgments;
|
|
161
|
+
dependencies.stdout("");
|
|
162
|
+
dependencies.stdout(
|
|
163
|
+
` executes destruction ${judgments.executesDestruction.toFixed(3)}`,
|
|
164
|
+
);
|
|
165
|
+
dependencies.stdout(
|
|
166
|
+
` unrecoverable ${judgments.unrecoverable.toFixed(3)}`,
|
|
167
|
+
);
|
|
168
|
+
dependencies.stdout(
|
|
169
|
+
` shared infrastructure ${judgments.targetsSharedInfrastructure.toFixed(3)}`,
|
|
170
|
+
);
|
|
171
|
+
dependencies.stdout(
|
|
172
|
+
` blast radius ${judgments.blastRadius.toFixed(2)}/3 (confidence ${judgments.blastRadiusConfidence.toFixed(2)})`,
|
|
173
|
+
);
|
|
33
174
|
}
|
|
34
175
|
|
|
35
|
-
|
|
36
|
-
|
|
176
|
+
dependencies.stdout("");
|
|
177
|
+
dependencies.stdout(
|
|
37
178
|
` ${verdict.latencyMs}ms${verdict.usage ? `, ${verdict.usage.inputTokens} in / ${verdict.usage.outputTokens} out tokens` : ""}`,
|
|
38
179
|
);
|
|
180
|
+
return 0;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function readSecret(prompt: string): Promise<string> {
|
|
184
|
+
if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== "function") {
|
|
185
|
+
return (await Bun.stdin.text()).trim();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
process.stderr.write(prompt);
|
|
189
|
+
process.stdin.setEncoding("utf8");
|
|
190
|
+
process.stdin.setRawMode(true);
|
|
191
|
+
process.stdin.resume();
|
|
192
|
+
|
|
193
|
+
return await new Promise<string>((resolve, reject) => {
|
|
194
|
+
let value = "";
|
|
195
|
+
|
|
196
|
+
const cleanup = () => {
|
|
197
|
+
process.stdin.off("data", onData);
|
|
198
|
+
process.stdin.setRawMode(false);
|
|
199
|
+
process.stdin.pause();
|
|
200
|
+
process.stderr.write("\n");
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const onData = (chunk: string | Buffer) => {
|
|
204
|
+
for (const character of String(chunk)) {
|
|
205
|
+
if (character === "\u0003" || character === "\u0004") {
|
|
206
|
+
cleanup();
|
|
207
|
+
reject(new Error("credential entry cancelled"));
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (character === "\r" || character === "\n") {
|
|
212
|
+
cleanup();
|
|
213
|
+
resolve(value.trim());
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (character === "\b" || character === "\u007f") {
|
|
218
|
+
value = Array.from(value).slice(0, -1).join("");
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (character >= " ") value += character;
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
process.stdin.on("data", onData);
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function errorDetail(error: unknown): string {
|
|
231
|
+
return Predicate.isError(error) ? error.message : String(error);
|
|
39
232
|
}
|
|
40
233
|
|
|
41
|
-
|
|
234
|
+
if (import.meta.main) {
|
|
235
|
+
process.exitCode = await runCli(Bun.argv.slice(2));
|
|
236
|
+
}
|
package/src/judge.ts
CHANGED
|
@@ -12,7 +12,11 @@ import {
|
|
|
12
12
|
} from "effect";
|
|
13
13
|
import { AiError, DecisionModel } from "effect/unstable/ai";
|
|
14
14
|
import { FetchHttpClient } from "effect/unstable/http";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
Environment,
|
|
17
|
+
MISSING_KEY_HELP,
|
|
18
|
+
TypeSafeApiKey,
|
|
19
|
+
} from "./key.ts";
|
|
16
20
|
import { COMMAND_JUDGMENTS } from "./questions.ts";
|
|
17
21
|
import { renderState } from "./state.ts";
|
|
18
22
|
import type { CommandState, FailureKind, Judgments } from "./types.ts";
|
|
@@ -71,6 +75,7 @@ export class JudgmentError extends Schema.TaggedError<JudgmentError>()(
|
|
|
71
75
|
{
|
|
72
76
|
failure: Schema.Literals([
|
|
73
77
|
"no-api-key",
|
|
78
|
+
"credential-error",
|
|
74
79
|
"timeout",
|
|
75
80
|
"api-error",
|
|
76
81
|
"unexpected",
|
|
@@ -92,6 +97,7 @@ export class Judgment extends Context.Service<
|
|
|
92
97
|
Judgment,
|
|
93
98
|
Effect.gen(function* () {
|
|
94
99
|
const environment = yield* Environment;
|
|
100
|
+
const apiKey = yield* TypeSafeApiKey;
|
|
95
101
|
let decisionLayer: ReturnType<typeof makeDecisionModelLayer> | undefined;
|
|
96
102
|
let timeoutMs = DEFAULT_TIMEOUT_MS;
|
|
97
103
|
|
|
@@ -102,9 +108,16 @@ export class Judgment extends Context.Service<
|
|
|
102
108
|
> {
|
|
103
109
|
if (decisionLayer !== undefined) return decisionLayer;
|
|
104
110
|
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
111
|
+
const resolvedApiKey = yield* apiKey.resolve.pipe(
|
|
112
|
+
Effect.mapError(
|
|
113
|
+
(error) =>
|
|
114
|
+
new JudgmentError({
|
|
115
|
+
failure: "credential-error",
|
|
116
|
+
detail: `Unable to read the operating system credential store: ${error.detail}`,
|
|
117
|
+
}),
|
|
118
|
+
),
|
|
119
|
+
);
|
|
120
|
+
if (resolvedApiKey === undefined) {
|
|
108
121
|
return yield* new JudgmentError({
|
|
109
122
|
failure: "no-api-key",
|
|
110
123
|
detail: MISSING_KEY_HELP,
|
|
@@ -115,7 +128,7 @@ export class Judgment extends Context.Service<
|
|
|
115
128
|
yield* environment.get("DEMUR_TIMEOUT_MS"),
|
|
116
129
|
);
|
|
117
130
|
timeoutMs = configuredTimeout || DEFAULT_TIMEOUT_MS;
|
|
118
|
-
decisionLayer = makeDecisionModelLayer(
|
|
131
|
+
decisionLayer = makeDecisionModelLayer(resolvedApiKey.value);
|
|
119
132
|
return decisionLayer;
|
|
120
133
|
},
|
|
121
134
|
);
|
|
@@ -158,7 +171,7 @@ export class Judgment extends Context.Service<
|
|
|
158
171
|
);
|
|
159
172
|
|
|
160
173
|
static readonly layer = this.layerNoDeps.pipe(
|
|
161
|
-
Layer.provide(Environment.layer),
|
|
174
|
+
Layer.provide(Layer.merge(Environment.layer, TypeSafeApiKey.layer)),
|
|
162
175
|
);
|
|
163
176
|
}
|
|
164
177
|
|
package/src/key.ts
CHANGED
|
@@ -1,4 +1,40 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
Context,
|
|
3
|
+
Effect,
|
|
4
|
+
Layer,
|
|
5
|
+
ManagedRuntime,
|
|
6
|
+
Predicate,
|
|
7
|
+
Schema,
|
|
8
|
+
} from "effect";
|
|
9
|
+
|
|
10
|
+
const TYPESAFE_SECRET = {
|
|
11
|
+
service: "com.github.wyattjoh.demur",
|
|
12
|
+
name: "typesafe-api-key",
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Where demur found the active TypeSafe API key.
|
|
17
|
+
*/
|
|
18
|
+
export type ApiKeySource = "environment" | "system";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A resolved TypeSafe API key and the source that supplied it.
|
|
22
|
+
*/
|
|
23
|
+
export type ResolvedApiKey = {
|
|
24
|
+
value: string;
|
|
25
|
+
source: ApiKeySource;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A failure while accessing the operating system credential store.
|
|
30
|
+
*/
|
|
31
|
+
export class SecretStoreError extends Schema.TaggedError<SecretStoreError>()(
|
|
32
|
+
"SecretStoreError",
|
|
33
|
+
{
|
|
34
|
+
operation: Schema.Literals(["read", "write", "delete"]),
|
|
35
|
+
detail: Schema.String,
|
|
36
|
+
},
|
|
37
|
+
) {}
|
|
2
38
|
|
|
3
39
|
/**
|
|
4
40
|
* Effect service for reading process environment variables.
|
|
@@ -23,16 +59,167 @@ export class Environment extends Context.Service<
|
|
|
23
59
|
}
|
|
24
60
|
|
|
25
61
|
/**
|
|
26
|
-
*
|
|
62
|
+
* Effect service for the operating system's credential storage.
|
|
63
|
+
*/
|
|
64
|
+
export class SystemSecrets extends Context.Service<
|
|
65
|
+
SystemSecrets,
|
|
66
|
+
{
|
|
67
|
+
get(service: string, name: string): Effect.Effect<string | undefined, SecretStoreError>;
|
|
68
|
+
set(service: string, name: string, value: string): Effect.Effect<void, SecretStoreError>;
|
|
69
|
+
delete(service: string, name: string): Effect.Effect<boolean, SecretStoreError>;
|
|
70
|
+
}
|
|
71
|
+
>()("demur/key/SystemSecrets") {
|
|
72
|
+
static readonly layer = Layer.succeed(
|
|
73
|
+
SystemSecrets,
|
|
74
|
+
SystemSecrets.of({
|
|
75
|
+
get: Effect.fn("SystemSecrets.get")(function* (
|
|
76
|
+
service: string,
|
|
77
|
+
name: string,
|
|
78
|
+
) {
|
|
79
|
+
const value = yield* Effect.tryPromise({
|
|
80
|
+
try: () => Bun.secrets.get({ service, name }),
|
|
81
|
+
catch: secretStoreFailure("read"),
|
|
82
|
+
});
|
|
83
|
+
return value ?? undefined;
|
|
84
|
+
}),
|
|
85
|
+
set: Effect.fn("SystemSecrets.set")(function* (
|
|
86
|
+
service: string,
|
|
87
|
+
name: string,
|
|
88
|
+
value: string,
|
|
89
|
+
) {
|
|
90
|
+
yield* Effect.tryPromise({
|
|
91
|
+
try: () => Bun.secrets.set({ service, name, value }),
|
|
92
|
+
catch: secretStoreFailure("write"),
|
|
93
|
+
});
|
|
94
|
+
}),
|
|
95
|
+
delete: Effect.fn("SystemSecrets.delete")(function* (
|
|
96
|
+
service: string,
|
|
97
|
+
name: string,
|
|
98
|
+
) {
|
|
99
|
+
return yield* Effect.tryPromise({
|
|
100
|
+
try: () => Bun.secrets.delete({ service, name }),
|
|
101
|
+
catch: secretStoreFailure("delete"),
|
|
102
|
+
});
|
|
103
|
+
}),
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Effect service that resolves and manages demur's TypeSafe API key.
|
|
110
|
+
*
|
|
111
|
+
* `TYPESAFE_API_KEY` remains the highest-priority source for automation and
|
|
112
|
+
* one-off overrides. Otherwise, demur reads the key from the operating system's
|
|
113
|
+
* credential store through {@link SystemSecrets}.
|
|
114
|
+
*/
|
|
115
|
+
export class TypeSafeApiKey extends Context.Service<
|
|
116
|
+
TypeSafeApiKey,
|
|
117
|
+
{
|
|
118
|
+
readonly resolve: Effect.Effect<ResolvedApiKey | undefined, SecretStoreError>;
|
|
119
|
+
store(value: string): Effect.Effect<void, SecretStoreError>;
|
|
120
|
+
readonly remove: Effect.Effect<boolean, SecretStoreError>;
|
|
121
|
+
}
|
|
122
|
+
>()("demur/key/TypeSafeApiKey") {
|
|
123
|
+
static readonly layerNoDeps = Layer.effect(
|
|
124
|
+
TypeSafeApiKey,
|
|
125
|
+
Effect.gen(function* () {
|
|
126
|
+
const environment = yield* Environment;
|
|
127
|
+
const secrets = yield* SystemSecrets;
|
|
128
|
+
|
|
129
|
+
const resolve = Effect.gen(function* () {
|
|
130
|
+
const environmentValue = normalizeApiKey(
|
|
131
|
+
yield* environment.get("TYPESAFE_API_KEY"),
|
|
132
|
+
);
|
|
133
|
+
if (environmentValue !== undefined) {
|
|
134
|
+
return {
|
|
135
|
+
value: environmentValue,
|
|
136
|
+
source: "environment",
|
|
137
|
+
} satisfies ResolvedApiKey;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const storedValue = normalizeApiKey(
|
|
141
|
+
yield* secrets.get(TYPESAFE_SECRET.service, TYPESAFE_SECRET.name),
|
|
142
|
+
);
|
|
143
|
+
if (storedValue === undefined) return undefined;
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
value: storedValue,
|
|
147
|
+
source: "system",
|
|
148
|
+
} satisfies ResolvedApiKey;
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const store = Effect.fn("TypeSafeApiKey.store")(function* (value: string) {
|
|
152
|
+
const normalized = normalizeApiKey(value);
|
|
153
|
+
if (normalized === undefined) {
|
|
154
|
+
return yield* new SecretStoreError({
|
|
155
|
+
operation: "write",
|
|
156
|
+
detail: "The TypeSafe API key cannot be empty.",
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
yield* secrets.set(
|
|
161
|
+
TYPESAFE_SECRET.service,
|
|
162
|
+
TYPESAFE_SECRET.name,
|
|
163
|
+
normalized,
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
return TypeSafeApiKey.of({
|
|
168
|
+
resolve,
|
|
169
|
+
store,
|
|
170
|
+
remove: secrets.delete(TYPESAFE_SECRET.service, TYPESAFE_SECRET.name),
|
|
171
|
+
});
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
static readonly layer = this.layerNoDeps.pipe(
|
|
176
|
+
Layer.provide(Layer.merge(Environment.layer, SystemSecrets.layer)),
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const runtime = ManagedRuntime.make(TypeSafeApiKey.layer);
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Resolve the TypeSafe API key for a Promise-based host integration.
|
|
184
|
+
*
|
|
185
|
+
* @returns The active key and its source, or `undefined` when none is configured
|
|
186
|
+
*/
|
|
187
|
+
export function resolveApiKey(): Promise<ResolvedApiKey | undefined> {
|
|
188
|
+
return runtime.runPromise(
|
|
189
|
+
Effect.gen(function* () {
|
|
190
|
+
const apiKey = yield* TypeSafeApiKey;
|
|
191
|
+
return yield* apiKey.resolve;
|
|
192
|
+
}),
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Store a TypeSafe API key in the operating system credential store.
|
|
27
198
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
|
|
199
|
+
* @param value - The API key to persist
|
|
200
|
+
* @returns A promise that completes after the credential is stored
|
|
201
|
+
*/
|
|
202
|
+
export function storeApiKey(value: string): Promise<void> {
|
|
203
|
+
return runtime.runPromise(
|
|
204
|
+
Effect.gen(function* () {
|
|
205
|
+
const apiKey = yield* TypeSafeApiKey;
|
|
206
|
+
return yield* apiKey.store(value);
|
|
207
|
+
}),
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Delete demur's TypeSafe API key from the operating system credential store.
|
|
31
213
|
*
|
|
32
|
-
* @returns
|
|
214
|
+
* @returns Whether a stored credential existed
|
|
33
215
|
*/
|
|
34
|
-
export function
|
|
35
|
-
return
|
|
216
|
+
export function deleteApiKey(): Promise<boolean> {
|
|
217
|
+
return runtime.runPromise(
|
|
218
|
+
Effect.gen(function* () {
|
|
219
|
+
const apiKey = yield* TypeSafeApiKey;
|
|
220
|
+
return yield* apiKey.remove;
|
|
221
|
+
}),
|
|
222
|
+
);
|
|
36
223
|
}
|
|
37
224
|
|
|
38
225
|
/**
|
|
@@ -42,4 +229,18 @@ export function resolveApiKey(): string | undefined {
|
|
|
42
229
|
* name the fix, or the failure reads like a policy decision.
|
|
43
230
|
*/
|
|
44
231
|
export const MISSING_KEY_HELP =
|
|
45
|
-
"No
|
|
232
|
+
"No TypeSafe API key configured. Run `demur auth login`, set TYPESAFE_API_KEY, or inject it with a secret manager.";
|
|
233
|
+
|
|
234
|
+
function normalizeApiKey(value: string | undefined): string | undefined {
|
|
235
|
+
return value?.trim() || undefined;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function secretStoreFailure(
|
|
239
|
+
operation: SecretStoreError["operation"],
|
|
240
|
+
): (cause: unknown) => SecretStoreError {
|
|
241
|
+
return (cause) =>
|
|
242
|
+
new SecretStoreError({
|
|
243
|
+
operation,
|
|
244
|
+
detail: Predicate.isError(cause) ? cause.message : String(cause),
|
|
245
|
+
});
|
|
246
|
+
}
|