@vellumai/credential-executor 0.6.4 → 0.6.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.
@@ -9,7 +9,8 @@
9
9
  "./handles": "./src/handles.ts",
10
10
  "./grants": "./src/grants.ts",
11
11
  "./rpc": "./src/rpc.ts",
12
- "./rendering": "./src/rendering.ts"
12
+ "./rendering": "./src/rendering.ts",
13
+ "./trust-rules": "./src/trust-rules.ts"
13
14
  },
14
15
  "scripts": {
15
16
  "typecheck": "bunx tsc --noEmit",
@@ -0,0 +1,471 @@
1
+ /**
2
+ * Tests for trust-rule family types and canonical parsing/normalization.
3
+ *
4
+ * Verifies:
5
+ * 1. Scoped-rule parsing preserves executionTarget, strips allowHighRisk.
6
+ * 2. Non-scoped known-tool rules strip executionTarget and allowHighRisk.
7
+ * 3. Unknown-tool rules preserve executionTarget, strip allowHighRisk.
8
+ * 4. Normalization flag behavior signals when a re-save is warranted.
9
+ * 5. parseTrustFileData handles full trust file objects.
10
+ */
11
+
12
+ import { describe, expect, test } from "bun:test";
13
+ import {
14
+ isManagedSkillRule,
15
+ isScopedRule,
16
+ isSkillLoadRule,
17
+ isUrlRule,
18
+ parseTrustFileData,
19
+ parseTrustRule,
20
+ ruleScope,
21
+ SCOPED_TOOLS,
22
+ URL_TOOLS,
23
+ MANAGED_SKILL_TOOLS,
24
+ SKILL_LOAD_TOOL,
25
+ } from "../trust-rules.js";
26
+ import type {
27
+ GenericTrustRule,
28
+ ManagedSkillTrustRule,
29
+ ScopedTrustRule,
30
+ SkillLoadTrustRule,
31
+ TrustRule,
32
+ UrlTrustRule,
33
+ } from "../trust-rules.js";
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Helpers
37
+ // ---------------------------------------------------------------------------
38
+
39
+ function makeRaw(overrides: Record<string, unknown> = {}): Record<string, unknown> {
40
+ return {
41
+ id: "test-rule-1",
42
+ tool: "bash",
43
+ pattern: "**",
44
+ scope: "everywhere",
45
+ decision: "allow",
46
+ priority: 100,
47
+ createdAt: 1700000000000,
48
+ ...overrides,
49
+ };
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Scoped-rule parsing
54
+ // ---------------------------------------------------------------------------
55
+
56
+ describe("parseTrustRule — scoped tools", () => {
57
+ test.each([...SCOPED_TOOLS])("preserves executionTarget and strips allowHighRisk for %s", (tool) => {
58
+ const raw = makeRaw({
59
+ tool,
60
+ executionTarget: "container-a",
61
+ allowHighRisk: true,
62
+ });
63
+ const { rule, normalized } = parseTrustRule(raw);
64
+ // allowHighRisk triggers normalization
65
+ expect(normalized).toBe(true);
66
+ expect(rule.tool).toBe(tool);
67
+ expect((rule as ScopedTrustRule).executionTarget).toBe("container-a");
68
+ // allowHighRisk is stripped
69
+ expect("allowHighRisk" in rule).toBe(false);
70
+ });
71
+
72
+ test("scoped rule without optional fields is not normalized", () => {
73
+ const raw = makeRaw({ tool: "host_bash" });
74
+ const { rule, normalized } = parseTrustRule(raw);
75
+ expect(normalized).toBe(false);
76
+ expect(rule.tool).toBe("host_bash");
77
+ expect("executionTarget" in rule).toBe(false);
78
+ expect("allowHighRisk" in rule).toBe(false);
79
+ });
80
+
81
+ test("type guard isScopedRule narrows correctly", () => {
82
+ const { rule } = parseTrustRule(makeRaw({ tool: "file_write" }));
83
+ expect(isScopedRule(rule)).toBe(true);
84
+ expect(isUrlRule(rule)).toBe(false);
85
+ expect(isManagedSkillRule(rule)).toBe(false);
86
+ expect(isSkillLoadRule(rule)).toBe(false);
87
+ });
88
+ });
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Non-scoped known-tool scope stripping
92
+ // ---------------------------------------------------------------------------
93
+
94
+ describe("parseTrustRule — URL tools strip invalid fields", () => {
95
+ test.each([...URL_TOOLS])(
96
+ "strips executionTarget and allowHighRisk on %s",
97
+ (tool) => {
98
+ const raw = makeRaw({
99
+ tool,
100
+ executionTarget: "should-be-stripped",
101
+ allowHighRisk: true,
102
+ });
103
+ const { rule, normalized } = parseTrustRule(raw);
104
+ expect(normalized).toBe(true);
105
+ expect(rule.tool).toBe(tool);
106
+ expect("executionTarget" in rule).toBe(false);
107
+ expect("allowHighRisk" in rule).toBe(false);
108
+ },
109
+ );
110
+
111
+ test("URL tool without invalid fields is not normalized", () => {
112
+ const raw = makeRaw({ tool: "web_fetch" });
113
+ const { rule, normalized } = parseTrustRule(raw);
114
+ expect(normalized).toBe(false);
115
+ expect(rule.tool).toBe("web_fetch");
116
+ // scope is stripped even though it was present in raw input
117
+ expect("scope" in rule).toBe(false);
118
+ });
119
+
120
+ test("URL tool with scope 'everywhere' strips scope without normalization flag", () => {
121
+ const raw = makeRaw({ tool: "web_fetch", scope: "everywhere" });
122
+ const { rule, normalized } = parseTrustRule(raw);
123
+ expect(normalized).toBe(false);
124
+ expect("scope" in rule).toBe(false);
125
+ });
126
+
127
+ test("URL tool with non-'everywhere' scope strips scope and sets normalized", () => {
128
+ const raw = makeRaw({ tool: "web_fetch", scope: "/some/dir" });
129
+ const { rule, normalized } = parseTrustRule(raw);
130
+ expect(normalized).toBe(true);
131
+ expect("scope" in rule).toBe(false);
132
+ });
133
+
134
+ test("type guard isUrlRule narrows correctly", () => {
135
+ const { rule } = parseTrustRule(makeRaw({ tool: "network_request" }));
136
+ expect(isUrlRule(rule)).toBe(true);
137
+ expect(isScopedRule(rule)).toBe(false);
138
+ });
139
+ });
140
+
141
+ describe("parseTrustRule — managed skill tools strip invalid fields", () => {
142
+ test.each([...MANAGED_SKILL_TOOLS])(
143
+ "strips executionTarget and allowHighRisk on %s",
144
+ (tool) => {
145
+ const raw = makeRaw({
146
+ tool,
147
+ executionTarget: "x",
148
+ allowHighRisk: false,
149
+ });
150
+ const { rule, normalized } = parseTrustRule(raw);
151
+ expect(normalized).toBe(true);
152
+ expect("executionTarget" in rule).toBe(false);
153
+ expect("allowHighRisk" in rule).toBe(false);
154
+ },
155
+ );
156
+
157
+ test("managed skill tool with scope 'everywhere' strips scope without normalization flag", () => {
158
+ const raw = makeRaw({ tool: "scaffold_managed_skill", scope: "everywhere" });
159
+ const { rule, normalized } = parseTrustRule(raw);
160
+ expect(normalized).toBe(false);
161
+ expect("scope" in rule).toBe(false);
162
+ });
163
+
164
+ test("managed skill tool with non-'everywhere' scope strips scope and sets normalized", () => {
165
+ const raw = makeRaw({ tool: "delete_managed_skill", scope: "/some/dir" });
166
+ const { rule, normalized } = parseTrustRule(raw);
167
+ expect(normalized).toBe(true);
168
+ expect("scope" in rule).toBe(false);
169
+ });
170
+
171
+ test("type guard isManagedSkillRule narrows correctly", () => {
172
+ const { rule } = parseTrustRule(makeRaw({ tool: "scaffold_managed_skill" }));
173
+ expect(isManagedSkillRule(rule)).toBe(true);
174
+ expect(isScopedRule(rule)).toBe(false);
175
+ });
176
+ });
177
+
178
+ describe("parseTrustRule — skill_load strips invalid fields", () => {
179
+ test("strips executionTarget and allowHighRisk on skill_load", () => {
180
+ const raw = makeRaw({
181
+ tool: SKILL_LOAD_TOOL,
182
+ executionTarget: "container-b",
183
+ allowHighRisk: true,
184
+ });
185
+ const { rule, normalized } = parseTrustRule(raw);
186
+ expect(normalized).toBe(true);
187
+ expect(rule.tool).toBe(SKILL_LOAD_TOOL);
188
+ expect("executionTarget" in rule).toBe(false);
189
+ expect("allowHighRisk" in rule).toBe(false);
190
+ });
191
+
192
+ test("skill_load without invalid fields is not normalized", () => {
193
+ const raw = makeRaw({ tool: SKILL_LOAD_TOOL, pattern: "skill_load:*" });
194
+ const { rule, normalized } = parseTrustRule(raw);
195
+ expect(normalized).toBe(false);
196
+ expect(rule.tool).toBe(SKILL_LOAD_TOOL);
197
+ expect("scope" in rule).toBe(false);
198
+ });
199
+
200
+ test("skill_load with scope 'everywhere' strips scope without normalization flag", () => {
201
+ const raw = makeRaw({ tool: SKILL_LOAD_TOOL, scope: "everywhere" });
202
+ const { rule, normalized } = parseTrustRule(raw);
203
+ expect(normalized).toBe(false);
204
+ expect("scope" in rule).toBe(false);
205
+ });
206
+
207
+ test("skill_load with non-'everywhere' scope strips scope and sets normalized", () => {
208
+ const raw = makeRaw({ tool: SKILL_LOAD_TOOL, scope: "/some/dir" });
209
+ const { rule, normalized } = parseTrustRule(raw);
210
+ expect(normalized).toBe(true);
211
+ expect("scope" in rule).toBe(false);
212
+ });
213
+
214
+ test("type guard isSkillLoadRule narrows correctly", () => {
215
+ const { rule } = parseTrustRule(makeRaw({ tool: SKILL_LOAD_TOOL }));
216
+ expect(isSkillLoadRule(rule)).toBe(true);
217
+ expect(isScopedRule(rule)).toBe(false);
218
+ });
219
+ });
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // Unknown-tool preservation
223
+ // ---------------------------------------------------------------------------
224
+
225
+ describe("parseTrustRule — unknown tools", () => {
226
+ test("preserves executionTarget but strips allowHighRisk for unknown tools", () => {
227
+ const raw = makeRaw({
228
+ tool: "future_tool_v99",
229
+ executionTarget: "edge-worker",
230
+ allowHighRisk: true,
231
+ });
232
+ const { rule, normalized } = parseTrustRule(raw);
233
+ // allowHighRisk triggers normalization
234
+ expect(normalized).toBe(true);
235
+ expect(rule.tool).toBe("future_tool_v99");
236
+ expect((rule as GenericTrustRule).executionTarget).toBe("edge-worker");
237
+ expect("allowHighRisk" in rule).toBe(false);
238
+ });
239
+
240
+ test("unknown tool without optional fields is not normalized", () => {
241
+ const raw = makeRaw({ tool: "computer_use_click" });
242
+ const { rule, normalized } = parseTrustRule(raw);
243
+ expect(normalized).toBe(false);
244
+ expect(rule.tool).toBe("computer_use_click");
245
+ expect("scope" in rule).toBe(false);
246
+ expect("executionTarget" in rule).toBe(false);
247
+ expect("allowHighRisk" in rule).toBe(false);
248
+ });
249
+
250
+ test("unknown tool with non-everywhere scope strips scope and sets normalized", () => {
251
+ const raw = makeRaw({ tool: "future_tool_v99", scope: "/some/dir" });
252
+ const { rule, normalized } = parseTrustRule(raw);
253
+ expect(normalized).toBe(true);
254
+ expect("scope" in rule).toBe(false);
255
+ });
256
+
257
+ test("all type guards return false for generic rules", () => {
258
+ const { rule } = parseTrustRule(makeRaw({ tool: "some_new_tool" }));
259
+ expect(isScopedRule(rule)).toBe(false);
260
+ expect(isUrlRule(rule)).toBe(false);
261
+ expect(isManagedSkillRule(rule)).toBe(false);
262
+ expect(isSkillLoadRule(rule)).toBe(false);
263
+ });
264
+ });
265
+
266
+ // ---------------------------------------------------------------------------
267
+ // Normalization flag behavior
268
+ // ---------------------------------------------------------------------------
269
+
270
+ describe("parseTrustRule — normalization flag", () => {
271
+ test("normalized is false when no changes needed (no allowHighRisk)", () => {
272
+ const raw = makeRaw({ tool: "host_bash" });
273
+ const { normalized } = parseTrustRule(raw);
274
+ expect(normalized).toBe(false);
275
+ });
276
+
277
+ test("normalized is true when allowHighRisk is present (stripped)", () => {
278
+ const raw = makeRaw({ tool: "host_bash", allowHighRisk: true });
279
+ const { normalized } = parseTrustRule(raw);
280
+ expect(normalized).toBe(true);
281
+ });
282
+
283
+ test("normalized is true when URL tool has allowHighRisk (stripped)", () => {
284
+ const raw = makeRaw({ tool: "web_fetch", allowHighRisk: true });
285
+ const { normalized } = parseTrustRule(raw);
286
+ expect(normalized).toBe(true);
287
+ });
288
+
289
+ test("normalized is true when decision is coerced", () => {
290
+ const raw = makeRaw({ tool: "bash", decision: "invalid_decision" });
291
+ const { rule, normalized } = parseTrustRule(raw);
292
+ expect(normalized).toBe(true);
293
+ expect(rule.decision).toBe("ask");
294
+ });
295
+
296
+ test("empty executionTarget string is not preserved on scoped rules", () => {
297
+ const raw = makeRaw({ tool: "bash", executionTarget: "" });
298
+ const { rule, normalized } = parseTrustRule(raw);
299
+ expect(normalized).toBe(false);
300
+ expect("executionTarget" in rule).toBe(false);
301
+ });
302
+ });
303
+
304
+ // ---------------------------------------------------------------------------
305
+ // ruleScope helper
306
+ // ---------------------------------------------------------------------------
307
+
308
+ describe("ruleScope", () => {
309
+ test("returns scope for scoped rules", () => {
310
+ const { rule } = parseTrustRule(makeRaw({ tool: "bash", scope: "/projects" }));
311
+ expect(ruleScope(rule)).toBe("/projects");
312
+ });
313
+
314
+ test("returns 'everywhere' for non-scoped rules without scope", () => {
315
+ const { rule } = parseTrustRule(makeRaw({ tool: "web_fetch" }));
316
+ expect("scope" in rule).toBe(false);
317
+ expect(ruleScope(rule)).toBe("everywhere");
318
+ });
319
+
320
+ test("returns 'everywhere' for generic rules (scope is stripped)", () => {
321
+ const { rule } = parseTrustRule(makeRaw({ tool: "future_tool", scope: "/custom" }));
322
+ expect("scope" in rule).toBe(false);
323
+ expect(ruleScope(rule)).toBe("everywhere");
324
+ });
325
+
326
+ test("returns 'everywhere' for scoped rules with default scope", () => {
327
+ const { rule } = parseTrustRule(makeRaw({ tool: "file_read", scope: "everywhere" }));
328
+ expect(ruleScope(rule)).toBe("everywhere");
329
+ });
330
+ });
331
+
332
+ // ---------------------------------------------------------------------------
333
+ // parseTrustFileData
334
+ // ---------------------------------------------------------------------------
335
+
336
+ describe("parseTrustFileData", () => {
337
+ test("parses a valid trust file with mixed rule families", () => {
338
+ const raw = {
339
+ version: 3,
340
+ starterBundleAccepted: true,
341
+ rules: [
342
+ makeRaw({ id: "r1", tool: "bash" }),
343
+ makeRaw({ id: "r2", tool: "web_fetch", pattern: "web_fetch:https://example.com/*" }),
344
+ makeRaw({ id: "r3", tool: "skill_load", pattern: "skill_load:*" }),
345
+ ],
346
+ };
347
+ const { data, normalized } = parseTrustFileData(raw);
348
+ expect(normalized).toBe(false);
349
+ expect(data.version).toBe(3);
350
+ expect(data.starterBundleAccepted).toBe(true);
351
+ expect(data.rules).toHaveLength(3);
352
+ expect(data.rules[0].tool).toBe("bash");
353
+ expect(data.rules[1].tool).toBe("web_fetch");
354
+ expect(data.rules[2].tool).toBe("skill_load");
355
+ });
356
+
357
+ test("reports normalized when any rule is modified", () => {
358
+ const raw = {
359
+ version: 3,
360
+ rules: [
361
+ makeRaw({ id: "r1", tool: "bash" }),
362
+ // This rule has an invalid field for its family
363
+ makeRaw({ id: "r2", tool: "web_fetch", executionTarget: "stale" }),
364
+ ],
365
+ };
366
+ const { data, normalized } = parseTrustFileData(raw);
367
+ expect(normalized).toBe(true);
368
+ expect(data.rules).toHaveLength(2);
369
+ expect("executionTarget" in data.rules[1]).toBe(false);
370
+ });
371
+
372
+ test("reports normalized when allowHighRisk is present (stripped)", () => {
373
+ const raw = {
374
+ version: 3,
375
+ rules: [
376
+ makeRaw({ id: "r1", tool: "bash", allowHighRisk: true }),
377
+ ],
378
+ };
379
+ const { data, normalized } = parseTrustFileData(raw);
380
+ expect(normalized).toBe(true);
381
+ expect(data.rules).toHaveLength(1);
382
+ expect("allowHighRisk" in data.rules[0]).toBe(false);
383
+ });
384
+
385
+ test("skips null/non-object entries and flags as normalized", () => {
386
+ const raw = {
387
+ version: 3,
388
+ rules: [null, 42, makeRaw({ id: "r1", tool: "bash" })],
389
+ };
390
+ const { data, normalized } = parseTrustFileData(raw);
391
+ expect(normalized).toBe(true);
392
+ expect(data.rules).toHaveLength(1);
393
+ expect(data.rules[0].id).toBe("r1");
394
+ });
395
+
396
+ test("handles missing rules array", () => {
397
+ const raw = { version: 3 };
398
+ const { data, normalized } = parseTrustFileData(raw);
399
+ expect(normalized).toBe(false);
400
+ expect(data.rules).toEqual([]);
401
+ });
402
+
403
+ test("handles missing version", () => {
404
+ const raw = { rules: [] };
405
+ const { data } = parseTrustFileData(raw);
406
+ expect(data.version).toBe(0);
407
+ });
408
+
409
+ test("starterBundleAccepted defaults to undefined when not true", () => {
410
+ const raw = { version: 3, rules: [], starterBundleAccepted: false };
411
+ const { data } = parseTrustFileData(raw);
412
+ expect(data.starterBundleAccepted).toBeUndefined();
413
+ });
414
+ });
415
+
416
+ // ---------------------------------------------------------------------------
417
+ // Type-level smoke tests (compile-time only — no runtime assertions)
418
+ // ---------------------------------------------------------------------------
419
+
420
+ describe("type-level compatibility", () => {
421
+ test("TrustRule union is assignable from all family interfaces", () => {
422
+ // These assignments verify that each family interface satisfies TrustRule.
423
+ // If any interface breaks the union, TypeScript will fail at compile time.
424
+ const scoped: ScopedTrustRule = {
425
+ id: "s1",
426
+ tool: "bash",
427
+ pattern: "**",
428
+ scope: "everywhere",
429
+ decision: "allow",
430
+ priority: 50,
431
+ createdAt: 0,
432
+ executionTarget: "host",
433
+ };
434
+ const url: UrlTrustRule = {
435
+ id: "u1",
436
+ tool: "web_fetch",
437
+ pattern: "web_fetch:*",
438
+ decision: "allow",
439
+ priority: 90,
440
+ createdAt: 0,
441
+ };
442
+ const managed: ManagedSkillTrustRule = {
443
+ id: "m1",
444
+ tool: "scaffold_managed_skill",
445
+ pattern: "scaffold_managed_skill:*",
446
+ decision: "ask",
447
+ priority: 1000,
448
+ createdAt: 0,
449
+ };
450
+ const skillLoad: SkillLoadTrustRule = {
451
+ id: "sl1",
452
+ tool: "skill_load",
453
+ pattern: "skill_load:*",
454
+ decision: "allow",
455
+ priority: 100,
456
+ createdAt: 0,
457
+ };
458
+ const generic: GenericTrustRule = {
459
+ id: "g1",
460
+ tool: "computer_use_click",
461
+ pattern: "**",
462
+ decision: "ask",
463
+ priority: 1000,
464
+ createdAt: 0,
465
+ };
466
+
467
+ // All should be assignable to TrustRule
468
+ const rules: TrustRule[] = [scoped, url, managed, skillLoad, generic];
469
+ expect(rules).toHaveLength(5);
470
+ });
471
+ });
@@ -4,6 +4,22 @@
4
4
  * These are extracted from `assistant/src/permissions/types.ts` and
5
5
  * `assistant/src/permissions/trust-store.ts` so that both packages can
6
6
  * reference a single canonical definition.
7
+ *
8
+ * Tools are grouped into "families" based on how their permission candidates
9
+ * are constructed and matched:
10
+ *
11
+ * - **Scoped**: tools whose candidates include a filesystem path and obey
12
+ * directory-boundary scope constraints (`file_read`, `file_write`,
13
+ * `file_edit`, `host_file_read`, `host_file_write`, `host_file_edit`,
14
+ * `bash`, `host_bash`).
15
+ * - **URL**: tools whose candidates include a URL (`web_fetch`,
16
+ * `network_request`).
17
+ * - **Managed skill**: tools that manage first-party skill packages
18
+ * (`scaffold_managed_skill`, `delete_managed_skill`).
19
+ * - **Skill load**: the `skill_load` tool, which uses a distinct candidate
20
+ * namespace (`skill_load:selector` or `skill_load_dynamic:selector`).
21
+ * - **Generic**: everything else (computer-use tools, UI surface tools,
22
+ * recall, skill_execute, etc.).
7
23
  */
8
24
 
9
25
  // ---------------------------------------------------------------------------
@@ -14,19 +30,349 @@
14
30
  export type TrustDecision = "allow" | "deny" | "ask";
15
31
 
16
32
  // ---------------------------------------------------------------------------
17
- // Trust rule
33
+ // Tool family constants
34
+ // ---------------------------------------------------------------------------
35
+
36
+ /**
37
+ * Tools whose permission candidates are scoped to a filesystem path and obey
38
+ * directory-boundary scope constraints.
39
+ */
40
+ export const SCOPED_TOOLS = [
41
+ "file_read",
42
+ "file_write",
43
+ "file_edit",
44
+ "host_file_read",
45
+ "host_file_write",
46
+ "host_file_edit",
47
+ "bash",
48
+ "host_bash",
49
+ ] as const;
50
+
51
+ /**
52
+ * Tools whose permission candidates include a URL.
53
+ */
54
+ export const URL_TOOLS = ["web_fetch", "network_request"] as const;
55
+
56
+ /**
57
+ * Tools that manage first-party skill packages (scaffold/delete).
58
+ */
59
+ export const MANAGED_SKILL_TOOLS = [
60
+ "scaffold_managed_skill",
61
+ "delete_managed_skill",
62
+ ] as const;
63
+
64
+ /**
65
+ * The skill_load tool name. Separated from the array constants because
66
+ * skill_load is a singleton, not a family with multiple members.
67
+ */
68
+ export const SKILL_LOAD_TOOL = "skill_load" as const;
69
+
70
+ /** Set for O(1) lookups when classifying tool names. */
71
+ const SCOPED_TOOLS_SET: ReadonlySet<string> = new Set(SCOPED_TOOLS);
72
+ const URL_TOOLS_SET: ReadonlySet<string> = new Set(URL_TOOLS);
73
+ const MANAGED_SKILL_TOOLS_SET: ReadonlySet<string> = new Set(
74
+ MANAGED_SKILL_TOOLS,
75
+ );
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Trust rule — base and family-specific variants
18
79
  // ---------------------------------------------------------------------------
19
80
 
20
- export interface TrustRule {
81
+ /** Fields shared by all trust rule variants. */
82
+ export interface TrustRuleBase {
21
83
  id: string;
22
84
  tool: string;
23
85
  pattern: string;
24
- scope: string;
25
86
  decision: TrustDecision;
26
87
  priority: number;
27
88
  createdAt: number;
89
+ /**
90
+ * Set when a user explicitly modifies a default trust rule.
91
+ * When present, `backfillDefaults()` will not overwrite the rule
92
+ * with updated template values on upgrade — preserving the user's
93
+ * customization.
94
+ */
95
+ userModifiedAt?: number;
96
+ }
97
+
98
+ /**
99
+ * A trust rule for a scoped tool (filesystem-path-based candidates).
100
+ *
101
+ * Scoped rules may carry `executionTarget` to constrain matching to a
102
+ * specific execution environment.
103
+ */
104
+ export interface ScopedTrustRule extends TrustRuleBase {
105
+ tool: (typeof SCOPED_TOOLS)[number];
106
+ scope: string;
28
107
  executionTarget?: string;
29
- allowHighRisk?: boolean;
108
+ }
109
+
110
+ /**
111
+ * A trust rule for a URL-based tool.
112
+ *
113
+ * URL rules do not use `executionTarget`.
114
+ */
115
+ export interface UrlTrustRule extends TrustRuleBase {
116
+ tool: (typeof URL_TOOLS)[number];
117
+ }
118
+
119
+ /**
120
+ * A trust rule for a managed-skill tool (scaffold/delete).
121
+ */
122
+ export interface ManagedSkillTrustRule extends TrustRuleBase {
123
+ tool: (typeof MANAGED_SKILL_TOOLS)[number];
124
+ }
125
+
126
+ /**
127
+ * A trust rule for the `skill_load` tool.
128
+ */
129
+ export interface SkillLoadTrustRule extends TrustRuleBase {
130
+ tool: typeof SKILL_LOAD_TOOL;
131
+ }
132
+
133
+ /**
134
+ * A trust rule for any tool that doesn't belong to a known family.
135
+ *
136
+ * Generic rules preserve `executionTarget` for backward compatibility —
137
+ * existing rules for unknown/new tools may carry this field.
138
+ * Scope is intentionally absent: new tools that need scope must be explicitly
139
+ * added to `SCOPED_TOOLS` and use `ScopedTrustRule`.
140
+ */
141
+ export interface GenericTrustRule extends TrustRuleBase {
142
+ tool: string;
143
+ executionTarget?: string;
144
+ }
145
+
146
+ /**
147
+ * Discriminated union of all trust rule families.
148
+ *
149
+ * The union is discriminated on the `tool` field: known tool names narrow to
150
+ * the corresponding family variant, while unknown tool names fall through to
151
+ * `GenericTrustRule`.
152
+ *
153
+ * For backward compatibility, `TrustRule` remains the single type that all
154
+ * existing code uses. The family-specific interfaces exist so that new code
155
+ * can narrow the type when it knows the tool family.
156
+ */
157
+ export type TrustRule =
158
+ | ScopedTrustRule
159
+ | UrlTrustRule
160
+ | ManagedSkillTrustRule
161
+ | SkillLoadTrustRule
162
+ | GenericTrustRule;
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Type guards
166
+ // ---------------------------------------------------------------------------
167
+
168
+ /** Narrow a TrustRule to a ScopedTrustRule. */
169
+ export function isScopedRule(rule: TrustRule): rule is ScopedTrustRule {
170
+ return SCOPED_TOOLS_SET.has(rule.tool);
171
+ }
172
+
173
+ /** Narrow a TrustRule to a UrlTrustRule. */
174
+ export function isUrlRule(rule: TrustRule): rule is UrlTrustRule {
175
+ return URL_TOOLS_SET.has(rule.tool);
176
+ }
177
+
178
+ /** Narrow a TrustRule to a ManagedSkillTrustRule. */
179
+ export function isManagedSkillRule(
180
+ rule: TrustRule,
181
+ ): rule is ManagedSkillTrustRule {
182
+ return MANAGED_SKILL_TOOLS_SET.has(rule.tool);
183
+ }
184
+
185
+ /** Narrow a TrustRule to a SkillLoadTrustRule. */
186
+ export function isSkillLoadRule(rule: TrustRule): rule is SkillLoadTrustRule {
187
+ return rule.tool === SKILL_LOAD_TOOL;
188
+ }
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // Scope helper
192
+ // ---------------------------------------------------------------------------
193
+
194
+ /**
195
+ * Return the effective scope for any trust rule. Only scoped rules carry a
196
+ * `scope` field; all other rule families return `"everywhere"`.
197
+ */
198
+ export function ruleScope(rule: TrustRule): string {
199
+ if (isScopedRule(rule)) {
200
+ return rule.scope;
201
+ }
202
+ return "everywhere";
203
+ }
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // Canonical parse / normalize
207
+ // ---------------------------------------------------------------------------
208
+
209
+ /**
210
+ * Result of parsing a raw trust rule object. Includes the normalized rule
211
+ * and a flag indicating whether any normalization occurred (so callers can
212
+ * trigger a re-save of the trust file).
213
+ */
214
+ export interface ParsedTrustRule {
215
+ rule: TrustRule;
216
+ /** True if any fields were stripped or modified during normalization. */
217
+ normalized: boolean;
218
+ }
219
+
220
+ /**
221
+ * Parse and normalize a raw trust rule object into a canonical `TrustRule`.
222
+ *
223
+ * Normalization strips fields that are invalid for the rule's tool family:
224
+ * - URL rules: `executionTarget` and `scope` are stripped.
225
+ * - Managed skill rules: `executionTarget` and `scope` are stripped.
226
+ * - Skill load rules: `executionTarget` and `scope` are stripped.
227
+ * - Scoped rules: `scope` is preserved (defaulting to `"everywhere"`),
228
+ * `executionTarget` is preserved when valid.
229
+ * - Generic (unknown) rules: `scope` is stripped (new tools that need scope
230
+ * must be added to `SCOPED_TOOLS`); `executionTarget` is preserved for
231
+ * forward compatibility.
232
+ * - All families: `allowHighRisk` is stripped (replaced by runtime
233
+ * determination in checker.ts). Old trust.json files with `allowHighRisk`
234
+ * are normalized on load.
235
+ */
236
+ export function parseTrustRule(raw: Record<string, unknown>): ParsedTrustRule {
237
+ let normalized = false;
238
+
239
+ // Extract base fields with coercion for safety — mark normalized whenever
240
+ // a field is coerced to its default so callers know to re-save.
241
+ const id = typeof raw.id === "string" ? raw.id : ((normalized = true), "");
242
+ const tool =
243
+ typeof raw.tool === "string" ? raw.tool : ((normalized = true), "");
244
+ const pattern =
245
+ typeof raw.pattern === "string" ? raw.pattern : ((normalized = true), "");
246
+ const decision = isValidDecision(raw.decision)
247
+ ? raw.decision
248
+ : ((normalized = true), "ask" as const);
249
+ const priority =
250
+ typeof raw.priority === "number" ? raw.priority : ((normalized = true), 100);
251
+ const createdAt =
252
+ typeof raw.createdAt === "number"
253
+ ? raw.createdAt
254
+ : ((normalized = true), 0);
255
+ const userModifiedAt =
256
+ typeof raw.userModifiedAt === "number" ? raw.userModifiedAt : undefined;
257
+
258
+ // Build the base rule — scope is NOT included here; it is added only by
259
+ // the scoped and generic branches below.
260
+ const base: TrustRuleBase = {
261
+ id,
262
+ tool,
263
+ pattern,
264
+ decision,
265
+ priority,
266
+ createdAt,
267
+ ...(userModifiedAt != null ? { userModifiedAt } : {}),
268
+ };
269
+
270
+ // Determine the family and strip invalid fields
271
+ if (URL_TOOLS_SET.has(tool)) {
272
+ // URL rules must not carry executionTarget or scope.
273
+ if (raw.executionTarget !== undefined) {
274
+ normalized = true;
275
+ }
276
+ if (typeof raw.scope === "string" && raw.scope !== "everywhere") {
277
+ normalized = true;
278
+ }
279
+ // allowHighRisk is stripped (replaced by runtime determination).
280
+ if (raw.allowHighRisk !== undefined) {
281
+ normalized = true;
282
+ }
283
+ const rule: UrlTrustRule = { ...base, tool: tool as UrlTrustRule["tool"] };
284
+ return { rule, normalized };
285
+ }
286
+
287
+ if (MANAGED_SKILL_TOOLS_SET.has(tool)) {
288
+ // Managed skill rules must not carry executionTarget or scope.
289
+ if (raw.executionTarget !== undefined) {
290
+ normalized = true;
291
+ }
292
+ if (typeof raw.scope === "string" && raw.scope !== "everywhere") {
293
+ normalized = true;
294
+ }
295
+ // allowHighRisk is stripped (replaced by runtime determination).
296
+ if (raw.allowHighRisk !== undefined) {
297
+ normalized = true;
298
+ }
299
+ const rule: ManagedSkillTrustRule = {
300
+ ...base,
301
+ tool: tool as ManagedSkillTrustRule["tool"],
302
+ };
303
+ return { rule, normalized };
304
+ }
305
+
306
+ if (tool === SKILL_LOAD_TOOL) {
307
+ // Skill-load rules must not carry executionTarget or scope.
308
+ if (raw.executionTarget !== undefined) {
309
+ normalized = true;
310
+ }
311
+ if (typeof raw.scope === "string" && raw.scope !== "everywhere") {
312
+ normalized = true;
313
+ }
314
+ // allowHighRisk is stripped (replaced by runtime determination).
315
+ if (raw.allowHighRisk !== undefined) {
316
+ normalized = true;
317
+ }
318
+ const rule: SkillLoadTrustRule = { ...base, tool: SKILL_LOAD_TOOL };
319
+ return { rule, normalized };
320
+ }
321
+
322
+ if (SCOPED_TOOLS_SET.has(tool)) {
323
+ // Scoped rules include scope (defaulting to "everywhere") and preserve
324
+ // executionTarget.
325
+ const scope =
326
+ typeof raw.scope === "string"
327
+ ? raw.scope
328
+ : ((normalized = true), "everywhere");
329
+ const rule: ScopedTrustRule = {
330
+ ...base,
331
+ tool: tool as ScopedTrustRule["tool"],
332
+ scope,
333
+ };
334
+ if (
335
+ typeof raw.executionTarget === "string" &&
336
+ raw.executionTarget.length > 0
337
+ ) {
338
+ rule.executionTarget = raw.executionTarget;
339
+ } else if (raw.executionTarget !== undefined && raw.executionTarget !== "") {
340
+ normalized = true;
341
+ }
342
+ // allowHighRisk is stripped (replaced by runtime determination).
343
+ if (raw.allowHighRisk !== undefined) {
344
+ normalized = true;
345
+ }
346
+ return { rule, normalized };
347
+ }
348
+
349
+ // Generic (unknown) tool — strip scope (new tools that need scope must be
350
+ // added to SCOPED_TOOLS explicitly), preserve executionTarget for forward compat.
351
+ const rule: GenericTrustRule = { ...base };
352
+ if (
353
+ typeof raw.scope === "string" &&
354
+ raw.scope !== "" &&
355
+ raw.scope !== "everywhere"
356
+ ) {
357
+ normalized = true;
358
+ }
359
+ if (
360
+ typeof raw.executionTarget === "string" &&
361
+ raw.executionTarget.length > 0
362
+ ) {
363
+ rule.executionTarget = raw.executionTarget;
364
+ } else if (raw.executionTarget !== undefined && raw.executionTarget !== "") {
365
+ normalized = true;
366
+ }
367
+ // allowHighRisk is stripped (replaced by runtime determination).
368
+ if (raw.allowHighRisk !== undefined) {
369
+ normalized = true;
370
+ }
371
+ return { rule, normalized };
372
+ }
373
+
374
+ function isValidDecision(value: unknown): value is TrustDecision {
375
+ return value === "allow" || value === "deny" || value === "ask";
30
376
  }
31
377
 
32
378
  // ---------------------------------------------------------------------------
@@ -40,3 +386,51 @@ export interface TrustFileData {
40
386
  /** Set to true when the user explicitly accepts the starter approval bundle. */
41
387
  starterBundleAccepted?: boolean;
42
388
  }
389
+
390
+ /**
391
+ * Result of parsing a raw trust file. Includes the parsed data and a flag
392
+ * indicating whether any rules were normalized.
393
+ */
394
+ export interface ParsedTrustFileData {
395
+ data: TrustFileData;
396
+ /** True if any rules were normalized during parsing. */
397
+ normalized: boolean;
398
+ }
399
+
400
+ /**
401
+ * Parse and normalize a raw trust file object.
402
+ *
403
+ * Each rule in the `rules` array is run through `parseTrustRule` for
404
+ * family-aware normalization. The `normalized` flag in the result is true
405
+ * if *any* rule was modified, signaling the caller that a re-save is warranted.
406
+ */
407
+ export function parseTrustFileData(
408
+ raw: Record<string, unknown>,
409
+ ): ParsedTrustFileData {
410
+ const version = typeof raw.version === "number" ? raw.version : 0;
411
+ const starterBundleAccepted =
412
+ raw.starterBundleAccepted === true ? true : undefined;
413
+ const rawRules = Array.isArray(raw.rules) ? raw.rules : [];
414
+
415
+ let anyNormalized = false;
416
+ const rules: TrustRule[] = [];
417
+
418
+ for (const rawRule of rawRules) {
419
+ if (rawRule == null || typeof rawRule !== "object" || Array.isArray(rawRule)) {
420
+ anyNormalized = true;
421
+ continue;
422
+ }
423
+ const { rule, normalized } = parseTrustRule(
424
+ rawRule as Record<string, unknown>,
425
+ );
426
+ if (normalized) anyNormalized = true;
427
+ rules.push(rule);
428
+ }
429
+
430
+ const data: TrustFileData = { version, rules };
431
+ if (starterBundleAccepted) {
432
+ data.starterBundleAccepted = true;
433
+ }
434
+
435
+ return { data, normalized: anyNormalized };
436
+ }
@@ -5,8 +5,8 @@
5
5
  "": {
6
6
  "name": "@vellumai/credential-storage",
7
7
  "devDependencies": {
8
- "@types/bun": "^1.2.4",
9
- "typescript": "^5.7.3",
8
+ "@types/bun": "1.3.10",
9
+ "typescript": "5.9.3",
10
10
  },
11
11
  },
12
12
  },
@@ -12,7 +12,7 @@
12
12
  "test": "bun test src/"
13
13
  },
14
14
  "devDependencies": {
15
- "@types/bun": "^1.2.4",
16
- "typescript": "^5.7.3"
15
+ "@types/bun": "1.3.10",
16
+ "typescript": "5.9.3"
17
17
  }
18
18
  }
