@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
|
@@ -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.";
|