prae-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +101 -0
- package/lib/bin.d.ts +1 -0
- package/lib/bin.js +11 -0
- package/lib/cli-DGVWMDT0.js +2405 -0
- package/lib/index.d.ts +61 -0
- package/lib/index.js +3 -0
- package/lib/playground.js +685 -0
- package/package.json +52 -0
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
(function() {
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
//#region ../kernel/src/path.ts
|
|
5
|
+
/**
|
|
6
|
+
* Path normalization and glob containment, with no filesystem access.
|
|
7
|
+
*
|
|
8
|
+
* The contract, stated here because getting it wrong is how path policies
|
|
9
|
+
* become theatre: **this module compares strings.** It resolves `.` and `..`
|
|
10
|
+
* lexically so that `./src/../../etc/passwd` cannot pose as a workspace path,
|
|
11
|
+
* but it cannot see a symlink. A link inside an allowed directory pointing out
|
|
12
|
+
* of it will pass every check here.
|
|
13
|
+
*
|
|
14
|
+
* That is not a gap to paper over in the matcher — it is a gap the caller
|
|
15
|
+
* closes by resolving before it asks. `dsh-overlay-check` learned this the
|
|
16
|
+
* hard way during the plugin-store gate review: lexical containment accepted a
|
|
17
|
+
* symlink escape cleanly, and the fix was to resolve both the path and its
|
|
18
|
+
* nearest existing ancestor before deciding. Adapters that can touch the
|
|
19
|
+
* filesystem must resolve first and pass the resolved path in.
|
|
20
|
+
*/
|
|
21
|
+
/** Split a path into segments, resolving `.` and `..` lexically. */
|
|
22
|
+
function normalize(path) {
|
|
23
|
+
const absolute = path.startsWith("/");
|
|
24
|
+
const out = [];
|
|
25
|
+
let escaped = false;
|
|
26
|
+
for (const raw of path.split(/[/\\]+/)) {
|
|
27
|
+
if (raw === "" || raw === ".") continue;
|
|
28
|
+
if (raw === "..") {
|
|
29
|
+
if (out.length > 0) out.pop();
|
|
30
|
+
else escaped = true;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
out.push(raw);
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
segments: out,
|
|
37
|
+
absolute,
|
|
38
|
+
escaped
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** Turn one glob segment into an anchored regular expression. */
|
|
42
|
+
function segmentPattern(segment) {
|
|
43
|
+
const body = segment.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*");
|
|
44
|
+
return /* @__PURE__ */ new RegExp(`^${body}$`);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Wildcard segment matching, where `**` spans zero or more segments.
|
|
48
|
+
*
|
|
49
|
+
* Two-pointer with backtracking rather than a recursive walk: the pattern
|
|
50
|
+
* comes from a policy pack and the path from a tool call, and a catastrophic
|
|
51
|
+
* backtrack in the hot path of every call would be a denial of service handed
|
|
52
|
+
* to whoever writes the pack.
|
|
53
|
+
*/
|
|
54
|
+
function matchSegments(path, pattern) {
|
|
55
|
+
let i = 0;
|
|
56
|
+
let j = 0;
|
|
57
|
+
let starPattern = -1;
|
|
58
|
+
let starPath = 0;
|
|
59
|
+
while (i < path.length) {
|
|
60
|
+
const segment = j < pattern.length ? pattern[j] : void 0;
|
|
61
|
+
const here = path[i];
|
|
62
|
+
if (segment === "**") {
|
|
63
|
+
starPattern = j;
|
|
64
|
+
starPath = i;
|
|
65
|
+
j += 1;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (segment !== void 0 && here !== void 0 && segmentPattern(segment).test(here)) {
|
|
69
|
+
i += 1;
|
|
70
|
+
j += 1;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (starPattern >= 0) {
|
|
74
|
+
starPath += 1;
|
|
75
|
+
i = starPath;
|
|
76
|
+
j = starPattern + 1;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
while (j < pattern.length && pattern[j] === "**") j += 1;
|
|
82
|
+
return j === pattern.length;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Is `path` contained by `glob`?
|
|
86
|
+
*
|
|
87
|
+
* A glob naming a directory contains everything under it: `./src` and
|
|
88
|
+
* `./src/**` both match `src/app/main.ts`. Writing the shorter form is the
|
|
89
|
+
* common intent, and treating it as "this exact directory entry only" is a
|
|
90
|
+
* trap that silently grants the whole subtree past the rule.
|
|
91
|
+
*/
|
|
92
|
+
function within(path, glob) {
|
|
93
|
+
const target = normalize(path);
|
|
94
|
+
if (target.escaped) return false;
|
|
95
|
+
const pattern = normalize(glob);
|
|
96
|
+
if (matchSegments(target.segments, pattern.segments)) return true;
|
|
97
|
+
return matchSegments(target.segments, [...pattern.segments, "**"]);
|
|
98
|
+
}
|
|
99
|
+
/** Is `path` inside any of these globs? */
|
|
100
|
+
function withinAny(path, globs) {
|
|
101
|
+
return globs.some((glob) => within(path, glob));
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The host of a URL, or undefined when it is not a URL we can read.
|
|
105
|
+
*
|
|
106
|
+
* Unparseable input returns undefined, and callers treat that as "no host" —
|
|
107
|
+
* which for a `notIn` rule means the rule matches. Failing toward the rule is
|
|
108
|
+
* the only safe direction: a string we cannot parse is exactly the shape an
|
|
109
|
+
* evasion attempt takes.
|
|
110
|
+
*/
|
|
111
|
+
function hostOf(url) {
|
|
112
|
+
try {
|
|
113
|
+
return new URL(url).hostname.toLowerCase();
|
|
114
|
+
} catch {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Host list membership, where a leading dot means "this domain or below".
|
|
120
|
+
*
|
|
121
|
+
* `example.com` matches only that host. `.example.com` also matches
|
|
122
|
+
* `api.example.com`, and deliberately still matches `example.com` itself —
|
|
123
|
+
* an operator writing the suffix form means the organisation, not a rule that
|
|
124
|
+
* covers every subdomain except the one they first thought of.
|
|
125
|
+
*/
|
|
126
|
+
function hostIn(host, list) {
|
|
127
|
+
return list.some((entry) => {
|
|
128
|
+
const want = entry.toLowerCase();
|
|
129
|
+
if (want.startsWith(".")) return host === want.slice(1) || host.endsWith(want);
|
|
130
|
+
return host === want;
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
//#endregion
|
|
135
|
+
//#region ../kernel/src/rule.ts
|
|
136
|
+
/**
|
|
137
|
+
* Strength ordering, used when several rules match one call.
|
|
138
|
+
*
|
|
139
|
+
* Higher wins. This is the property that makes a pack safe to extend: adding a
|
|
140
|
+
* rule can raise the strength of a decision and never lower it, so an operator
|
|
141
|
+
* appending to a pack can never accidentally widen what an agent may do.
|
|
142
|
+
*/
|
|
143
|
+
const STRENGTH = {
|
|
144
|
+
warn: 1,
|
|
145
|
+
correct: 2,
|
|
146
|
+
ask: 3,
|
|
147
|
+
deny: 4
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
//#endregion
|
|
151
|
+
//#region ../kernel/src/evaluate.ts
|
|
152
|
+
function listed(value, want) {
|
|
153
|
+
if (want === void 0) return true;
|
|
154
|
+
if (value === void 0) return false;
|
|
155
|
+
return typeof want === "string" ? value === want : want.includes(value);
|
|
156
|
+
}
|
|
157
|
+
function argMatches(match, args) {
|
|
158
|
+
const has = Object.hasOwn(args, match.name);
|
|
159
|
+
if (match.present !== void 0 && match.present !== has) return false;
|
|
160
|
+
if (!has) return match.above === void 0 && match.below === void 0 && match.equals === void 0 && match.oneOf === void 0;
|
|
161
|
+
const value = args[match.name];
|
|
162
|
+
if (match.above !== void 0) {
|
|
163
|
+
if (typeof value !== "number" || !(value > match.above)) return false;
|
|
164
|
+
}
|
|
165
|
+
if (match.below !== void 0) {
|
|
166
|
+
if (typeof value !== "number" || !(value < match.below)) return false;
|
|
167
|
+
}
|
|
168
|
+
if (match.equals !== void 0 && value !== match.equals) return false;
|
|
169
|
+
if (match.oneOf !== void 0) {
|
|
170
|
+
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") return false;
|
|
171
|
+
if (!match.oneOf.includes(value)) return false;
|
|
172
|
+
}
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
function matches(when, facts) {
|
|
176
|
+
if (!listed(facts.tool, when.tool)) return false;
|
|
177
|
+
if (!listed(facts.capability, when.capability)) return false;
|
|
178
|
+
if (!listed(facts.plugin, when.plugin)) return false;
|
|
179
|
+
if (!listed(facts.agent, when.agent)) return false;
|
|
180
|
+
if (when.path !== void 0) {
|
|
181
|
+
const raw = facts.args[when.path.arg ?? "path"];
|
|
182
|
+
if (typeof raw !== "string") return false;
|
|
183
|
+
if (when.path.inside !== void 0 && !withinAny(raw, when.path.inside)) return false;
|
|
184
|
+
if (when.path.outside !== void 0 && withinAny(raw, when.path.outside)) return false;
|
|
185
|
+
}
|
|
186
|
+
if (when.host !== void 0) {
|
|
187
|
+
const raw = facts.args[when.host.arg ?? "url"];
|
|
188
|
+
if (typeof raw !== "string") return false;
|
|
189
|
+
const host = hostOf(raw);
|
|
190
|
+
if (host === void 0) return when.host.notIn !== void 0;
|
|
191
|
+
if (when.host.in !== void 0 && !hostIn(host, when.host.in)) return false;
|
|
192
|
+
if (when.host.notIn !== void 0 && hostIn(host, when.host.notIn)) return false;
|
|
193
|
+
}
|
|
194
|
+
if (when.arg !== void 0) {
|
|
195
|
+
for (const one of when.arg) if (!argMatches(one, facts.args)) return false;
|
|
196
|
+
}
|
|
197
|
+
if (when.count !== void 0) {
|
|
198
|
+
const key = facts.capability;
|
|
199
|
+
if (key === void 0) return false;
|
|
200
|
+
if (!((facts.counts?.[key] ?? 0) > when.count.above)) return false;
|
|
201
|
+
}
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
function step(rule, effect) {
|
|
205
|
+
return {
|
|
206
|
+
rule: rule.id,
|
|
207
|
+
...rule.title === void 0 ? {} : { title: rule.title },
|
|
208
|
+
effect: effect.kind,
|
|
209
|
+
because: effect.because
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Decide one call against one pack.
|
|
214
|
+
*
|
|
215
|
+
* Every matching rule lands in the trace; the strongest effect decides. Ties
|
|
216
|
+
* go to the first rule in pack order, so a pack reads top-down the way its
|
|
217
|
+
* author wrote it.
|
|
218
|
+
*
|
|
219
|
+
* `correct` is the effect the rest of this ecosystem does not have. A rival
|
|
220
|
+
* guardrail can allow a call or refuse it; a rule here can also rewrite an
|
|
221
|
+
* argument and let it through, recording what it changed. Refusing is not
|
|
222
|
+
* always the safe option — an agent denied mid-task retries, and the retry is
|
|
223
|
+
* rarely safer than the clamp would have been.
|
|
224
|
+
*/
|
|
225
|
+
function evaluate(pack, facts) {
|
|
226
|
+
const hits = [];
|
|
227
|
+
for (const rule of pack.rules) if (matches(rule.when, facts)) hits.push({
|
|
228
|
+
rule,
|
|
229
|
+
effect: rule.effect
|
|
230
|
+
});
|
|
231
|
+
if (hits.length === 0) return {
|
|
232
|
+
kind: "allow",
|
|
233
|
+
trace: [],
|
|
234
|
+
evaluated: pack.rules.length
|
|
235
|
+
};
|
|
236
|
+
hits.sort((a, b) => STRENGTH[b.effect.kind] - STRENGTH[a.effect.kind]);
|
|
237
|
+
const trace = hits.map((hit) => step(hit.rule, hit.effect));
|
|
238
|
+
const evaluated = pack.rules.length;
|
|
239
|
+
const strongest = hits[0];
|
|
240
|
+
if (strongest === void 0) return {
|
|
241
|
+
kind: "allow",
|
|
242
|
+
trace: [],
|
|
243
|
+
evaluated
|
|
244
|
+
};
|
|
245
|
+
const winner = strongest.effect.kind;
|
|
246
|
+
if (winner === "deny" || winner === "ask") return {
|
|
247
|
+
kind: winner,
|
|
248
|
+
rule: strongest.rule.id,
|
|
249
|
+
reason: strongest.effect.because,
|
|
250
|
+
trace,
|
|
251
|
+
evaluated
|
|
252
|
+
};
|
|
253
|
+
if (winner === "correct") {
|
|
254
|
+
const args = { ...facts.args };
|
|
255
|
+
for (const hit of hits) if (hit.effect.kind === "correct") Object.assign(args, hit.effect.set);
|
|
256
|
+
return {
|
|
257
|
+
kind: "correct",
|
|
258
|
+
rule: strongest.rule.id,
|
|
259
|
+
reason: strongest.effect.because,
|
|
260
|
+
args,
|
|
261
|
+
trace,
|
|
262
|
+
evaluated
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
kind: "allow",
|
|
267
|
+
trace,
|
|
268
|
+
evaluated
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
//#endregion
|
|
273
|
+
//#region ../kernel/src/pack.ts
|
|
274
|
+
function isObject(value) {
|
|
275
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
276
|
+
}
|
|
277
|
+
var Check = class {
|
|
278
|
+
errors = [];
|
|
279
|
+
fail(path, message) {
|
|
280
|
+
this.errors.push({
|
|
281
|
+
path,
|
|
282
|
+
message
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
/** Reject any key the shape does not define. */
|
|
286
|
+
keys(path, value, allowed) {
|
|
287
|
+
for (const key of Object.keys(value)) if (!allowed.includes(key)) this.fail(`${path}.${key}`, `unknown field \`${key}\`; expected one of ${allowed.map((k) => `\`${k}\``).join(", ")}`);
|
|
288
|
+
}
|
|
289
|
+
text(path, value) {
|
|
290
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
291
|
+
this.fail(path, "expected a non-empty string");
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
return value;
|
|
295
|
+
}
|
|
296
|
+
/** A field that accepts one name or a list of them. */
|
|
297
|
+
names(path, value) {
|
|
298
|
+
if (typeof value === "string") return value;
|
|
299
|
+
if (Array.isArray(value)) {
|
|
300
|
+
const out = [];
|
|
301
|
+
value.forEach((entry, index) => {
|
|
302
|
+
const one = this.text(`${path}[${index}]`, entry);
|
|
303
|
+
if (one !== void 0) out.push(one);
|
|
304
|
+
});
|
|
305
|
+
return out;
|
|
306
|
+
}
|
|
307
|
+
this.fail(path, "expected a string or an array of strings");
|
|
308
|
+
}
|
|
309
|
+
globs(path, value) {
|
|
310
|
+
if (!Array.isArray(value)) {
|
|
311
|
+
this.fail(path, "expected an array of path globs");
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const out = [];
|
|
315
|
+
value.forEach((entry, index) => {
|
|
316
|
+
const one = this.text(`${path}[${index}]`, entry);
|
|
317
|
+
if (one !== void 0) out.push(one);
|
|
318
|
+
});
|
|
319
|
+
return out;
|
|
320
|
+
}
|
|
321
|
+
number(path, value) {
|
|
322
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
323
|
+
this.fail(path, "expected a finite number");
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
return value;
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
function readPathMatch(check, path, value) {
|
|
330
|
+
if (!isObject(value)) {
|
|
331
|
+
check.fail(path, "expected an object");
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
check.keys(path, value, [
|
|
335
|
+
"arg",
|
|
336
|
+
"outside",
|
|
337
|
+
"inside"
|
|
338
|
+
]);
|
|
339
|
+
if (value.outside === void 0 && value.inside === void 0) check.fail(path, "expected `inside` or `outside`; a path matcher with neither matches nothing");
|
|
340
|
+
const out = {};
|
|
341
|
+
if (value.arg !== void 0) {
|
|
342
|
+
const arg = check.text(`${path}.arg`, value.arg);
|
|
343
|
+
if (arg !== void 0) out.arg = arg;
|
|
344
|
+
}
|
|
345
|
+
if (value.outside !== void 0) {
|
|
346
|
+
const globs = check.globs(`${path}.outside`, value.outside);
|
|
347
|
+
if (globs !== void 0) out.outside = globs;
|
|
348
|
+
}
|
|
349
|
+
if (value.inside !== void 0) {
|
|
350
|
+
const globs = check.globs(`${path}.inside`, value.inside);
|
|
351
|
+
if (globs !== void 0) out.inside = globs;
|
|
352
|
+
}
|
|
353
|
+
return out;
|
|
354
|
+
}
|
|
355
|
+
function readHostMatch(check, path, value) {
|
|
356
|
+
if (!isObject(value)) {
|
|
357
|
+
check.fail(path, "expected an object");
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
check.keys(path, value, [
|
|
361
|
+
"arg",
|
|
362
|
+
"in",
|
|
363
|
+
"notIn"
|
|
364
|
+
]);
|
|
365
|
+
if (value.in === void 0 && value.notIn === void 0) check.fail(path, "expected `in` or `notIn`; a host matcher with neither matches nothing");
|
|
366
|
+
const out = {};
|
|
367
|
+
if (value.arg !== void 0) {
|
|
368
|
+
const arg = check.text(`${path}.arg`, value.arg);
|
|
369
|
+
if (arg !== void 0) out.arg = arg;
|
|
370
|
+
}
|
|
371
|
+
if (value.in !== void 0) {
|
|
372
|
+
const list = check.globs(`${path}.in`, value.in);
|
|
373
|
+
if (list !== void 0) out.in = list;
|
|
374
|
+
}
|
|
375
|
+
if (value.notIn !== void 0) {
|
|
376
|
+
const list = check.globs(`${path}.notIn`, value.notIn);
|
|
377
|
+
if (list !== void 0) out.notIn = list;
|
|
378
|
+
}
|
|
379
|
+
return out;
|
|
380
|
+
}
|
|
381
|
+
function readArgMatch(check, path, value) {
|
|
382
|
+
if (!isObject(value)) {
|
|
383
|
+
check.fail(path, "expected an object");
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
check.keys(path, value, [
|
|
387
|
+
"name",
|
|
388
|
+
"above",
|
|
389
|
+
"below",
|
|
390
|
+
"equals",
|
|
391
|
+
"oneOf",
|
|
392
|
+
"present"
|
|
393
|
+
]);
|
|
394
|
+
const name = check.text(`${path}.name`, value.name);
|
|
395
|
+
if (name === void 0) return void 0;
|
|
396
|
+
const out = { name };
|
|
397
|
+
if (value.above !== void 0) {
|
|
398
|
+
const n = check.number(`${path}.above`, value.above);
|
|
399
|
+
if (n !== void 0) out.above = n;
|
|
400
|
+
}
|
|
401
|
+
if (value.below !== void 0) {
|
|
402
|
+
const n = check.number(`${path}.below`, value.below);
|
|
403
|
+
if (n !== void 0) out.below = n;
|
|
404
|
+
}
|
|
405
|
+
if (value.equals !== void 0) out.equals = value.equals;
|
|
406
|
+
if (value.oneOf !== void 0) if (!Array.isArray(value.oneOf)) check.fail(`${path}.oneOf`, "expected an array");
|
|
407
|
+
else out.oneOf = value.oneOf;
|
|
408
|
+
if (value.present !== void 0) if (typeof value.present !== "boolean") check.fail(`${path}.present`, "expected a boolean");
|
|
409
|
+
else out.present = value.present;
|
|
410
|
+
return out;
|
|
411
|
+
}
|
|
412
|
+
function readMatch(check, path, value) {
|
|
413
|
+
if (!isObject(value)) {
|
|
414
|
+
check.fail(path, "expected an object");
|
|
415
|
+
return {};
|
|
416
|
+
}
|
|
417
|
+
check.keys(path, value, [
|
|
418
|
+
"tool",
|
|
419
|
+
"capability",
|
|
420
|
+
"plugin",
|
|
421
|
+
"agent",
|
|
422
|
+
"path",
|
|
423
|
+
"host",
|
|
424
|
+
"arg",
|
|
425
|
+
"count"
|
|
426
|
+
]);
|
|
427
|
+
const out = {};
|
|
428
|
+
for (const key of [
|
|
429
|
+
"tool",
|
|
430
|
+
"capability",
|
|
431
|
+
"plugin",
|
|
432
|
+
"agent"
|
|
433
|
+
]) if (value[key] !== void 0) {
|
|
434
|
+
const names = check.names(`${path}.${key}`, value[key]);
|
|
435
|
+
if (names !== void 0) out[key] = names;
|
|
436
|
+
}
|
|
437
|
+
if (value.path !== void 0) {
|
|
438
|
+
const match = readPathMatch(check, `${path}.path`, value.path);
|
|
439
|
+
if (match !== void 0) out.path = match;
|
|
440
|
+
}
|
|
441
|
+
if (value.host !== void 0) {
|
|
442
|
+
const match = readHostMatch(check, `${path}.host`, value.host);
|
|
443
|
+
if (match !== void 0) out.host = match;
|
|
444
|
+
}
|
|
445
|
+
if (value.arg !== void 0) if (!Array.isArray(value.arg)) check.fail(`${path}.arg`, "expected an array of argument matchers");
|
|
446
|
+
else {
|
|
447
|
+
const list = [];
|
|
448
|
+
value.arg.forEach((entry, index) => {
|
|
449
|
+
const one = readArgMatch(check, `${path}.arg[${index}]`, entry);
|
|
450
|
+
if (one !== void 0) list.push(one);
|
|
451
|
+
});
|
|
452
|
+
out.arg = list;
|
|
453
|
+
}
|
|
454
|
+
if (value.count !== void 0) if (!isObject(value.count)) check.fail(`${path}.count`, "expected an object");
|
|
455
|
+
else {
|
|
456
|
+
check.keys(`${path}.count`, value.count, ["above"]);
|
|
457
|
+
const above = check.number(`${path}.count.above`, value.count.above);
|
|
458
|
+
if (above !== void 0) out.count = { above };
|
|
459
|
+
}
|
|
460
|
+
return out;
|
|
461
|
+
}
|
|
462
|
+
function readEffect(check, path, value) {
|
|
463
|
+
if (!isObject(value)) {
|
|
464
|
+
check.fail(path, "expected an object");
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
const kind = value.kind;
|
|
468
|
+
if (kind !== "deny" && kind !== "ask" && kind !== "correct" && kind !== "warn") {
|
|
469
|
+
check.fail(`${path}.kind`, "expected `deny`, `ask`, `correct`, or `warn`");
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
check.keys(path, value, kind === "correct" ? [
|
|
473
|
+
"kind",
|
|
474
|
+
"set",
|
|
475
|
+
"because"
|
|
476
|
+
] : ["kind", "because"]);
|
|
477
|
+
const because = check.text(`${path}.because`, value.because);
|
|
478
|
+
if (because === void 0) return void 0;
|
|
479
|
+
if (kind === "correct") {
|
|
480
|
+
if (!isObject(value.set) || Object.keys(value.set).length === 0) {
|
|
481
|
+
check.fail(`${path}.set`, "expected a non-empty object of arguments to set");
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
return {
|
|
485
|
+
kind,
|
|
486
|
+
set: value.set,
|
|
487
|
+
because
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
return {
|
|
491
|
+
kind,
|
|
492
|
+
because
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
function readRule(check, path, value) {
|
|
496
|
+
if (!isObject(value)) {
|
|
497
|
+
check.fail(path, "expected an object");
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
check.keys(path, value, [
|
|
501
|
+
"id",
|
|
502
|
+
"title",
|
|
503
|
+
"when",
|
|
504
|
+
"effect"
|
|
505
|
+
]);
|
|
506
|
+
const id = check.text(`${path}.id`, value.id);
|
|
507
|
+
const effect = readEffect(check, `${path}.effect`, value.effect);
|
|
508
|
+
if (value.when === void 0) check.fail(`${path}.when`, "expected an object");
|
|
509
|
+
const when = readMatch(check, `${path}.when`, value.when ?? {});
|
|
510
|
+
if (id === void 0 || effect === void 0) return void 0;
|
|
511
|
+
const rule = {
|
|
512
|
+
id,
|
|
513
|
+
when,
|
|
514
|
+
effect
|
|
515
|
+
};
|
|
516
|
+
if (value.title !== void 0) {
|
|
517
|
+
const title = check.text(`${path}.title`, value.title);
|
|
518
|
+
if (title !== void 0) rule.title = title;
|
|
519
|
+
}
|
|
520
|
+
return rule;
|
|
521
|
+
}
|
|
522
|
+
/** Parse and validate a pack. Never throws; every problem lands in `errors`. */
|
|
523
|
+
function parsePack(source) {
|
|
524
|
+
let raw;
|
|
525
|
+
try {
|
|
526
|
+
raw = JSON.parse(source);
|
|
527
|
+
} catch (error) {
|
|
528
|
+
return {
|
|
529
|
+
ok: false,
|
|
530
|
+
errors: [{
|
|
531
|
+
path: "",
|
|
532
|
+
message: `invalid JSON: ${error instanceof Error ? error.message : String(error)}`
|
|
533
|
+
}]
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
const check = new Check();
|
|
537
|
+
if (!isObject(raw)) return {
|
|
538
|
+
ok: false,
|
|
539
|
+
errors: [{
|
|
540
|
+
path: "",
|
|
541
|
+
message: "expected an object"
|
|
542
|
+
}]
|
|
543
|
+
};
|
|
544
|
+
check.keys("", raw, [
|
|
545
|
+
"prae",
|
|
546
|
+
"pack",
|
|
547
|
+
"version",
|
|
548
|
+
"baseline",
|
|
549
|
+
"rules"
|
|
550
|
+
]);
|
|
551
|
+
if (raw.prae !== "0.1") check.fail(".prae", "expected `\"0.1\"`; this is the only pack version this build reads");
|
|
552
|
+
const name = check.text(".pack", raw.pack);
|
|
553
|
+
const version = check.text(".version", raw.version);
|
|
554
|
+
let baseline;
|
|
555
|
+
if (raw.baseline !== void 0) if (!isObject(raw.baseline)) check.fail(".baseline", "expected an object");
|
|
556
|
+
else {
|
|
557
|
+
check.keys(".baseline", raw.baseline, ["capabilities"]);
|
|
558
|
+
const caps = check.globs(".baseline.capabilities", raw.baseline.capabilities);
|
|
559
|
+
if (caps !== void 0) baseline = { capabilities: caps };
|
|
560
|
+
}
|
|
561
|
+
const rules = [];
|
|
562
|
+
if (!Array.isArray(raw.rules)) check.fail(".rules", "expected an array");
|
|
563
|
+
else {
|
|
564
|
+
const seen = /* @__PURE__ */ new Set();
|
|
565
|
+
raw.rules.forEach((entry, index) => {
|
|
566
|
+
const rule = readRule(check, `rules[${index}]`, entry);
|
|
567
|
+
if (rule === void 0) return;
|
|
568
|
+
if (seen.has(rule.id)) {
|
|
569
|
+
check.fail(`rules[${index}].id`, `duplicate rule id \`${rule.id}\``);
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
seen.add(rule.id);
|
|
573
|
+
rules.push(rule);
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
if (check.errors.length > 0) return {
|
|
577
|
+
ok: false,
|
|
578
|
+
errors: check.errors
|
|
579
|
+
};
|
|
580
|
+
if (name === void 0 || version === void 0) return {
|
|
581
|
+
ok: false,
|
|
582
|
+
errors: check.errors
|
|
583
|
+
};
|
|
584
|
+
return {
|
|
585
|
+
ok: true,
|
|
586
|
+
pack: {
|
|
587
|
+
prae: "0.1",
|
|
588
|
+
pack: name,
|
|
589
|
+
version,
|
|
590
|
+
...baseline === void 0 ? {} : { baseline },
|
|
591
|
+
rules
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
//#endregion
|
|
597
|
+
//#region src/playground/app.ts
|
|
598
|
+
const VERDICT_LABEL = {
|
|
599
|
+
allow: "ALLOWED",
|
|
600
|
+
ask: "HELD",
|
|
601
|
+
deny: "DENIED",
|
|
602
|
+
correct: "CORRECTED"
|
|
603
|
+
};
|
|
604
|
+
function esc(value) {
|
|
605
|
+
return String(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
606
|
+
}
|
|
607
|
+
/** `key=value` pairs, with numbers and booleans read as themselves. */
|
|
608
|
+
function parseArgs(text) {
|
|
609
|
+
const args = {};
|
|
610
|
+
for (const line of text.split("\n")) {
|
|
611
|
+
const trimmed = line.trim();
|
|
612
|
+
if (trimmed === "") continue;
|
|
613
|
+
const at = trimmed.indexOf("=");
|
|
614
|
+
if (at === -1) continue;
|
|
615
|
+
const key = trimmed.slice(0, at).trim();
|
|
616
|
+
const raw = trimmed.slice(at + 1).trim();
|
|
617
|
+
if (raw === "true") args[key] = true;
|
|
618
|
+
else if (raw === "false") args[key] = false;
|
|
619
|
+
else if (raw !== "" && !Number.isNaN(Number(raw))) args[key] = Number(raw);
|
|
620
|
+
else args[key] = raw;
|
|
621
|
+
}
|
|
622
|
+
return args;
|
|
623
|
+
}
|
|
624
|
+
function render(els) {
|
|
625
|
+
const parsed = parsePack(els.pack.value);
|
|
626
|
+
if (!parsed.ok) {
|
|
627
|
+
els.errors.innerHTML = `<div class="k" style="color:var(--veto-text)">PACK DID NOT PARSE</div><ul>${parsed.errors.map((e) => `<li><code>${esc(e.path || "<root>")}</code> — ${esc(e.message)}</li>`).join("")}</ul>`;
|
|
628
|
+
els.errors.hidden = false;
|
|
629
|
+
els.verdict.innerHTML = "<span class=\"big\" style=\"color:var(--muted)\">—</span>";
|
|
630
|
+
els.trace.innerHTML = "";
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
els.errors.hidden = true;
|
|
634
|
+
const capability = els.capability.value.trim();
|
|
635
|
+
const countValue = Number(els.counts.value);
|
|
636
|
+
const counts = capability !== "" && Number.isFinite(countValue) && countValue > 0 ? { [capability]: countValue } : void 0;
|
|
637
|
+
const decision = evaluate(parsed.pack, {
|
|
638
|
+
tool: els.tool.value.trim(),
|
|
639
|
+
...capability === "" ? {} : { capability },
|
|
640
|
+
args: parseArgs(els.args.value),
|
|
641
|
+
...counts === void 0 ? {} : { counts }
|
|
642
|
+
});
|
|
643
|
+
const label = VERDICT_LABEL[decision.kind] ?? decision.kind.toUpperCase();
|
|
644
|
+
els.verdict.innerHTML = `<span class="big v-${esc(decision.kind)}">${esc(label)}</span>${decision.rule === void 0 ? "" : `<span class="clause">${esc(decision.rule)} · ${esc(decision.reason ?? "")}</span>`}`;
|
|
645
|
+
els.trace.innerHTML = decision.trace.length === 0 ? `<div class="none">No rule matched. ${decision.evaluated} considered.</div>` : decision.trace.map((step$1) => `<div class="step">
|
|
646
|
+
<span class="effect e-${esc(step$1.effect)}">${esc(step$1.effect.toUpperCase())}</span>
|
|
647
|
+
<span class="id">${esc(step$1.rule)}</span>
|
|
648
|
+
<span class="why">${esc(step$1.because)}</span>
|
|
649
|
+
</div>`).join("") + `<div class="none">${decision.trace.length} matched of ${decision.evaluated} considered · strongest wins</div>`;
|
|
650
|
+
}
|
|
651
|
+
function start() {
|
|
652
|
+
const get = (id) => document.getElementById(id);
|
|
653
|
+
const els = {
|
|
654
|
+
pack: get("pack"),
|
|
655
|
+
tool: get("tool"),
|
|
656
|
+
args: get("args"),
|
|
657
|
+
capability: get("capability"),
|
|
658
|
+
counts: get("counts"),
|
|
659
|
+
verdict: get("verdict"),
|
|
660
|
+
trace: get("trace"),
|
|
661
|
+
errors: get("errors")
|
|
662
|
+
};
|
|
663
|
+
const update = () => render(els);
|
|
664
|
+
for (const el of [
|
|
665
|
+
els.pack,
|
|
666
|
+
els.tool,
|
|
667
|
+
els.args,
|
|
668
|
+
els.capability,
|
|
669
|
+
els.counts
|
|
670
|
+
]) el.addEventListener("input", update);
|
|
671
|
+
for (const button of document.querySelectorAll("[data-try]")) button.addEventListener("click", () => {
|
|
672
|
+
const spec = JSON.parse(button.dataset.try ?? "{}");
|
|
673
|
+
els.tool.value = spec.tool ?? "";
|
|
674
|
+
els.args.value = spec.args ?? "";
|
|
675
|
+
els.capability.value = spec.capability ?? "";
|
|
676
|
+
els.counts.value = spec.counts ?? "";
|
|
677
|
+
update();
|
|
678
|
+
});
|
|
679
|
+
update();
|
|
680
|
+
}
|
|
681
|
+
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start);
|
|
682
|
+
else start();
|
|
683
|
+
|
|
684
|
+
//#endregion
|
|
685
|
+
})();
|