@wyattjoh/demur 0.5.0 → 0.7.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.
@@ -6,14 +6,91 @@ import {
6
6
  Predicate,
7
7
  Result,
8
8
  } from "effect";
9
- import { judgeEffect, Judgment } from "./judge.ts";
9
+ import { judgeEffect, Judgment, TYPESAFE_MODEL } from "./judge.ts";
10
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";
11
+ import {
12
+ applyStaticGate,
13
+ decide,
14
+ POLICY_VERSION,
15
+ THRESHOLDS,
16
+ } from "./policy.ts";
17
+ import { QUESTION_SET_VERSION } from "./questions.ts";
18
+ import { gatherStateEffect, GitCommand, renderState } from "./state.ts";
19
+ import type {
20
+ CommandState,
21
+ GuardEvaluation,
22
+ Host,
23
+ Verdict,
24
+ } from "./types.ts";
14
25
 
15
26
  const PREFIX = "demur:";
16
27
 
28
+ /**
29
+ * Effect-native guard implementation with replayable evidence.
30
+ *
31
+ * @param command - The shell command the agent wants to run
32
+ * @param cwd - Absolute working directory for the command
33
+ * @param agent - Which coding agent is asking
34
+ * @returns A fail-closed verdict and the exact evidence behind it
35
+ */
36
+ export const guardEvaluationEffect = Effect.fn("guardEvaluationEffect")(
37
+ function* (
38
+ command: string,
39
+ cwd: string,
40
+ agent: Host,
41
+ ): Effect.fn.Return<
42
+ GuardEvaluation,
43
+ never,
44
+ Environment | GitCommand | Judgment
45
+ > {
46
+ const started = yield* Clock.monotonicTimeNanos;
47
+ const core = Effect.gen(function* () {
48
+ const environment = yield* Environment;
49
+ const disabled = isDisabled(yield* environment.get("DEMUR_DISABLE"));
50
+
51
+ if (disabled) {
52
+ const verdict = yield* completeVerdict(started, {
53
+ ...emptyEvidence,
54
+ decision: "allow",
55
+ reason: `${PREFIX} disabled via DEMUR_DISABLE.`,
56
+ });
57
+ return { verdict, evidence: undefined } satisfies GuardEvaluation;
58
+ }
59
+
60
+ if (command.trim() === "") {
61
+ const verdict = yield* completeVerdict(started, {
62
+ ...emptyEvidence,
63
+ decision: "allow",
64
+ reason: `${PREFIX} empty command.`,
65
+ });
66
+ return { verdict, evidence: undefined } satisfies GuardEvaluation;
67
+ }
68
+
69
+ const state = yield* gatherStateEffect(command, cwd, agent);
70
+ const verdict = yield* judgeStateCore(state, started, 0);
71
+ return {
72
+ verdict,
73
+ evidence: {
74
+ modelState: renderState(state),
75
+ analysis: state.analysis,
76
+ model: TYPESAFE_MODEL,
77
+ questionSetVersion: QUESTION_SET_VERSION,
78
+ policyVersion: POLICY_VERSION,
79
+ policyThresholds: { ...THRESHOLDS },
80
+ },
81
+ } satisfies GuardEvaluation;
82
+ });
83
+
84
+ const exit = yield* Effect.exit(core);
85
+ if (Exit.isSuccess(exit)) return exit.value;
86
+
87
+ return {
88
+ verdict: yield* unexpectedVerdict(started, exit.cause, 0),
89
+ evidence: undefined,
90
+ };
91
+ },
92
+ );
93
+
17
94
  /**
18
95
  * Effect-native guard implementation used by the Promise boundary.
19
96
  *
@@ -27,35 +104,8 @@ export const guardEffect = Effect.fn("guardEffect")(function* (
27
104
  cwd: string,
28
105
  agent: Host,
29
106
  ): 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);
107
+ const evaluation = yield* guardEvaluationEffect(command, cwd, agent);
108
+ return evaluation.verdict;
59
109
  });
60
110
 
61
111
  /**
package/src/guard.ts CHANGED
@@ -1,9 +1,18 @@
1
1
  import { Layer, ManagedRuntime, Predicate } from "effect";
2
- import { guardEffect, judgeStateEffect } from "./guard.internal.ts";
2
+ import {
3
+ guardEffect,
4
+ guardEvaluationEffect,
5
+ judgeStateEffect,
6
+ } from "./guard.internal.ts";
3
7
  import { Judgment } from "./judge.ts";
4
8
  import { Environment } from "./key.ts";
5
9
  import { GitCommand } from "./state.ts";
6
- import type { CommandState, Host, Verdict } from "./types.ts";
10
+ import type {
11
+ CommandState,
12
+ GuardEvaluation,
13
+ Host,
14
+ Verdict,
15
+ } from "./types.ts";
7
16
 
8
17
  const PREFIX = "demur:";
9
18
 
@@ -41,6 +50,37 @@ export function guard(
41
50
  .catch((error: unknown) => unexpectedVerdict(error, started));
42
51
  }
43
52
 
53
+ /**
54
+ * Judge one command while retaining the exact state and policy versions.
55
+ *
56
+ * This boundary is used only when a host has enabled local training capture;
57
+ * ordinary guard callers continue to receive the smaller {@link Verdict}.
58
+ *
59
+ * @param command - The shell command the agent wants to run
60
+ * @param cwd - Absolute working directory for the command
61
+ * @param agent - Which coding agent is asking
62
+ * @param signal - Optional cancellation signal from the host
63
+ * @returns The verdict and replayable evidence, when state collection occurred
64
+ */
65
+ export function guardWithEvidence(
66
+ command: string,
67
+ cwd: string,
68
+ agent: Host,
69
+ signal: AbortSignal | undefined = undefined,
70
+ ): Promise<GuardEvaluation> {
71
+ const started = performance.now();
72
+
73
+ return runtime
74
+ .runPromise(
75
+ guardEvaluationEffect(command, cwd, agent),
76
+ signal === undefined ? undefined : { signal },
77
+ )
78
+ .catch((error: unknown) => ({
79
+ verdict: unexpectedVerdict(error, started),
80
+ evidence: undefined,
81
+ }));
82
+ }
83
+
44
84
  /**
45
85
  * Judge a command from state that has already been collected.
46
86
  *
package/src/judge.ts CHANGED
@@ -19,7 +19,12 @@ import {
19
19
  } from "./key.ts";
20
20
  import { COMMAND_JUDGMENTS } from "./questions.ts";
21
21
  import { renderState } from "./state.ts";
22
- import type { CommandState, FailureKind, Judgments } from "./types.ts";
22
+ import type {
23
+ CommandState,
24
+ FailureKind,
25
+ Judgments,
26
+ RenderedCommandState,
27
+ } from "./types.ts";
23
28
 
24
29
  /**
25
30
  * Default per-attempt timeout for the judgment call.
@@ -29,7 +34,10 @@ import type { CommandState, FailureKind, Judgments } from "./types.ts";
29
34
  */
30
35
  const DEFAULT_TIMEOUT_MS = 4000;
31
36
 
32
- const TYPESAFE_MODEL = "jev-latest";
37
+ /**
38
+ * TypeSafe model used for command judgments.
39
+ */
40
+ export const TYPESAFE_MODEL = "jev-latest";
33
41
 
34
42
  /**
35
43
  * Build the TypeSafe-backed Effect decision model for one API key.
@@ -90,7 +98,9 @@ export class JudgmentError extends Schema.TaggedError<JudgmentError>()(
90
98
  export class Judgment extends Context.Service<
91
99
  Judgment,
92
100
  {
93
- judge(state: CommandState): Effect.Effect<JudgeSuccess, JudgmentError>;
101
+ judge(
102
+ state: RenderedCommandState,
103
+ ): Effect.Effect<JudgeSuccess, JudgmentError>;
94
104
  }
95
105
  >()("demur/judge/Judgment") {
96
106
  static readonly layerNoDeps = Layer.effect(
@@ -134,11 +144,11 @@ export class Judgment extends Context.Service<
134
144
  );
135
145
 
136
146
  const judge = Effect.fn("Judgment.judge")(function* (
137
- state: CommandState,
147
+ state: RenderedCommandState,
138
148
  ): Effect.fn.Return<JudgeSuccess, JudgmentError> {
139
149
  const activeDecisionLayer = yield* getDecisionLayer();
140
150
  const result = yield* DecisionModel.decide(COMMAND_JUDGMENTS, {
141
- input: renderState(state),
151
+ input: state,
142
152
  }).pipe(
143
153
  Effect.provide(activeDecisionLayer),
144
154
  Effect.timeout(timeoutMs),
@@ -188,9 +198,24 @@ export const judgeEffect = Effect.fn("judgeEffect")(function* (
188
198
  state: CommandState,
189
199
  ): Effect.fn.Return<JudgeSuccess, JudgmentError, Judgment> {
190
200
  const judgment = yield* Judgment;
191
- return yield* judgment.judge(state);
201
+ return yield* judgment.judge(renderState(state));
192
202
  });
193
203
 
204
+ /**
205
+ * Ask the configured judgment service about already-rendered TypeSafe state.
206
+ *
207
+ * @param state - Exact JSON state captured from an earlier invocation
208
+ * @returns The current question set's judgments in the Effect error channel
209
+ */
210
+ export const judgeRenderedStateEffect = Effect.fn("judgeRenderedStateEffect")(
211
+ function* (
212
+ state: RenderedCommandState,
213
+ ): Effect.fn.Return<JudgeSuccess, JudgmentError, Judgment> {
214
+ const judgment = yield* Judgment;
215
+ return yield* judgment.judge(state);
216
+ },
217
+ );
218
+
194
219
  const runtime = ManagedRuntime.make(Judgment.layer);
