prae-cli 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/lib/bin.js +2 -1
- package/lib/{cli-DGVWMDT0.js → cli-B6X4uAj5.js} +121 -928
- package/lib/index.js +2 -1
- package/lib/instructions-ijuj4IP1.js +133 -0
- package/lib/src-C5neqxZc.js +274 -0
- package/lib/src-Dd2_8m2g.js +153 -0
- package/lib/views-BU0sOciR.js +929 -0
- package/package.json +6 -4
package/lib/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
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-
|
|
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-B6X4uAj5.js";
|
|
2
|
+
import "./views-BU0sOciR.js";
|
|
2
3
|
|
|
3
4
|
export { applyWrite, defaultProfile, dshHome, main, planWrite, praeRow, profileAt };
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { c as renderShell, o as esc, s as renderNav, t as navFor } from "./views-BU0sOciR.js";
|
|
2
|
+
|
|
3
|
+
//#region src/console/instructions.ts
|
|
4
|
+
const STYLE = `
|
|
5
|
+
.stack { padding: 22px 30px 8px; }
|
|
6
|
+
.layer {
|
|
7
|
+
display: grid; grid-template-columns: 92px 1fr 120px; gap: 16px;
|
|
8
|
+
padding: 12px 0; border-bottom: 1px solid var(--line-faint);
|
|
9
|
+
font-family: var(--proof); font-size: 12px; align-items: baseline;
|
|
10
|
+
}
|
|
11
|
+
.layer .who { letter-spacing: 0.1em; font-size: 11px; }
|
|
12
|
+
.layer .who.operator { color: var(--signal-text); }
|
|
13
|
+
.layer .who.project { color: var(--muted); }
|
|
14
|
+
.layer .file { color: var(--ink-row); overflow-wrap: anywhere; }
|
|
15
|
+
.layer .count { color: var(--muted); text-align: right; }
|
|
16
|
+
|
|
17
|
+
.proposal {
|
|
18
|
+
padding: 20px 30px; border-bottom: 1px solid var(--line);
|
|
19
|
+
display: grid; grid-template-columns: 1fr 200px; gap: 24px;
|
|
20
|
+
}
|
|
21
|
+
.proposal h3 {
|
|
22
|
+
font-size: 16px; font-weight: 600; letter-spacing: -0.01em; margin: 0 0 10px;
|
|
23
|
+
}
|
|
24
|
+
.quote {
|
|
25
|
+
border-left: 2px solid var(--line-strong); padding: 6px 0 6px 14px;
|
|
26
|
+
font-family: var(--proof); font-size: 12px; color: var(--ink-row);
|
|
27
|
+
line-height: 1.7; margin: 0 0 10px;
|
|
28
|
+
}
|
|
29
|
+
.quote .where {
|
|
30
|
+
display: block; color: var(--muted); font-size: 11px; margin-top: 6px;
|
|
31
|
+
}
|
|
32
|
+
.reading { font-size: 13px; color: var(--secondary); margin: 0 0 10px; }
|
|
33
|
+
.matcher {
|
|
34
|
+
font-family: var(--proof); font-size: 11.5px; color: var(--dim);
|
|
35
|
+
background: var(--sunk); padding: 8px 10px; border-radius: 2px;
|
|
36
|
+
overflow-wrap: anywhere;
|
|
37
|
+
}
|
|
38
|
+
.verdict-col { text-align: right; }
|
|
39
|
+
.verdict-col .kind {
|
|
40
|
+
font-family: var(--proof); font-size: 20px; letter-spacing: 0.08em;
|
|
41
|
+
font-weight: 600;
|
|
42
|
+
}
|
|
43
|
+
.verdict-col .conf {
|
|
44
|
+
font-family: var(--proof); font-size: 10px; letter-spacing: 0.12em;
|
|
45
|
+
color: var(--muted); margin-top: 6px;
|
|
46
|
+
}
|
|
47
|
+
.context-only { padding: 22px 30px 30px; }
|
|
48
|
+
.context-only ul {
|
|
49
|
+
margin: 10px 0 0; padding-left: 18px; font-size: 13px; line-height: 1.9;
|
|
50
|
+
color: var(--secondary); columns: 2; column-gap: 40px;
|
|
51
|
+
}
|
|
52
|
+
@media (max-width: 860px) {
|
|
53
|
+
.proposal { grid-template-columns: 1fr; }
|
|
54
|
+
.verdict-col { text-align: left; }
|
|
55
|
+
.context-only ul { columns: 1; }
|
|
56
|
+
}
|
|
57
|
+
@media print { .context-only ul { columns: 1; } }
|
|
58
|
+
`;
|
|
59
|
+
function renderInstructions(data, read, found) {
|
|
60
|
+
const layers = read.files.map((file) => ` <div class="layer">
|
|
61
|
+
<div class="who ${esc(file.layer)}">${esc(file.layer.toUpperCase())}</div>
|
|
62
|
+
<div class="file">${esc(file.path)}</div>
|
|
63
|
+
<div class="count">${file.sections.reduce((n, s) => n + s.statements.length, 0)} statements</div>
|
|
64
|
+
</div>`).join("\n");
|
|
65
|
+
const proposals = found.proposals.map((proposal) => ` <section class="proposal">
|
|
66
|
+
<div>
|
|
67
|
+
<h3>${esc(proposal.rule.id)} · ${esc(proposal.rule.title ?? "")}</h3>
|
|
68
|
+
<blockquote class="quote">${esc(proposal.from.text)}
|
|
69
|
+
<span class="where">${esc(proposal.from.path)}:${proposal.from.line} · ${esc(proposal.from.layer)}</span>
|
|
70
|
+
</blockquote>
|
|
71
|
+
<p class="reading">Read as: ${esc(proposal.why)}.</p>
|
|
72
|
+
<div class="matcher">${esc(JSON.stringify(proposal.rule.when))}</div>
|
|
73
|
+
</div>
|
|
74
|
+
<div class="verdict-col">
|
|
75
|
+
<div class="kind e-${esc(proposal.rule.effect.kind)}">${esc(proposal.rule.effect.kind.toUpperCase())}</div>
|
|
76
|
+
<div class="conf">${esc(proposal.confidence.toUpperCase())} CONFIDENCE</div>
|
|
77
|
+
</div>
|
|
78
|
+
</section>`).join("\n");
|
|
79
|
+
const body = `<style>${STYLE}</style>
|
|
80
|
+
<section class="stats">
|
|
81
|
+
<div class="stat"><div class="v">${read.files.length}</div><div class="k">FILES</div></div>
|
|
82
|
+
<div class="stat"><div class="v">${read.statements.length}</div><div class="k">STATEMENTS</div></div>
|
|
83
|
+
<div class="stat"><div class="v hold">${found.proposals.length}</div><div class="k">PROPOSED</div></div>
|
|
84
|
+
<div class="stat"><div class="v">${found.skipped.length}</div><div class="k">CONTEXT ONLY</div></div>
|
|
85
|
+
</section>
|
|
86
|
+
|
|
87
|
+
<section class="sect">
|
|
88
|
+
<h2>Precedence</h2>
|
|
89
|
+
<p>Least specific first. An operator file is named by the profile and
|
|
90
|
+
applies to every repository the agent is pointed at; a project file was
|
|
91
|
+
found by walking up from the work and applies only to the checkout that
|
|
92
|
+
shipped it.</p>
|
|
93
|
+
</section>
|
|
94
|
+
<div class="stack">
|
|
95
|
+
${layers}
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
<section class="sect">
|
|
99
|
+
<h2>Proposed</h2>
|
|
100
|
+
<p>Nothing here is in force. Each rule is a <em>reading</em> of the
|
|
101
|
+
sentence beside it, and the only thing that turns a reading into policy
|
|
102
|
+
is a person putting it in a pack. Read every one against its sentence
|
|
103
|
+
before it enforces anything.</p>
|
|
104
|
+
</section>
|
|
105
|
+
${found.proposals.length === 0 ? " <div class=\"empty\">Nothing was proposed. On most real instruction files that is the expected result, not a failure.</div>" : proposals}
|
|
106
|
+
|
|
107
|
+
<section class="context-only">
|
|
108
|
+
<div class="k" style="font-family:var(--proof);font-size:10px;letter-spacing:0.14em;color:var(--muted)">
|
|
109
|
+
${found.skipped.length} STATEMENTS PROPOSED NOTHING AND REMAIN CONTEXT
|
|
110
|
+
</div>
|
|
111
|
+
<p style="font-size:13px;color:var(--secondary);max-width:76ch;margin:10px 0 0">
|
|
112
|
+
Most engineering guidance is about code that gets written, not calls an
|
|
113
|
+
agent makes. These are served to the agent and sealed into the ledger;
|
|
114
|
+
they are not enforced, and pretending otherwise would be inventing
|
|
115
|
+
policy nobody wrote.
|
|
116
|
+
</p>
|
|
117
|
+
<ul>
|
|
118
|
+
${found.skipped.slice(0, 40).map((statement) => ` <li>${esc(statement.text)}</li>`).join("\n")}
|
|
119
|
+
</ul>
|
|
120
|
+
</section>`;
|
|
121
|
+
return renderShell({
|
|
122
|
+
title: "Instructions",
|
|
123
|
+
nav: renderNav(navFor(data), "Instructions"),
|
|
124
|
+
head: ` <header class="head">
|
|
125
|
+
<div><h1>Instructions</h1><div class="meta">${esc(read.hash.slice(0, 16))} · ${read.files.length} files · ${found.proposals.length} proposed, unreviewed</div></div>
|
|
126
|
+
<div class="actions"><button type="button" class="btn" onclick="window.print()">Export evidence</button></div>
|
|
127
|
+
</header>`,
|
|
128
|
+
body
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
//#endregion
|
|
133
|
+
export { renderInstructions };
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { readFileSync, statSync } from "node:fs";
|
|
4
|
+
|
|
5
|
+
//#region ../instructions/src/parse.ts
|
|
6
|
+
const HEADING = /^(#{1,6})\s+(.*)$/;
|
|
7
|
+
const BULLET = /^\s*(?:[-*+]|\d+[.)])\s+(.*)$/;
|
|
8
|
+
const FENCE = /^\s*(?:```|~~~)/;
|
|
9
|
+
const COMMENT_OPEN = /^\s*<!--/;
|
|
10
|
+
const COMMENT_CLOSE = /-->/;
|
|
11
|
+
/** How far a continuation line must be indented to belong to the bullet above. */
|
|
12
|
+
const CONTINUATION = /^\s{1,}\S/;
|
|
13
|
+
/** Trim, drop emphasis markers that carry no meaning for a reviewer. */
|
|
14
|
+
function clean(text) {
|
|
15
|
+
return text.trim().replace(/\s+/g, " ");
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Parse an instruction file.
|
|
19
|
+
*
|
|
20
|
+
* `.cursorrules` and friends are plain text: every non-empty line is a
|
|
21
|
+
* statement, because there is no structure to read and guessing at one would
|
|
22
|
+
* invent provenance that is not in the file.
|
|
23
|
+
*/
|
|
24
|
+
function parseInstructions(text, format = "markdown") {
|
|
25
|
+
const hash = createHash("sha256").update(text).digest("hex");
|
|
26
|
+
const lines = text.split("\n");
|
|
27
|
+
if (format === "text") {
|
|
28
|
+
const statements = [];
|
|
29
|
+
for (const [index, raw] of lines.entries()) {
|
|
30
|
+
const line = clean(raw);
|
|
31
|
+
if (line === "" || line.startsWith("#")) continue;
|
|
32
|
+
statements.push({
|
|
33
|
+
text: line,
|
|
34
|
+
line: index + 1,
|
|
35
|
+
kind: "bullet",
|
|
36
|
+
headingPath: []
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
format,
|
|
41
|
+
text,
|
|
42
|
+
hash,
|
|
43
|
+
sections: [{
|
|
44
|
+
headingPath: [],
|
|
45
|
+
level: 0,
|
|
46
|
+
statements
|
|
47
|
+
}]
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const sections = [];
|
|
51
|
+
let path = [];
|
|
52
|
+
let current = {
|
|
53
|
+
level: 0,
|
|
54
|
+
statements: []
|
|
55
|
+
};
|
|
56
|
+
let currentPath = [];
|
|
57
|
+
let fenced = false;
|
|
58
|
+
let commented = false;
|
|
59
|
+
/** An open bullet, which an indented line below it continues. */
|
|
60
|
+
let bulletText;
|
|
61
|
+
/** Consecutive unindented prose lines, which are one wrapped sentence. */
|
|
62
|
+
let paragraph;
|
|
63
|
+
const push = (held, kind) => {
|
|
64
|
+
if (held === void 0) return;
|
|
65
|
+
const joined = clean(held.text.join(" "));
|
|
66
|
+
if (joined !== "") current.statements.push({
|
|
67
|
+
text: joined,
|
|
68
|
+
line: held.line,
|
|
69
|
+
kind,
|
|
70
|
+
headingPath: currentPath
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
const flush = () => {
|
|
74
|
+
push(bulletText, "bullet");
|
|
75
|
+
bulletText = void 0;
|
|
76
|
+
push(paragraph, "paragraph");
|
|
77
|
+
paragraph = void 0;
|
|
78
|
+
};
|
|
79
|
+
const flushSection = () => {
|
|
80
|
+
flush();
|
|
81
|
+
if (current.heading === void 0 && current.statements.length === 0) return;
|
|
82
|
+
sections.push({
|
|
83
|
+
...current.heading === void 0 ? {} : { heading: current.heading },
|
|
84
|
+
headingPath: currentPath,
|
|
85
|
+
level: current.level,
|
|
86
|
+
statements: current.statements
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
for (const [index, raw] of lines.entries()) {
|
|
90
|
+
if (commented) {
|
|
91
|
+
if (COMMENT_CLOSE.test(raw)) commented = false;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (COMMENT_OPEN.test(raw)) {
|
|
95
|
+
flush();
|
|
96
|
+
if (!COMMENT_CLOSE.test(raw)) commented = true;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (FENCE.test(raw)) {
|
|
100
|
+
flush();
|
|
101
|
+
fenced = !fenced;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (fenced) continue;
|
|
105
|
+
const heading = HEADING.exec(raw);
|
|
106
|
+
if (heading?.[1] !== void 0 && heading[2] !== void 0) {
|
|
107
|
+
flushSection();
|
|
108
|
+
const level = heading[1].length;
|
|
109
|
+
const title = clean(heading[2]);
|
|
110
|
+
path = [...path.slice(0, level - 1), title];
|
|
111
|
+
currentPath = path;
|
|
112
|
+
current = {
|
|
113
|
+
heading: title,
|
|
114
|
+
level,
|
|
115
|
+
statements: []
|
|
116
|
+
};
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const bullet = BULLET.exec(raw);
|
|
120
|
+
if (bullet?.[1] !== void 0) {
|
|
121
|
+
flush();
|
|
122
|
+
bulletText = {
|
|
123
|
+
text: [bullet[1]],
|
|
124
|
+
line: index + 1
|
|
125
|
+
};
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (clean(raw) === "") {
|
|
129
|
+
flush();
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (bulletText !== void 0 && CONTINUATION.test(raw)) {
|
|
133
|
+
bulletText.text.push(raw);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
push(bulletText, "bullet");
|
|
137
|
+
bulletText = void 0;
|
|
138
|
+
if (paragraph === void 0) paragraph = {
|
|
139
|
+
text: [raw],
|
|
140
|
+
line: index + 1
|
|
141
|
+
};
|
|
142
|
+
else paragraph.text.push(raw);
|
|
143
|
+
}
|
|
144
|
+
flushSection();
|
|
145
|
+
return {
|
|
146
|
+
format,
|
|
147
|
+
text,
|
|
148
|
+
hash,
|
|
149
|
+
sections
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/** Every statement in the document, in file order. */
|
|
153
|
+
function statementsOf(doc) {
|
|
154
|
+
return doc.sections.flatMap((section) => section.statements);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
//#endregion
|
|
158
|
+
//#region ../instructions/src/discover.ts
|
|
159
|
+
/** The formats each recognised filename is read as. */
|
|
160
|
+
const FORMATS = {
|
|
161
|
+
"AGENTS.md": "markdown",
|
|
162
|
+
"CLAUDE.md": "markdown",
|
|
163
|
+
".cursorrules": "text",
|
|
164
|
+
".windsurfrules": "text"
|
|
165
|
+
};
|
|
166
|
+
const DEFAULT_CANDIDATES = Object.keys(FORMATS);
|
|
167
|
+
const DEFAULT_ROOT_MARKERS = [
|
|
168
|
+
".git",
|
|
169
|
+
"package.json",
|
|
170
|
+
"pyproject.toml",
|
|
171
|
+
"go.mod"
|
|
172
|
+
];
|
|
173
|
+
const DEFAULT_MAX_BYTES = 256 * 1024;
|
|
174
|
+
const DEFAULT_MAX_DEPTH = 24;
|
|
175
|
+
function exists(path) {
|
|
176
|
+
try {
|
|
177
|
+
statSync(path);
|
|
178
|
+
return true;
|
|
179
|
+
} catch {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Walk up from `from`, collecting instruction files, outermost first.
|
|
185
|
+
*
|
|
186
|
+
* A file larger than `maxBytes` is skipped rather than truncated: half an
|
|
187
|
+
* instruction file is worse than none, because the half that is missing is
|
|
188
|
+
* invisible to whoever reads the result.
|
|
189
|
+
*/
|
|
190
|
+
function discover(from, options = {}) {
|
|
191
|
+
const candidates = options.candidates ?? DEFAULT_CANDIDATES;
|
|
192
|
+
const markers = options.rootMarkers ?? DEFAULT_ROOT_MARKERS;
|
|
193
|
+
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
194
|
+
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
195
|
+
const dirs = [];
|
|
196
|
+
let dir = resolve(from);
|
|
197
|
+
for (let depth = 0; depth < maxDepth; depth += 1) {
|
|
198
|
+
dirs.push(dir);
|
|
199
|
+
if (markers.some((marker) => exists(join(dir, marker)))) break;
|
|
200
|
+
const parent = dirname(dir);
|
|
201
|
+
if (parent === dir) break;
|
|
202
|
+
dir = parent;
|
|
203
|
+
}
|
|
204
|
+
const found = [];
|
|
205
|
+
for (const directory of dirs.reverse()) for (const name of candidates) {
|
|
206
|
+
const path = join(directory, name);
|
|
207
|
+
if (!exists(path)) continue;
|
|
208
|
+
let size;
|
|
209
|
+
try {
|
|
210
|
+
size = statSync(path).size;
|
|
211
|
+
} catch {
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (size > maxBytes) continue;
|
|
215
|
+
let text;
|
|
216
|
+
try {
|
|
217
|
+
text = readFileSync(path, "utf8");
|
|
218
|
+
} catch {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (text.trim() === "") continue;
|
|
222
|
+
found.push({
|
|
223
|
+
...parseInstructions(text, FORMATS[name] ?? "markdown"),
|
|
224
|
+
path,
|
|
225
|
+
name,
|
|
226
|
+
layer: "project",
|
|
227
|
+
depth: found.length
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
return found;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
//#endregion
|
|
234
|
+
//#region ../instructions/src/index.ts
|
|
235
|
+
/**
|
|
236
|
+
* Read every instruction file that governs work at `from`.
|
|
237
|
+
*
|
|
238
|
+
* Ordered least specific to most: operator files first, then project files
|
|
239
|
+
* outermost-in. A reader looking at the stack can see which layer a sentence
|
|
240
|
+
* came from without opening anything.
|
|
241
|
+
*/
|
|
242
|
+
function ingest(from, options = {}) {
|
|
243
|
+
const operator = [];
|
|
244
|
+
for (const path of options.operator ?? []) {
|
|
245
|
+
let text;
|
|
246
|
+
try {
|
|
247
|
+
text = readFileSync(path, "utf8");
|
|
248
|
+
} catch {
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (text.trim() === "") continue;
|
|
252
|
+
const name = basename(path);
|
|
253
|
+
operator.push({
|
|
254
|
+
...parseInstructions(text, FORMATS[name] ?? "markdown"),
|
|
255
|
+
path,
|
|
256
|
+
name,
|
|
257
|
+
layer: "operator",
|
|
258
|
+
depth: operator.length
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
const files = [...operator, ...discover(from, options)];
|
|
262
|
+
return {
|
|
263
|
+
files,
|
|
264
|
+
statements: files.flatMap((file) => statementsOf(file).map((statement) => ({
|
|
265
|
+
...statement,
|
|
266
|
+
path: file.path,
|
|
267
|
+
layer: file.layer
|
|
268
|
+
}))),
|
|
269
|
+
hash: createHash("sha256").update(files.map((file) => `${file.layer}:${file.path}:${file.hash}`).join("\n")).digest("hex")
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
//#endregion
|
|
274
|
+
export { DEFAULT_CANDIDATES, DEFAULT_ROOT_MARKERS, FORMATS, discover, ingest, parseInstructions, statementsOf };
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
//#region ../extract/src/index.ts
|
|
2
|
+
/** Backticked or quoted literals, which is where a real path or host appears. */
|
|
3
|
+
function literals(text) {
|
|
4
|
+
const found = /* @__PURE__ */ new Set();
|
|
5
|
+
for (const match of text.matchAll(/`([^`]+)`|"([^"]+)"|'([^']+)'/g)) {
|
|
6
|
+
const value = (match[1] ?? match[2] ?? match[3] ?? "").trim();
|
|
7
|
+
if (value !== "") found.add(value);
|
|
8
|
+
}
|
|
9
|
+
return [...found];
|
|
10
|
+
}
|
|
11
|
+
const PATH_LIKE = /^[./~]|\/$|^[\w.-]+\/|\*\*/;
|
|
12
|
+
const HOST_LIKE = /^(?:https?:\/\/)?([a-z0-9-]+\.)+[a-z]{2,}(?:\/|$)/i;
|
|
13
|
+
function paths(text) {
|
|
14
|
+
return literals(text).filter((value) => PATH_LIKE.test(value) && !HOST_LIKE.test(value)).map((value) => value.endsWith("/") ? `${value}**` : value);
|
|
15
|
+
}
|
|
16
|
+
function hosts(text) {
|
|
17
|
+
return literals(text).filter((value) => HOST_LIKE.test(value)).map((value) => value.replace(/^https?:\/\//, "").replace(/\/.*$/, ""));
|
|
18
|
+
}
|
|
19
|
+
/** `PRAE-P01` — proposals are numbered apart from anything hand-written. */
|
|
20
|
+
function id(index) {
|
|
21
|
+
return `PRAE-P${String(index + 1).padStart(2, "0")}`;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The readings, kept few on purpose.
|
|
25
|
+
*
|
|
26
|
+
* Each one is a phrasing whose meaning is not in doubt *and* which names what
|
|
27
|
+
* it applies to. Adding a looser pattern here trades precision for recall, and
|
|
28
|
+
* the whole value of a proposal is that a reviewer can agree with it in a
|
|
29
|
+
* second without opening the file.
|
|
30
|
+
*/
|
|
31
|
+
const READINGS = [
|
|
32
|
+
{
|
|
33
|
+
test: /\bwrites?\b[^.]*\b(?:stay|remain|live|only|confined)\b[^.]*\b(?:in|inside|within|under)\b/i,
|
|
34
|
+
confidence: "high",
|
|
35
|
+
why: "names where writes are allowed, so anything outside is not",
|
|
36
|
+
build: (found) => found.paths.length === 0 ? void 0 : {
|
|
37
|
+
title: "Writes stay in the workspace",
|
|
38
|
+
when: {
|
|
39
|
+
capability: "fs:write",
|
|
40
|
+
path: { outside: found.paths }
|
|
41
|
+
},
|
|
42
|
+
effect: {
|
|
43
|
+
kind: "deny",
|
|
44
|
+
because: `writes outside ${found.paths.join(", ")} are not granted in this pack`
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
test: /\b(?:never|do not|don't|no)\b[^.]*\bwrit\w*\b[^.]*\b(?:outside|beyond|above)\b/i,
|
|
50
|
+
confidence: "high",
|
|
51
|
+
why: "forbids writing outside the paths it names",
|
|
52
|
+
build: (found) => found.paths.length === 0 ? void 0 : {
|
|
53
|
+
title: "Writes stay in the workspace",
|
|
54
|
+
when: {
|
|
55
|
+
capability: "fs:write",
|
|
56
|
+
path: { outside: found.paths }
|
|
57
|
+
},
|
|
58
|
+
effect: {
|
|
59
|
+
kind: "deny",
|
|
60
|
+
because: `writes outside ${found.paths.join(", ")} are not granted in this pack`
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
test: /\b(?:never|do not|don't|no)\b[^.]*\b(?:read|open|access|touch)\b/i,
|
|
66
|
+
confidence: "high",
|
|
67
|
+
why: "forbids reading the paths it names",
|
|
68
|
+
build: (found) => found.paths.length === 0 ? void 0 : {
|
|
69
|
+
title: "Secrets are not readable",
|
|
70
|
+
when: {
|
|
71
|
+
capability: "fs:read",
|
|
72
|
+
path: { inside: found.paths }
|
|
73
|
+
},
|
|
74
|
+
effect: {
|
|
75
|
+
kind: "deny",
|
|
76
|
+
because: `reads of ${found.paths.join(", ")} are not granted in this pack`
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
test: /\bonly\b[^.]*\b(?:fetch|request|call|download)\b[^.]*\bfrom\b/i,
|
|
82
|
+
confidence: "high",
|
|
83
|
+
why: "names the only hosts allowed, so any other host is not",
|
|
84
|
+
build: (found) => found.hosts.length === 0 ? void 0 : {
|
|
85
|
+
title: "Fetches stay on listed hosts",
|
|
86
|
+
when: {
|
|
87
|
+
capability: "net:fetch",
|
|
88
|
+
host: { notIn: found.hosts }
|
|
89
|
+
},
|
|
90
|
+
effect: {
|
|
91
|
+
kind: "deny",
|
|
92
|
+
because: `this host is not on the pack allowlist (${found.hosts.join(", ")})`
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
test: /\b(?:never|do not|don't|no)\b[^.]*\b(?:fetch|request|download|call)\b/i,
|
|
98
|
+
confidence: "medium",
|
|
99
|
+
why: "forbids reaching the hosts it names, though the phrasing is broad",
|
|
100
|
+
build: (found) => found.hosts.length === 0 ? void 0 : {
|
|
101
|
+
title: "Listed hosts are refused",
|
|
102
|
+
when: {
|
|
103
|
+
capability: "net:fetch",
|
|
104
|
+
host: { in: found.hosts }
|
|
105
|
+
},
|
|
106
|
+
effect: {
|
|
107
|
+
kind: "deny",
|
|
108
|
+
because: `${found.hosts.join(", ")} is refused by this pack`
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
];
|
|
113
|
+
/** Read statements and propose rules. Deterministic; no model involved. */
|
|
114
|
+
function extract(statements, options = {}) {
|
|
115
|
+
const proposals = [];
|
|
116
|
+
const skipped = [];
|
|
117
|
+
for (const statement of statements) {
|
|
118
|
+
const found = {
|
|
119
|
+
paths: paths(statement.text),
|
|
120
|
+
hosts: hosts(statement.text)
|
|
121
|
+
};
|
|
122
|
+
let proposed = false;
|
|
123
|
+
for (const reading of READINGS) {
|
|
124
|
+
if (!reading.test.test(statement.text)) continue;
|
|
125
|
+
const built = reading.build(found);
|
|
126
|
+
if (built === void 0) continue;
|
|
127
|
+
const effect = options.as === void 0 ? built.effect : {
|
|
128
|
+
kind: options.as,
|
|
129
|
+
because: built.effect.because
|
|
130
|
+
};
|
|
131
|
+
proposals.push({
|
|
132
|
+
rule: {
|
|
133
|
+
id: id(proposals.length),
|
|
134
|
+
...built,
|
|
135
|
+
effect
|
|
136
|
+
},
|
|
137
|
+
from: statement,
|
|
138
|
+
confidence: reading.confidence,
|
|
139
|
+
why: reading.why
|
|
140
|
+
});
|
|
141
|
+
proposed = true;
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
if (!proposed) skipped.push(statement);
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
proposals,
|
|
148
|
+
skipped
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
//#endregion
|
|
153
|
+
export { extract };
|