prae-cli 0.3.0 → 0.4.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.
@@ -0,0 +1,419 @@
1
+ //#region ../kernel/src/command.ts
2
+ /** Wrappers whose first operand is the program actually being run. */
3
+ const WRAPPERS = new Set([
4
+ "sudo",
5
+ "command",
6
+ "env",
7
+ "nohup",
8
+ "time",
9
+ "xargs"
10
+ ]);
11
+ /** Wrapper options that consume the next token, so it is not the program. */
12
+ const OPTION_WITH_VALUE = new Set([
13
+ "-u",
14
+ "-g",
15
+ "-p",
16
+ "-h",
17
+ "-C",
18
+ "-r",
19
+ "-t",
20
+ "-U",
21
+ "-I",
22
+ "-n",
23
+ "-P",
24
+ "-L",
25
+ "-s"
26
+ ]);
27
+ const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
28
+ /**
29
+ * Split a line into pipeline segments of words, one scan, quote-aware.
30
+ *
31
+ * Splitting on `|` before parsing quotes is the classic mistake — it turns
32
+ * `echo "a | b"` into two segments — so quoting and separators are read
33
+ * together.
34
+ */
35
+ function scan(command) {
36
+ const parts = [];
37
+ let tokens = [];
38
+ let current = "";
39
+ let quote;
40
+ const endToken = () => {
41
+ if (current !== "") tokens.push(current);
42
+ current = "";
43
+ };
44
+ const endSegment = () => {
45
+ endToken();
46
+ if (tokens.length > 0) parts.push(tokens);
47
+ tokens = [];
48
+ };
49
+ for (let at = 0; at < command.length; at += 1) {
50
+ const ch = command[at];
51
+ if (quote !== void 0) {
52
+ if (ch === quote) quote = void 0;
53
+ else current += ch;
54
+ continue;
55
+ }
56
+ if (ch === "'" || ch === "\"") {
57
+ quote = ch;
58
+ continue;
59
+ }
60
+ if (ch === ";") {
61
+ endSegment();
62
+ continue;
63
+ }
64
+ if (ch === "|" || ch === "&") {
65
+ endSegment();
66
+ if (command[at + 1] === ch) at += 1;
67
+ continue;
68
+ }
69
+ if (/\s/.test(ch)) {
70
+ endToken();
71
+ continue;
72
+ }
73
+ current += ch;
74
+ }
75
+ endSegment();
76
+ return parts;
77
+ }
78
+ /** Decompose a command line into pipeline segments. */
79
+ function segments(command) {
80
+ const found = [];
81
+ for (const tokens of scan(command)) {
82
+ let at = 0;
83
+ while (at < tokens.length) {
84
+ const token = tokens[at];
85
+ if (ASSIGNMENT.test(token)) {
86
+ at += 1;
87
+ continue;
88
+ }
89
+ if (WRAPPERS.has(token)) {
90
+ at += 1;
91
+ while (at < tokens.length) {
92
+ const option = tokens[at];
93
+ if (!option.startsWith("-")) break;
94
+ at += OPTION_WITH_VALUE.has(option) ? 2 : 1;
95
+ }
96
+ continue;
97
+ }
98
+ break;
99
+ }
100
+ const name = tokens[at];
101
+ if (name === void 0) continue;
102
+ found.push({
103
+ name: (name.split("/").pop() ?? name).toLowerCase(),
104
+ tokens: tokens.slice(at + 1)
105
+ });
106
+ }
107
+ return found;
108
+ }
109
+ /** `git*` matches `git`, `github`; bare strings match exactly. */
110
+ function matches$1(pattern, value) {
111
+ if (pattern.endsWith("*")) return value.startsWith(pattern.slice(0, -1));
112
+ return value === pattern;
113
+ }
114
+ const list = (v) => v === void 0 ? [] : typeof v === "string" ? [v] : v;
115
+ /**
116
+ * Judge one command line against one matcher. Every present field must hold.
117
+ */
118
+ function commandMatches(match, command) {
119
+ const parts = segments(command);
120
+ if (parts.length === 0) return false;
121
+ const names = list(match.name);
122
+ const args = list(match.token);
123
+ const pipes = list(match.pipedTo);
124
+ if (names.length > 0) {
125
+ const hit = parts.filter((segment) => names.some((pattern) => matches$1(pattern, segment.name)));
126
+ if (hit.length === 0) return false;
127
+ if (args.length > 0 && !hit.some((segment) => args.every((wanted) => segment.tokens.some((token) => matches$1(wanted, token))))) return false;
128
+ } else if (args.length > 0) {
129
+ if (!parts.some((segment) => args.every((wanted) => segment.tokens.some((token) => matches$1(wanted, token))))) return false;
130
+ }
131
+ if (pipes.length > 0) {
132
+ if (!parts.slice(1).some((segment) => pipes.some((pattern) => matches$1(pattern, segment.name)))) return false;
133
+ }
134
+ return true;
135
+ }
136
+
137
+ //#endregion
138
+ //#region ../kernel/src/path.ts
139
+ /**
140
+ * Path normalization and glob containment, with no filesystem access.
141
+ *
142
+ * The contract, stated here because getting it wrong is how path policies
143
+ * become theatre: **this module compares strings.** It resolves `.` and `..`
144
+ * lexically so that `./src/../../etc/passwd` cannot pose as a workspace path,
145
+ * but it cannot see a symlink. A link inside an allowed directory pointing out
146
+ * of it will pass every check here.
147
+ *
148
+ * That is not a gap to paper over in the matcher — it is a gap the caller
149
+ * closes by resolving before it asks. `dsh-overlay-check` learned this the
150
+ * hard way during the plugin-store gate review: lexical containment accepted a
151
+ * symlink escape cleanly, and the fix was to resolve both the path and its
152
+ * nearest existing ancestor before deciding. Adapters that can touch the
153
+ * filesystem must resolve first and pass the resolved path in.
154
+ */
155
+ /** Split a path into segments, resolving `.` and `..` lexically. */
156
+ function normalize(path) {
157
+ const absolute = path.startsWith("/");
158
+ const out = [];
159
+ let escaped = false;
160
+ for (const raw of path.split(/[/\\]+/)) {
161
+ if (raw === "" || raw === ".") continue;
162
+ if (raw === "..") {
163
+ if (out.length > 0) out.pop();
164
+ else escaped = true;
165
+ continue;
166
+ }
167
+ out.push(raw);
168
+ }
169
+ return {
170
+ segments: out,
171
+ absolute,
172
+ escaped
173
+ };
174
+ }
175
+ /** Turn one glob segment into an anchored regular expression. */
176
+ function segmentPattern(segment) {
177
+ const body = segment.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*");
178
+ return /* @__PURE__ */ new RegExp(`^${body}$`);
179
+ }
180
+ /**
181
+ * Wildcard segment matching, where `**` spans zero or more segments.
182
+ *
183
+ * Two-pointer with backtracking rather than a recursive walk: the pattern
184
+ * comes from a policy pack and the path from a tool call, and a catastrophic
185
+ * backtrack in the hot path of every call would be a denial of service handed
186
+ * to whoever writes the pack.
187
+ */
188
+ function matchSegments(path, pattern) {
189
+ let i = 0;
190
+ let j = 0;
191
+ let starPattern = -1;
192
+ let starPath = 0;
193
+ while (i < path.length) {
194
+ const segment = j < pattern.length ? pattern[j] : void 0;
195
+ const here = path[i];
196
+ if (segment === "**") {
197
+ starPattern = j;
198
+ starPath = i;
199
+ j += 1;
200
+ continue;
201
+ }
202
+ if (segment !== void 0 && here !== void 0 && segmentPattern(segment).test(here)) {
203
+ i += 1;
204
+ j += 1;
205
+ continue;
206
+ }
207
+ if (starPattern >= 0) {
208
+ starPath += 1;
209
+ i = starPath;
210
+ j = starPattern + 1;
211
+ continue;
212
+ }
213
+ return false;
214
+ }
215
+ while (j < pattern.length && pattern[j] === "**") j += 1;
216
+ return j === pattern.length;
217
+ }
218
+ /**
219
+ * Is `path` contained by `glob`?
220
+ *
221
+ * A glob naming a directory contains everything under it: `./src` and
222
+ * `./src/**` both match `src/app/main.ts`. Writing the shorter form is the
223
+ * common intent, and treating it as "this exact directory entry only" is a
224
+ * trap that silently grants the whole subtree past the rule.
225
+ */
226
+ function within(path, glob) {
227
+ const target = normalize(path);
228
+ if (target.escaped) return false;
229
+ const trimmed = glob.trim();
230
+ if (!(trimmed === "**" || trimmed.startsWith("**/")) && target.absolute !== trimmed.startsWith("/")) return false;
231
+ const pattern = normalize(glob);
232
+ if (matchSegments(target.segments, pattern.segments)) return true;
233
+ return matchSegments(target.segments, [...pattern.segments, "**"]);
234
+ }
235
+ /** Is `path` inside any of these globs? */
236
+ function withinAny(path, globs) {
237
+ return globs.some((glob) => within(path, glob));
238
+ }
239
+ /**
240
+ * The host of a URL, or undefined when it is not a URL we can read.
241
+ *
242
+ * Unparseable input returns undefined, and callers treat that as "no host" —
243
+ * which for a `notIn` rule means the rule matches. Failing toward the rule is
244
+ * the only safe direction: a string we cannot parse is exactly the shape an
245
+ * evasion attempt takes.
246
+ */
247
+ function hostOf(url) {
248
+ try {
249
+ return new URL(url).hostname.toLowerCase();
250
+ } catch {
251
+ return;
252
+ }
253
+ }
254
+ /**
255
+ * Host list membership, where a leading dot means "this domain or below".
256
+ *
257
+ * `example.com` matches only that host. `.example.com` also matches
258
+ * `api.example.com`, and deliberately still matches `example.com` itself —
259
+ * an operator writing the suffix form means the organisation, not a rule that
260
+ * covers every subdomain except the one they first thought of.
261
+ */
262
+ function hostIn(host, list$1) {
263
+ return list$1.some((entry) => {
264
+ const want = entry.toLowerCase();
265
+ if (want.startsWith(".")) return host === want.slice(1) || host.endsWith(want);
266
+ return host === want;
267
+ });
268
+ }
269
+
270
+ //#endregion
271
+ //#region ../kernel/src/rule.ts
272
+ /**
273
+ * Strength ordering, used when several rules match one call.
274
+ *
275
+ * Higher wins. This is the property that makes a pack safe to extend: adding a
276
+ * rule can raise the strength of a decision and never lower it, so an operator
277
+ * appending to a pack can never accidentally widen what an agent may do.
278
+ */
279
+ const STRENGTH = {
280
+ warn: 1,
281
+ correct: 2,
282
+ ask: 3,
283
+ deny: 4
284
+ };
285
+
286
+ //#endregion
287
+ //#region ../kernel/src/evaluate.ts
288
+ function listed(value, want) {
289
+ if (want === void 0) return true;
290
+ if (value === void 0) return false;
291
+ return typeof want === "string" ? value === want : want.includes(value);
292
+ }
293
+ function argMatches(match, args) {
294
+ const has = Object.hasOwn(args, match.name);
295
+ if (match.present !== void 0 && match.present !== has) return false;
296
+ if (!has) return match.above === void 0 && match.below === void 0 && match.equals === void 0 && match.oneOf === void 0;
297
+ const value = args[match.name];
298
+ if (match.above !== void 0) {
299
+ if (typeof value !== "number" || !(value > match.above)) return false;
300
+ }
301
+ if (match.below !== void 0) {
302
+ if (typeof value !== "number" || !(value < match.below)) return false;
303
+ }
304
+ if (match.equals !== void 0 && value !== match.equals) return false;
305
+ if (match.oneOf !== void 0) {
306
+ if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") return false;
307
+ if (!match.oneOf.includes(value)) return false;
308
+ }
309
+ return true;
310
+ }
311
+ function matches(when, facts) {
312
+ if (!listed(facts.tool, when.tool)) return false;
313
+ if (!listed(facts.capability, when.capability)) return false;
314
+ if (!listed(facts.plugin, when.plugin)) return false;
315
+ if (!listed(facts.agent, when.agent)) return false;
316
+ if (when.command !== void 0) {
317
+ const raw = facts.args.command ?? facts.args.cmd ?? facts.args.script;
318
+ if (typeof raw !== "string") return false;
319
+ if (!commandMatches(when.command, raw)) return false;
320
+ }
321
+ if (when.path !== void 0) {
322
+ const raw = facts.args[when.path.arg ?? "path"];
323
+ if (typeof raw !== "string") return false;
324
+ if (when.path.inside !== void 0 && !withinAny(raw, when.path.inside)) return false;
325
+ if (when.path.outside !== void 0 && withinAny(raw, when.path.outside)) return false;
326
+ }
327
+ if (when.host !== void 0) {
328
+ const raw = facts.args[when.host.arg ?? "url"];
329
+ if (typeof raw !== "string") return false;
330
+ const host = hostOf(raw);
331
+ if (host === void 0) return when.host.notIn !== void 0;
332
+ if (when.host.in !== void 0 && !hostIn(host, when.host.in)) return false;
333
+ if (when.host.notIn !== void 0 && hostIn(host, when.host.notIn)) return false;
334
+ }
335
+ if (when.arg !== void 0) {
336
+ for (const one of when.arg) if (!argMatches(one, facts.args)) return false;
337
+ }
338
+ if (when.count !== void 0) {
339
+ const key = facts.capability;
340
+ if (key === void 0) return false;
341
+ if (!((facts.counts?.[key] ?? 0) > when.count.above)) return false;
342
+ }
343
+ if (when.secrets !== void 0) {
344
+ const floor = when.secrets.minConfidence ?? 0;
345
+ const wanted = when.secrets.types;
346
+ if ((facts.secrets ?? []).filter((finding) => finding.confidence >= floor && (wanted === void 0 || wanted.includes(finding.type))).length < (when.secrets.atLeast ?? 1)) return false;
347
+ }
348
+ return true;
349
+ }
350
+ function step(rule, effect) {
351
+ return {
352
+ rule: rule.id,
353
+ ...rule.title === void 0 ? {} : { title: rule.title },
354
+ effect: effect.kind,
355
+ because: effect.because
356
+ };
357
+ }
358
+ /**
359
+ * Decide one call against one pack.
360
+ *
361
+ * Every matching rule lands in the trace; the strongest effect decides. Ties
362
+ * go to the first rule in pack order, so a pack reads top-down the way its
363
+ * author wrote it.
364
+ *
365
+ * `correct` is the effect the rest of this ecosystem does not have. A rival
366
+ * guardrail can allow a call or refuse it; a rule here can also rewrite an
367
+ * argument and let it through, recording what it changed. Refusing is not
368
+ * always the safe option — an agent denied mid-task retries, and the retry is
369
+ * rarely safer than the clamp would have been.
370
+ */
371
+ function evaluate(pack, facts) {
372
+ const hits = [];
373
+ for (const rule of pack.rules) if (matches(rule.when, facts)) hits.push({
374
+ rule,
375
+ effect: rule.effect
376
+ });
377
+ if (hits.length === 0) return {
378
+ kind: "allow",
379
+ trace: [],
380
+ evaluated: pack.rules.length
381
+ };
382
+ hits.sort((a, b) => STRENGTH[b.effect.kind] - STRENGTH[a.effect.kind]);
383
+ const trace = hits.map((hit) => step(hit.rule, hit.effect));
384
+ const evaluated = pack.rules.length;
385
+ const strongest = hits[0];
386
+ if (strongest === void 0) return {
387
+ kind: "allow",
388
+ trace: [],
389
+ evaluated
390
+ };
391
+ const winner = strongest.effect.kind;
392
+ if (winner === "deny" || winner === "ask") return {
393
+ kind: winner,
394
+ rule: strongest.rule.id,
395
+ reason: strongest.effect.because,
396
+ trace,
397
+ evaluated
398
+ };
399
+ if (winner === "correct") {
400
+ const args = { ...facts.args };
401
+ for (const hit of hits) if (hit.effect.kind === "correct") Object.assign(args, hit.effect.set);
402
+ return {
403
+ kind: "correct",
404
+ rule: strongest.rule.id,
405
+ reason: strongest.effect.because,
406
+ args,
407
+ trace,
408
+ evaluated
409
+ };
410
+ }
411
+ return {
412
+ kind: "allow",
413
+ trace,
414
+ evaluated
415
+ };
416
+ }
417
+
418
+ //#endregion
419
+ export { evaluate as t };
package/lib/index.js CHANGED
@@ -1,5 +1,6 @@
1
- import { a as planWrite, i as dshHome, n as applyWrite, o as praeRow, r as defaultProfile, s as profileAt, t as main } from "./cli-DZ0FPdSr.js";
2
- import "./src-DGWTvmRZ.js";
3
- import "./views-CLLfJPWB.js";
1
+ import "./evaluate-BilZez15.js";
2
+ import { a as planWrite, i as dshHome, n as applyWrite, o as praeRow, r as defaultProfile, s as profileAt, t as main } from "./cli-BvAYSK5w.js";
3
+ import "./src-CAUNqZJ1.js";
4
+ import "./views-DalPkBZZ.js";
4
5
 
