@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.
@@ -0,0 +1,164 @@
1
+ import {
2
+ Cause,
3
+ Clock,
4
+ Effect,
5
+ Exit,
6
+ Predicate,
7
+ Result,
8
+ } from "effect";
9
+ import { judgeEffect, Judgment } from "./judge.ts";
10
+ import { Environment } from "./key.ts";
11
+ import { applyStaticGate, decide } from "./policy.ts";
12
+ import { gatherStateEffect, GitCommand } from "./state.ts";
13
+ import type { CommandState, Host, Verdict } from "./types.ts";
14
+
15
+ const PREFIX = "demur:";
16
+
17
+ /**
18
+ * Effect-native guard implementation used by the Promise boundary.
19
+ *
20
+ * @param command - The shell command the agent wants to run
21
+ * @param cwd - Absolute working directory for the command
22
+ * @param agent - Which coding agent is asking
23
+ * @returns A fail-closed verdict
24
+ */
25
+ export const guardEffect = Effect.fn("guardEffect")(function* (
26
+ command: string,
27
+ cwd: string,
28
+ agent: Host,
29
+ ): Effect.fn.Return<Verdict, never, Environment | GitCommand | Judgment> {
30
+ const started = yield* Clock.monotonicTimeNanos;
31
+ const core = Effect.gen(function* () {
32
+ const environment = yield* Environment;
33
+ const disabled = isDisabled(yield* environment.get("DEMUR_DISABLE"));
34
+
35
+ if (disabled) {
36
+ return yield* completeVerdict(started, {
37
+ ...emptyEvidence,
38
+ decision: "allow",
39
+ reason: `${PREFIX} disabled via DEMUR_DISABLE.`,
40
+ });
41
+ }
42
+
43
+ if (command.trim() === "") {
44
+ return yield* completeVerdict(started, {
45
+ ...emptyEvidence,
46
+ decision: "allow",
47
+ reason: `${PREFIX} empty command.`,
48
+ });
49
+ }
50
+
51
+ const state = yield* gatherStateEffect(command, cwd, agent);
52
+ return yield* judgeStateCore(state, started, 0);
53
+ });
54
+
55
+ const exit = yield* Effect.exit(core);
56
+ if (Exit.isSuccess(exit)) return exit.value;
57
+
58
+ return yield* unexpectedVerdict(started, exit.cause, 0);
59
+ });
60
+
61
+ /**
62
+ * Effect-native judgment for state that has already been collected.
63
+ *
64
+ * @param state - Pre-collected command state
65
+ * @param latencyOffsetMs - Time already spent before entering this Effect
66
+ * @returns A fail-closed verdict
67
+ */
68
+ export const judgeStateEffect = Effect.fn("judgeStateEffect")(function* (
69
+ state: CommandState,
70
+ latencyOffsetMs = 0,
71
+ ): Effect.fn.Return<Verdict, never, Judgment> {
72
+ const started = yield* Clock.monotonicTimeNanos;
73
+ const exit = yield* Effect.exit(
74
+ judgeStateCore(state, started, latencyOffsetMs),
75
+ );
76
+ if (Exit.isSuccess(exit)) return exit.value;
77
+
78
+ return yield* unexpectedVerdict(started, exit.cause, latencyOffsetMs);
79
+ });
80
+
81
+ const judgeStateCore = Effect.fn("judgeStateCore")(function* (
82
+ state: CommandState,
83
+ started: bigint,
84
+ latencyOffsetMs: number,
85
+ ): Effect.fn.Return<Verdict, never, Judgment> {
86
+ const result = yield* Effect.result(judgeEffect(state));
87
+
88
+ if (Result.isFailure(result)) {
89
+ return yield* completeVerdict(
90
+ started,
91
+ {
92
+ ...emptyEvidence,
93
+ decision: "deny",
94
+ reason: `${PREFIX} guard unavailable (${result.failure.failure}) — ${result.failure.detail} Blocking because demur fails closed. Set DEMUR_DISABLE=1 to bypass.`,
95
+ failure: result.failure.failure,
96
+ },
97
+ latencyOffsetMs,
98
+ );
99
+ }
100
+
101
+ const success = result.success;
102
+ const outcome = applyStaticGate(
103
+ decide(success.judgments),
104
+ state.analysis,
105
+ success.judgments,
106
+ );
107
+
108
+ return yield* completeVerdict(
109
+ started,
110
+ {
111
+ decision: outcome.decision,
112
+ reason: `${PREFIX} ${outcome.reason}`,
113
+ judgments: success.judgments,
114
+ failure: undefined,
115
+ usage: success.usage,
116
+ },
117
+ latencyOffsetMs,
118
+ );
119
+ });
120
+
121
+ const emptyEvidence = {
122
+ judgments: undefined,
123
+ failure: undefined,
124
+ usage: undefined,
125
+ } as const;
126
+
127
+ type VerdictWithoutLatency = Omit<Verdict, "latencyMs">;
128
+
129
+ function completeVerdict(
130
+ started: bigint,
131
+ verdict: VerdictWithoutLatency,
132
+ latencyOffsetMs = 0,
133
+ ): Effect.Effect<Verdict> {
134
+ return Effect.map(Clock.monotonicTimeNanos, (finished) => ({
135
+ ...verdict,
136
+ latencyMs:
137
+ latencyOffsetMs + Number((finished - started) / BigInt(1_000_000)),
138
+ }));
139
+ }
140
+
141
+ function unexpectedVerdict(
142
+ started: bigint,
143
+ cause: Cause.Cause<unknown>,
144
+ latencyOffsetMs: number,
145
+ ): Effect.Effect<Verdict> {
146
+ const error = Cause.squash(cause);
147
+ const detail = Predicate.isError(error) ? error.message : String(error);
148
+
149
+ return completeVerdict(
150
+ started,
151
+ {
152
+ ...emptyEvidence,
153
+ decision: "deny",
154
+ reason: `${PREFIX} guard crashed — ${detail} Blocking because demur fails closed. Set DEMUR_DISABLE=1 to bypass.`,
155
+ failure: "unexpected",
156
+ },
157
+ latencyOffsetMs,
158
+ );
159
+ }
160
+
161
+ function isDisabled(flag: string | undefined): boolean {
162
+ const normalized = flag?.trim();
163
+ return normalized === "1" || normalized === "true";
164
+ }
package/src/guard.ts ADDED
@@ -0,0 +1,84 @@
1
+ import { Layer, ManagedRuntime, Predicate } from "effect";
2
+ import { guardEffect, judgeStateEffect } from "./guard.internal.ts";
3
+ import { Judgment } from "./judge.ts";
4
+ import { Environment } from "./key.ts";
5
+ import { GitCommand } from "./state.ts";
6
+ import type { CommandState, Host, Verdict } from "./types.ts";
7
+
8
+ const PREFIX = "demur:";
9
+
10
+ const runtime = ManagedRuntime.make(
11
+ Layer.mergeAll(Environment.layer, GitCommand.layer, Judgment.layer),
12
+ );
13
+
14
+ /**
15
+ * Judge one command and decide what the host should do with it.
16
+ *
17
+ * Fails closed: any path that does not produce a judgment returns `deny`, with
18
+ * a reason that names the failure so an outage is never mistaken for a policy
19
+ * decision. This Promise is the compatibility boundary around the Effect
20
+ * implementation.
21
+ *
22
+ * @param command - The shell command the agent wants to run
23
+ * @param cwd - Absolute working directory for the command
24
+ * @param agent - Which coding agent is asking
25
+ * @param signal - Optional cancellation signal from the host
26
+ * @returns The verdict, including the judgments behind it
27
+ */
28
+ export function guard(
29
+ command: string,
30
+ cwd: string,
31
+ agent: Host,
32
+ signal: AbortSignal | undefined = undefined,
33
+ ): Promise<Verdict> {
34
+ const started = performance.now();
35
+
36
+ return runtime
37
+ .runPromise(
38
+ guardEffect(command, cwd, agent),
39
+ signal === undefined ? undefined : { signal },
40
+ )
41
+ .catch((error: unknown) => unexpectedVerdict(error, started));
42
+ }
43
+
44
+ /**
45
+ * Judge a command from state that has already been collected.
46
+ *
47
+ * Separate from {@link guard} so the eval harness can hold context fixed across
48
+ * a corpus run instead of picking up whatever repository it happens to run in.
49
+ *
50
+ * @param state - Pre-collected command state
51
+ * @param signal - Optional cancellation signal from the host
52
+ * @param startedAt - `performance.now()` reading to measure latency from
53
+ * @returns The verdict, including the judgments behind it
54
+ */
55
+ export function judgeState(
56
+ state: CommandState,
57
+ signal: AbortSignal | undefined = undefined,
58
+ startedAt: number | undefined = undefined,
59
+ ): Promise<Verdict> {
60
+ const enteredAt = performance.now();
61
+ const latencyOffsetMs =
62
+ startedAt === undefined ? 0 : Math.max(0, Math.round(enteredAt - startedAt));
63
+
64
+ return runtime
65
+ .runPromise(
66
+ judgeStateEffect(state, latencyOffsetMs),
67
+ signal === undefined ? undefined : { signal },
68
+ )
69
+ .catch((error: unknown) =>
70
+ unexpectedVerdict(error, startedAt ?? enteredAt),
71
+ );
72
+ }
73
+
74
+ function unexpectedVerdict(error: unknown, startedAt: number): Verdict {
75
+ const detail = Predicate.isError(error) ? error.message : String(error);
76
+ return {
77
+ decision: "deny",
78
+ reason: `${PREFIX} guard crashed — ${detail} Blocking because demur fails closed. Set DEMUR_DISABLE=1 to bypass.`,
79
+ judgments: undefined,
80
+ failure: "unexpected",
81
+ usage: undefined,
82
+ latencyMs: Math.round(performance.now() - startedAt),
83
+ };
84
+ }
package/src/judge.ts ADDED
@@ -0,0 +1,233 @@
1
+ import { TypeSafeClient, TypeSafeDecisionModel } from "@effect/ai-typesafe";
2
+ import {
3
+ Cause,
4
+ Context,
5
+ Effect,
6
+ Layer,
7
+ ManagedRuntime,
8
+ Predicate,
9
+ Redacted,
10
+ Result,
11
+ Schema,
12
+ } from "effect";
13
+ import { AiError, DecisionModel } from "effect/unstable/ai";
14
+ import { FetchHttpClient } from "effect/unstable/http";
15
+ import { Environment, MISSING_KEY_HELP } from "./key.ts";
16
+ import { COMMAND_JUDGMENTS } from "./questions.ts";
17
+ import { renderState } from "./state.ts";
18
+ import type { CommandState, FailureKind, Judgments } from "./types.ts";
19
+
20
+ /**
21
+ * Default per-attempt timeout for the judgment call.
22
+ *
23
+ * Override with `DEMUR_TIMEOUT_MS`. This sits in front of every Bash call the
24
+ * agent makes, so it is a latency budget, not a generosity setting.
25
+ */
26
+ const DEFAULT_TIMEOUT_MS = 4000;
27
+
28
+ const TYPESAFE_MODEL = "jev-latest";
29
+
30
+ /**
31
+ * Build the TypeSafe-backed Effect decision model for one API key.
32
+ *
33
+ * @param apiKey - TypeSafe API key to redact and attach to requests
34
+ * @returns A complete decision-model layer with its HTTP dependency provided
35
+ */
36
+ export function makeDecisionModelLayer(apiKey: string) {
37
+ return TypeSafeDecisionModel.layer({ model: TYPESAFE_MODEL }).pipe(
38
+ Layer.provide(TypeSafeClient.layer({ apiKey: Redacted.make(apiKey) })),
39
+ Layer.provide(FetchHttpClient.layer),
40
+ );
41
+ }
42
+
43
+ /**
44
+ * A completed judgment, with the token cost of producing it.
45
+ */
46
+ export type JudgeSuccess = {
47
+ ok: true;
48
+ judgments: Judgments;
49
+ usage: { inputTokens: number; outputTokens: number };
50
+ };
51
+
52
+ /**
53
+ * A judgment that could not be produced, and why.
54
+ */
55
+ export type JudgeFailure = {
56
+ ok: false;
57
+ failure: FailureKind;
58
+ detail: string;
59
+ };
60
+
61
+ /**
62
+ * The outcome of asking the model about a command.
63
+ */
64
+ export type JudgeResult = JudgeSuccess | JudgeFailure;
65
+
66
+ /**
67
+ * Typed failure raised while obtaining a System One judgment.
68
+ */
69
+ export class JudgmentError extends Schema.TaggedError<JudgmentError>()(
70
+ "JudgmentError",
71
+ {
72
+ failure: Schema.Literals([
73
+ "no-api-key",
74
+ "timeout",
75
+ "api-error",
76
+ "unexpected",
77
+ ]),
78
+ detail: Schema.String,
79
+ },
80
+ ) {}
81
+
82
+ /**
83
+ * Effect service that obtains raw judgments for command state.
84
+ */
85
+ export class Judgment extends Context.Service<
86
+ Judgment,
87
+ {
88
+ judge(state: CommandState): Effect.Effect<JudgeSuccess, JudgmentError>;
89
+ }
90
+ >()("demur/judge/Judgment") {
91
+ static readonly layerNoDeps = Layer.effect(
92
+ Judgment,
93
+ Effect.gen(function* () {
94
+ const environment = yield* Environment;
95
+ let decisionLayer: ReturnType<typeof makeDecisionModelLayer> | undefined;
96
+ let timeoutMs = DEFAULT_TIMEOUT_MS;
97
+
98
+ const getDecisionLayer = Effect.fn("Judgment.getDecisionLayer")(
99
+ function* (): Effect.fn.Return<
100
+ ReturnType<typeof makeDecisionModelLayer>,
101
+ JudgmentError
102
+ > {
103
+ if (decisionLayer !== undefined) return decisionLayer;
104
+
105
+ const apiKey =
106
+ (yield* environment.get("TYPESAFE_API_KEY"))?.trim() || undefined;
107
+ if (apiKey === undefined) {
108
+ return yield* new JudgmentError({
109
+ failure: "no-api-key",
110
+ detail: MISSING_KEY_HELP,
111
+ });
112
+ }
113
+
114
+ const configuredTimeout = Number(
115
+ yield* environment.get("DEMUR_TIMEOUT_MS"),
116
+ );
117
+ timeoutMs = configuredTimeout || DEFAULT_TIMEOUT_MS;
118
+ decisionLayer = makeDecisionModelLayer(apiKey);
119
+ return decisionLayer;
120
+ },
121
+ );
122
+
123
+ const judge = Effect.fn("Judgment.judge")(function* (
124
+ state: CommandState,
125
+ ): Effect.fn.Return<JudgeSuccess, JudgmentError> {
126
+ const activeDecisionLayer = yield* getDecisionLayer();
127
+ const result = yield* DecisionModel.decide(COMMAND_JUDGMENTS, {
128
+ input: renderState(state),
129
+ }).pipe(
130
+ Effect.provide(activeDecisionLayer),
131
+ Effect.timeout(timeoutMs),
132
+ // One retry only. A guard that retries three times with backoff is a
133
+ // guard that hangs the agent for ten seconds on a bad network.
134
+ Effect.retry({ times: 1, while: isRetryableDecisionError }),
135
+ Effect.mapError(toJudgmentError),
136
+ );
137
+ const answers = result.answers;
138
+
139
+ return {
140
+ ok: true,
141
+ judgments: {
142
+ executesDestruction: answers.executesDestruction.probability,
143
+ unrecoverable: answers.unrecoverable.probability,
144
+ targetsSharedInfrastructure:
145
+ answers.targetsSharedInfrastructure.probability,
146
+ blastRadius: answers.blastRadius.rating,
147
+ blastRadiusConfidence: answers.blastRadius.confidence ?? 0,
148
+ },
149
+ usage: {
150
+ inputTokens: result.usage.inputTokens ?? 0,
151
+ outputTokens: result.usage.outputTokens ?? 0,
152
+ },
153
+ };
154
+ });
155
+
156
+ return Judgment.of({ judge });
157
+ }),
158
+ );
159
+
160
+ static readonly layer = this.layerNoDeps.pipe(
161
+ Layer.provide(Environment.layer),
162
+ );
163
+ }
164
+
165
+ /**
166
+ * Ask the configured judgment service about one command.
167
+ *
168
+ * @param state - The command and its surrounding context
169
+ * @returns The judgments, or a typed failure in the Effect error channel
170
+ */
171
+ export const judgeEffect = Effect.fn("judgeEffect")(function* (
172
+ state: CommandState,
173
+ ): Effect.fn.Return<JudgeSuccess, JudgmentError, Judgment> {
174
+ const judgment = yield* Judgment;
175
+ return yield* judgment.judge(state);
176
+ });
177
+
178
+ const runtime = ManagedRuntime.make(Judgment.layer);
179
+
180
+ /**
181
+ * Ask System One every question about one command, in a single call.
182
+ *
183
+ * This Promise API is retained for existing callers; the implementation runs
184
+ * the Effect-native judgment service through a managed runtime.
185
+ *
186
+ * @param state - The command and its surrounding context
187
+ * @param signal - Optional cancellation signal from the host
188
+ * @returns The judgments, or a typed failure for the caller to act on
189
+ */
190
+ export function judge(
191
+ state: CommandState,
192
+ signal: AbortSignal | undefined = undefined,
193
+ ): Promise<JudgeResult> {
194
+ const program = Effect.gen(function* () {
195
+ const result = yield* Effect.result(judgeEffect(state));
196
+ if (Result.isFailure(result)) {
197
+ return {
198
+ ok: false,
199
+ failure: result.failure.failure,
200
+ detail: result.failure.detail,
201
+ } satisfies JudgeFailure;
202
+ }
203
+ return result.success;
204
+ });
205
+
206
+ return runtime
207
+ .runPromise(program, signal === undefined ? undefined : { signal })
208
+ .catch((error: unknown) => ({
209
+ ok: false,
210
+ failure: "unexpected",
211
+ detail: errorDetail(error),
212
+ }));
213
+ }
214
+
215
+ function isRetryableDecisionError(
216
+ error: AiError.AiError | Cause.TimeoutError,
217
+ ): boolean {
218
+ return Cause.isTimeoutError(error) || error.isRetryable;
219
+ }
220
+
221
+ function toJudgmentError(error: unknown): JudgmentError {
222
+ const failure: FailureKind = Cause.isTimeoutError(error)
223
+ ? "timeout"
224
+ : AiError.isAiError(error)
225
+ ? "api-error"
226
+ : "unexpected";
227
+
228
+ return new JudgmentError({ failure, detail: errorDetail(error) });
229
+ }
230
+
231
+ function errorDetail(error: unknown): string {
232
+ return Predicate.isError(error) ? error.message : String(error);
233
+ }
package/src/key.ts ADDED
@@ -0,0 +1,45 @@
1
+ import { Context, Effect, Layer } from "effect";
2
+
3
+ /**
4
+ * Effect service for reading process environment variables.
5
+ *
6
+ * Keeping environment access behind a service lets guard tests supply a
7
+ * complete, deterministic process context without mutating global state.
8
+ */
9
+ export class Environment extends Context.Service<
10
+ Environment,
11
+ {
12
+ get(name: string): Effect.Effect<string | undefined>;
13
+ }
14
+ >()("demur/key/Environment") {
15
+ static readonly layer = Layer.succeed(
16
+ Environment,
17
+ Environment.of({
18
+ get: Effect.fn("Environment.get")(function* (name: string) {
19
+ return Bun.env[name];
20
+ }),
21
+ }),
22
+ );
23
+ }
24
+
25
+ /**
26
+ * Resolve the TypeSafe API key from the environment.
27
+ *
28
+ * demur does not fetch or persist credentials. Export `TYPESAFE_API_KEY` before
29
+ * launching the host agent, or inject it with a secret manager so every hook
30
+ * invocation inherits the already-resolved value.
31
+ *
32
+ * @returns The API key, or `undefined` when the environment does not carry one
33
+ */
34
+ export function resolveApiKey(): string | undefined {
35
+ return Bun.env.TYPESAFE_API_KEY?.trim() || undefined;
36
+ }
37
+
38
+ /**
39
+ * What to tell the user when the key is missing.
40
+ *
41
+ * demur fails closed, so a missing key blocks every command. The message has to
42
+ * name the fix, or the failure reads like a policy decision.
43
+ */
44
+ export const MISSING_KEY_HELP =
45
+ "No TYPESAFE_API_KEY in the environment. Export it, or inject it with a secret manager, before launching the agent.";