@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.
- package/node_modules/@vellumai/ces-contracts/package.json +2 -1
- package/node_modules/@vellumai/ces-contracts/src/__tests__/trust-rules.test.ts +471 -0
- package/node_modules/@vellumai/ces-contracts/src/trust-rules.ts +398 -4
- package/node_modules/@vellumai/credential-storage/bun.lock +2 -2
- package/node_modules/@vellumai/credential-storage/package.json +2 -2
- package/node_modules/@vellumai/credential-storage/src/oauth-runtime.ts +20 -2
- package/node_modules/@vellumai/egress-proxy/bun.lock +2 -2
- package/node_modules/@vellumai/egress-proxy/package.json +2 -2
- package/package.json +1 -1
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -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
|
-
/**
|
|
132
|
-
|
|
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
|