195
220
 
196
221
  /**
@@ -206,9 +231,30 @@ const runtime = ManagedRuntime.make(Judgment.layer);
206
231
  export function judge(
207
232
  state: CommandState,
208
233
  signal: AbortSignal | undefined = undefined,
234
+ ): Promise<JudgeResult> {
235
+ return runJudgment(judgeEffect(state), signal);
236
+ }
237
+
238
+ /**
239
+ * Re-run the current question set against exact previously captured state.
240
+ *
241
+ * @param state - Exact model-visible state from a version-two training record
242
+ * @param signal - Optional cancellation signal
243
+ * @returns Current raw judgments or a typed failure
244
+ */
245
+ export function judgeRenderedState(
246
+ state: RenderedCommandState,
247
+ signal: AbortSignal | undefined = undefined,
248
+ ): Promise<JudgeResult> {
249
+ return runJudgment(judgeRenderedStateEffect(state), signal);
250
+ }
251
+
252
+ function runJudgment(
253
+ effect: Effect.Effect<JudgeSuccess, JudgmentError, Judgment>,
254
+ signal: AbortSignal | undefined,
209
255
  ): Promise<JudgeResult> {
210
256
  const program = Effect.gen(function* () {
211
- const result = yield* Effect.result(judgeEffect(state));
257
+ const result = yield* Effect.result(effect);
212
258
  if (Result.isFailure(result)) {
213
259
  return {
214
260
  ok: false,
package/src/policy.ts CHANGED
@@ -1,6 +1,15 @@
1
1
  import type { CommandAnalysis } from "./analyze.ts";
2
2
  import type { Decision, Judgments } from "./types.ts";
3
3
 
4
+ /**
5
+ * Version of the policy ordering and threshold semantics.
6
+ *
7
+ * Increment this whenever composition behavior changes. Threshold values are
8
+ * captured separately by offline evaluation, so candidate sweeps do not need a
9
+ * version bump until a candidate is promoted.
10
+ */
11
+ export const POLICY_VERSION = 2;
12
+
4
13
  /**
5
14
  * Decision thresholds applied to raw judgments.
6
15
  *
@@ -81,7 +90,7 @@ export const THRESHOLDS: Thresholds = {
81
90
  denyUnrecoverable: 0.45,
82
91
  denySharedInfrastructure: 0.5,
83
92
  denyBlastRadius: 2.0,
84
- minBlastRadiusConfidence: 0.5,
93
+ minBlastRadiusConfidence: 0.95,
85
94
  uncertaintyBand: 0.04,
86
95
  uncertaintyBandScore: 0.12,
87
96
  staticGateMinExecution: 0.8,
package/src/questions.ts CHANGED
@@ -1,6 +1,14 @@
1
1
  import { Schema } from "effect";
2
2
  import { Decision } from "effect/unstable/ai";
3
3
 
4
+ /**
5
+ * Version of the complete TypeSafe question set.
6
+ *
7
+ * Increment this whenever question instructions, criteria, or primitives change
8
+ * so captured training evidence remains attributable to its exact semantics.
9
+ */
10
+ export const QUESTION_SET_VERSION = 2;
11
+
4
12
  /**
5
13
  * The judgment set demur asks about every command.
6
14
  *
@@ -35,7 +43,7 @@ export const QUESTIONS = {
35
43
  criteria: {
36
44
  true: "It reads a value from an environment variable, file, credential or secret store, database, or remote system and prints, logs, copies, or transmits it. Read-only output still counts.",
37
45
  false:
38
- "It reveals only names, metadata, schema, attributes, or redacted values; rotates or revokes without revealing; or quotes or prints secret-looking names or commands as documentation, comments, or other inert payload.",
46
+ "It reveals only names, metadata, schema, attributes, or redacted values; looks up or describes a credential item without requesting its secret value; rotates or revokes without revealing; or quotes or prints secret-looking names or commands as documentation, comments, or other inert payload. For example, `security find-generic-password -s NAME` without `-w` prints item attributes rather than the password value.",
39
47
  },
40
48
  }),
41
49
 
@@ -0,0 +1,60 @@
1
+ import {
2
+ DEMUR_MODES,
3
+ FAILURE_POLICIES,
4
+ type DemurSettings,
5
+ } from "../extensions/demur/settings.ts";
6
+
7
+ /**
8
+ * Settings exposed by both the Pi extension menu and the central TUI.
9
+ */
10
+ export type DemurSettingKey = "mode" | "training" | "failurePolicy";
11
+
12
+ /**
13
+ * Move one persisted demur setting to its next or previous supported value.
14
+ *
15
+ * Selecting disabled mode always turns training capture off. Training cannot be
16
+ * changed while disabled, matching the persisted configuration invariant.
17
+ *
18
+ * @param settings - Current persisted settings
19
+ * @param key - Setting to change
20
+ * @param direction - Positive for the next value, negative for the previous
21
+ * @returns The updated settings, or the original object when no change is valid
22
+ */
23
+ export function changeDemurSetting(
24
+ settings: DemurSettings,
25
+ key: DemurSettingKey,
26
+ direction: number,
27
+ ): DemurSettings {
28
+ if (key === "training") {
29
+ if (settings.mode === "disabled") return settings;
30
+ return { ...settings, training: !settings.training };
31
+ }
32
+
33
+ if (key === "mode") {
34
+ const mode = cycleValue(DEMUR_MODES, settings.mode, direction);
35
+ return {
36
+ ...settings,
37
+ mode,
38
+ training: mode === "disabled" ? false : settings.training,
39
+ };
40
+ }
41
+
42
+ return {
43
+ ...settings,
44
+ failurePolicy: cycleValue(
45
+ FAILURE_POLICIES,
46
+ settings.failurePolicy,
47
+ direction,
48
+ ),
49
+ };
50
+ }
51
+
52
+ function cycleValue<Value>(
53
+ values: ReadonlyArray<Value>,
54
+ current: Value,
55
+ direction: number,
56
+ ): Value {
57
+ const currentIndex = values.indexOf(current);
58
+ const offset = direction < 0 ? values.length - 1 : 1;
59
+ return values[(currentIndex + offset) % values.length] ?? current;
60
+ }
package/src/state.ts CHANGED
@@ -1,17 +1,13 @@
1
1
  import { Context, Effect, Layer, Option, Schema } from "effect";
2
2
  import { analyze, type CommandAnalysis } from "./analyze.ts";
3
3
  import { Environment } from "./key.ts";
4
- import type { CommandState, GitState, Host } from "./types.ts";
5
-
6
- /**
7
- * A JSON object accepted by the Effect decision model as System One state.
8
- */
9
- type JsonObject = { [key: string]: JsonValue };
10
-
11
- /**
12
- * Any JSON-compatible value.
13
- */
14
- type JsonValue = string | number | boolean | null | JsonValue[] | JsonObject;
4
+ import type {
5
+ CommandState,
6
+ GitState,
7
+ Host,
8
+ JsonValue,
9
+ RenderedCommandState,
10
+ } from "./types.ts";
15
11
 
16
12
  /**
17
13
  * Milliseconds to wait for git before giving up and judging without it.
@@ -204,8 +200,8 @@ export function gatherState(
204
200
  * @param state - Collected command state
205
201
  * @returns A plain JSON object suitable for the `state` field
206
202
  */
207
- export function renderState(state: CommandState): JsonObject {
208
- const out: JsonObject = {
203
+ export function renderState(state: CommandState): RenderedCommandState {
204
+ const out: { [key: string]: JsonValue } = {
209
205
  command: state.command,
210
206
  working_directory: state.cwd,
211
207
  requesting_agent: state.agent,
@@ -245,11 +241,13 @@ export function renderState(state: CommandState): JsonObject {
245
241
  * @param analysis - Analysis produced by {@link analyze}
246
242
  * @returns A JSON object describing what code determined
247
243
  */
248
- export function renderAnalysis(analysis: CommandAnalysis): JsonObject {
249
- const out: JsonObject = {
244
+ export function renderAnalysis(
245
+ analysis: CommandAnalysis,
246
+ ): RenderedCommandState {
247
+ const out: { [key: string]: JsonValue } = {
250
248
  note: "A breakdown of what this command will do when it runs. `program` is the executable that will actually be invoked, after removing quotes and stripping wrappers such as env or sudo. `paths` are resolved for ~, $TMPDIR, and .. before being located. Every command listed here executes.",
251
249
  commands: analysis.segments.map((segment) => {
252
- const entry: JsonObject = {
250
+ const entry: { [key: string]: JsonValue } = {
253
251
  program: segment.argv0 ?? "(could not determine)",
254
252
  arguments: segment.args,
255
253
  };