5
6
  export { applyWrite, defaultProfile, dshHome, main, planWrite, praeRow, profileAt };
@@ -1,4 +1,4 @@
1
- import { c as renderShell, o as esc, s as renderNav, t as navFor } from "./views-CLLfJPWB.js";
1
+ import { c as renderShell, o as esc, s as renderNav, t as navFor } from "./views-DalPkBZZ.js";
2
2
 
3
3
  //#region src/console/instructions.ts
4
4
  const STYLE = `
@@ -0,0 +1,208 @@
1
+ import { t as evaluate } from "./evaluate-BilZez15.js";
2
+ import { a as scan, n as MAX_SCAN_BYTES } from "./src-Br1tGPtS.js";
3
+ import { readFile, readdir, stat } from "node:fs/promises";
4
+ import { resolve, sep } from "node:path";
5
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
6
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
8
+
9
+ //#region src/mcp.ts
10
+ /** The one denial shape, agent-readable. Mirrors the dsh plugin's. */
11
+ function structuredDeny(kind, resource, types, policy, message) {
12
+ return JSON.stringify({
13
+ decision: kind,
14
+ reason: types.length > 0 ? "secret_detected" : "policy",
15
+ resource,
16
+ ...types.length > 0 ? { secret_types: [...new Set(types)] } : {},
17
+ ...policy === void 0 ? {} : { policy },
18
+ retry: false,
19
+ message
20
+ });
21
+ }
22
+ /**
23
+ * Judge one read the way the dsh plugin does, minus the harness: confine to
24
+ * the root, inspect the content, evaluate the pack, seal the row.
25
+ */
26
+ async function judgeRead(options, requested) {
27
+ const path = resolve(options.root, requested);
28
+ const rootPrefix = options.root.endsWith(sep) ? options.root : options.root + sep;
29
+ const at = (/* @__PURE__ */ new Date()).toISOString();
30
+ const record = (partial) => {
31
+ options.ledger?.append({
32
+ at,
33
+ ...options.session === void 0 ? {} : { session: options.session },
34
+ tool: "read_file",
35
+ pack: {
36
+ name: options.pack.pack,
37
+ version: options.pack.version
38
+ },
39
+ ...partial
40
+ });
41
+ };
42
+ if (path !== options.root && !path.startsWith(rootPrefix)) {
43
+ const refusal = structuredDeny("deny", requested, [], "root", `reads are confined to ${options.root}`);
44
+ record({
45
+ subject: requested,
46
+ capability: "fs:read",
47
+ verdict: "deny",
48
+ rule: "root",
49
+ reason: `outside the served root ${options.root}`,
50
+ trace: []
51
+ });
52
+ return {
53
+ ok: false,
54
+ refusal
55
+ };
56
+ }
57
+ let content;
58
+ try {
59
+ const info = await stat(path);
60
+ if (info.size > MAX_SCAN_BYTES) {
61
+ const refusal = structuredDeny("deny", requested, [], "scan-limit", `files over ${MAX_SCAN_BYTES} bytes are not served through the gate`);
62
+ record({
63
+ subject: path,
64
+ capability: "fs:read",
65
+ verdict: "deny",
66
+ rule: "scan-limit",
67
+ reason: `${info.size} bytes exceeds the inspection limit`,
68
+ trace: []
69
+ });
70
+ return {
71
+ ok: false,
72
+ refusal
73
+ };
74
+ }
75
+ content = await readFile(path, "utf8");
76
+ } catch {
77
+ return {
78
+ ok: false,
79
+ refusal: JSON.stringify({
80
+ decision: "error",
81
+ reason: "unreadable",
82
+ resource: requested,
83
+ retry: false,
84
+ message: "the file does not exist or cannot be read"
85
+ })
86
+ };
87
+ }
88
+ const findings = scan(content);
89
+ const decision = evaluate(options.pack, {
90
+ tool: "read_file",
91
+ capability: "fs:read",
92
+ args: { path },
93
+ ...findings.length > 0 ? { secrets: findings } : {}
94
+ });
95
+ record({
96
+ subject: path,
97
+ capability: "fs:read",
98
+ verdict: decision.kind === "correct" ? "deny" : decision.kind,
99
+ ...decision.rule === void 0 ? {} : { rule: decision.rule },
100
+ ...decision.reason === void 0 ? {} : { reason: decision.reason },
101
+ trace: decision.trace,
102
+ ...findings.length > 0 ? { secrets: findings.map(({ rule, type, line, confidence }) => ({
103
+ rule,
104
+ type,
105
+ line,
106
+ confidence
107
+ })) } : {}
108
+ });
109
+ if (decision.kind === "deny" || decision.kind === "ask" || decision.kind === "correct") return {
110
+ ok: false,
111
+ refusal: structuredDeny(decision.kind === "ask" ? "ask" : "deny", path, findings.map((f) => f.type), decision.rule, decision.reason ?? "refused by policy")
112
+ };
113
+ return {
114
+ ok: true,
115
+ content
116
+ };
117
+ }
118
+ /** Mount the gate on stdio and serve until the client hangs up. */
119
+ async function serveMcp(options) {
120
+ const server = new Server({
121
+ name: "prae-gate",
122
+ version: "0.4.0"
123
+ }, { capabilities: { tools: {} } });
124
+ server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: [{
125
+ name: "read_file",
126
+ description: "Read a file through the PRAE gate. Contents are inspected for credentials before they are returned; a refusal is structured JSON and never contains the credential.",
127
+ inputSchema: {
128
+ type: "object",
129
+ properties: { path: {
130
+ type: "string",
131
+ description: "File path, relative to the served root."
132
+ } },
133
+ required: ["path"]
134
+ }
135
+ }, {
136
+ name: "list_dir",
137
+ description: "List a directory under the served root.",
138
+ inputSchema: {
139
+ type: "object",
140
+ properties: { path: {
141
+ type: "string",
142
+ description: "Directory path, relative to the served root."
143
+ } },
144
+ required: ["path"]
145
+ }
146
+ }] }));
147
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
148
+ const name = request.params.name;
149
+ const args = request.params.arguments ?? {};
150
+ const requested = typeof args.path === "string" ? args.path : void 0;
151
+ if (requested === void 0) return {
152
+ content: [{
153
+ type: "text",
154
+ text: "missing required argument `path`"
155
+ }],
156
+ isError: true
157
+ };
158
+ if (name === "read_file") {
159
+ const result = await judgeRead(options, requested);
160
+ return result.ok ? { content: [{
161
+ type: "text",
162
+ text: result.content
163
+ }] } : {
164
+ content: [{
165
+ type: "text",
166
+ text: result.refusal
167
+ }],
168
+ isError: true
169
+ };
170
+ }
171
+ if (name === "list_dir") {
172
+ const path = resolve(options.root, requested);
173
+ const rootPrefix = options.root.endsWith(sep) ? options.root : options.root + sep;
174
+ if (path !== options.root && !path.startsWith(rootPrefix)) return {
175
+ content: [{
176
+ type: "text",
177
+ text: `listing is confined to ${options.root}`
178
+ }],
179
+ isError: true
180
+ };
181
+ try {
182
+ return { content: [{
183
+ type: "text",
184
+ text: (await readdir(path, { withFileTypes: true })).map((entry) => entry.isDirectory() ? `${entry.name}/` : entry.name).join("\n")
185
+ }] };
186
+ } catch {
187
+ return {
188
+ content: [{
189
+ type: "text",
190
+ text: "not a readable directory"
191
+ }],
192
+ isError: true
193
+ };
194
+ }
195
+ }
196
+ return {
197
+ content: [{
198
+ type: "text",
199
+ text: `unknown tool \`${name}\``
200
+ }],
201
+ isError: true
202
+ };
203
+ });
204
+ await server.connect(new StdioServerTransport());
205
+ }
206
+
207
+ //#endregion
208
+ export { judgeRead, serveMcp };
package/lib/playground.js CHANGED
@@ -343,6 +343,11 @@
343
343
  if (key === void 0) return false;
344
344
  if (!((facts.counts?.[key] ?? 0) > when.count.above)) return false;
345
345
  }
346
+ if (when.secrets !== void 0) {
347
+ const floor = when.secrets.minConfidence ?? 0;
348
+ const wanted = when.secrets.types;
349
+ if ((facts.secrets ?? []).filter((finding) => finding.confidence >= floor && (wanted === void 0 || wanted.includes(finding.type))).length < (when.secrets.atLeast ?? 1)) return false;
350
+ }
346
351
  return true;
347
352
  }
348
353
  function step(rule, effect) {
@@ -579,6 +584,33 @@
579
584
  else out.present = value.present;
580
585
  return out;
581
586
  }
587
+ function readSecretsMatch(check, path, value) {
588
+ if (!isObject(value)) {
589
+ check.fail(path, "expected an object");
590
+ return;
591
+ }
592
+ check.keys(path, value, [
593
+ "types",
594
+ "minConfidence",
595
+ "atLeast"
596
+ ]);
597
+ const out = {};
598
+ if (value.types !== void 0) {
599
+ const types = check.globs(`${path}.types`, value.types);
600
+ if (types !== void 0) out.types = types;
601
+ }
602
+ if (value.minConfidence !== void 0) {
603
+ const n = check.number(`${path}.minConfidence`, value.minConfidence);
604
+ if (n !== void 0) if (n < 0 || n > 1) check.fail(`${path}.minConfidence`, "expected a number between 0 and 1");
605
+ else out.minConfidence = n;
606
+ }
607
+ if (value.atLeast !== void 0) {
608
+ const n = check.number(`${path}.atLeast`, value.atLeast);
609
+ if (n !== void 0) if (!Number.isInteger(n) || n < 1) check.fail(`${path}.atLeast`, "expected an integer of at least 1");
610
+ else out.atLeast = n;
611
+ }
612
+ return out;
613
+ }
582
614
  function readMatch(check, path, value) {
583
615
  if (!isObject(value)) {
584
616
  check.fail(path, "expected an object");
@@ -593,7 +625,8 @@
593
625
  "path",
594
626
  "host",
595
627
  "arg",
596
- "count"
628
+ "count",
629
+ "secrets"
597
630
  ]);
598
631
  const out = {};
599
632
  for (const key of [
@@ -632,6 +665,10 @@
632
665
  const above = check.number(`${path}.count.above`, value.count.above);
633
666
  if (above !== void 0) out.count = { above };
634
667
  }
668
+ if (value.secrets !== void 0) {
669
+ const match = readSecretsMatch(check, `${path}.secrets`, value.secrets);
670
+ if (match !== void 0) out.secrets = match;
671
+ }
635
672
  return out;
636
673
  }
637
674
  function readEffect(check, path, value) {