@@ -89,6 +89,8 @@ export interface RefreshBreakerState {
89
89
  consecutiveFailures: number;
90
90
  openedAt: number;
91
91
  cooldownMs: number;
92
+ /** Whether the breaker tripped due to a credential error (vs transient). */
93
+ isCredentialError: boolean;
92
94
  }
93
95
 
94
96
  /**
@@ -128,18 +130,34 @@ export class RefreshCircuitBreaker {
128
130
  this.breakers.delete(key);
129
131
  }
130
132
 
131
- /** Record a failed refresh attempt, potentially opening the breaker. */
132
- recordFailure(key: string): void {
133
+ /**
134
+ * Record a failed refresh attempt, potentially opening the breaker.
135
+ *
136
+ * @param isCredential - When true, the failure is a credential error
137
+ * (revoked token, invalid client) that no amount of retrying will fix.
138
+ * Only credential errors count toward opening the circuit breaker.
139
+ * Transient errors (network timeouts, 5xx) are silently ignored here —
140
+ * they do not trip the breaker and are not recorded. Upstream retry logic
141
+ * in refreshOAuth2Token handles transient failures with exponential backoff.
142
+ */
143
+ recordFailure(key: string, isCredential = true): void {
144
+ if (!isCredential) {
145
+ // Transient failures should not trip the breaker. The retry logic in
146
+ // refreshOAuth2Token handles transient errors with its own backoff.
147
+ return;
148
+ }
133
149
  const state = this.breakers.get(key);
134
150
  if (!state) {
135
151
  this.breakers.set(key, {
136
152
  consecutiveFailures: 1,
137
153
  openedAt: 0,
138
154
  cooldownMs: INITIAL_COOLDOWN_MS,
155
+ isCredentialError: true,
139
156
  });
140
157
  return;
141
158
  }
142
159
  state.consecutiveFailures++;
160
+ state.isCredentialError = true;
143
161
  if (state.consecutiveFailures >= REFRESH_FAILURE_THRESHOLD) {
144
162
  // Only escalate cooldown on the exact failure that trips the breaker.
145
163
  // Concurrent in-flight failures that arrive after the threshold is
@@ -5,8 +5,8 @@
5
5
  "": {
6
6
  "name": "@vellumai/egress-proxy",
7
7
  "devDependencies": {
8
- "@types/bun": "^1.2.4",
9
- "typescript": "^5.7.3",
8
+ "@types/bun": "1.3.10",
9
+ "typescript": "5.9.3",
10
10
  },
11
11
  },
12
12
  },
@@ -12,7 +12,7 @@
12
12
  "test": "bun test src/"
13
13
  },
14
14
  "devDependencies": {
15
- "@types/bun": "^1.2.4",
16
- "typescript": "^5.7.3"
15
+ "@types/bun": "1.3.10",
16
+ "typescript": "5.9.3"
17
17
  }
18
18
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/credential-executor",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {