@opencode-cockpit/trust 0.0.0 → 0.8.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/README.md +240 -5
- package/dist/cli/preview.js +251 -0
- package/dist/core/adapt/seen.js +24 -0
- package/dist/core/adapt/v1.js +109 -0
- package/dist/core/adapt/v2.js +109 -0
- package/dist/core/config.js +93 -0
- package/dist/core/danger.js +366 -0
- package/dist/core/engine.js +245 -0
- package/dist/core/family.js +512 -0
- package/dist/core/history.js +169 -0
- package/dist/core/index.js +16 -0
- package/dist/core/keys.js +149 -0
- package/dist/core/ledger.js +202 -0
- package/dist/core/paths.js +38 -0
- package/dist/core/policy.js +128 -0
- package/dist/core/rules.js +152 -0
- package/dist/core/sample.js +311 -0
- package/dist/core/shell.js +253 -0
- package/dist/core/signature.js +38 -0
- package/dist/core/view/actions.js +321 -0
- package/dist/core/view/activity.js +557 -0
- package/dist/core/view/explorer.js +970 -0
- package/dist/core/view/model.js +193 -0
- package/dist/core/view/parts.js +290 -0
- package/dist/core/view/rows.js +165 -0
- package/dist/core/view/sidebar.js +170 -0
- package/dist/tui/index.js +920 -0
- package/dist/tui/journal.js +80 -0
- package/dist/tui/render.js +85 -0
- package/dist/tui/source.js +112 -0
- package/dist/tui/view/dialog.js +43 -0
- package/dist/tui/view/rows.js +69 -0
- package/package.json +49 -3
- package/tui.js +6 -0
- package/types/cli/preview.d.ts +22 -0
- package/types/core/adapt/seen.d.ts +46 -0
- package/types/core/adapt/v1.d.ts +18 -0
- package/types/core/adapt/v2.d.ts +20 -0
- package/types/core/config.d.ts +57 -0
- package/types/core/danger.d.ts +50 -0
- package/types/core/engine.d.ts +117 -0
- package/types/core/family.d.ts +97 -0
- package/types/core/history.d.ts +81 -0
- package/types/core/index.d.ts +16 -0
- package/types/core/keys.d.ts +68 -0
- package/types/core/ledger.d.ts +158 -0
- package/types/core/paths.d.ts +24 -0
- package/types/core/policy.d.ts +52 -0
- package/types/core/rules.d.ts +55 -0
- package/types/core/sample.d.ts +22 -0
- package/types/core/shell.d.ts +40 -0
- package/types/core/signature.d.ts +18 -0
- package/types/core/view/actions.d.ts +76 -0
- package/types/core/view/activity.d.ts +101 -0
- package/types/core/view/explorer.d.ts +154 -0
- package/types/core/view/model.d.ts +106 -0
- package/types/core/view/parts.d.ts +76 -0
- package/types/core/view/rows.d.ts +60 -0
- package/types/core/view/sidebar.d.ts +62 -0
- package/types/tui/index.d.ts +15 -0
- package/types/tui/journal.d.ts +19 -0
- package/types/tui/render.d.ts +14 -0
- package/types/tui/source.d.ts +35 -0
- package/types/tui/view/dialog.d.ts +24 -0
- package/types/tui/view/rows.d.ts +19 -0
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Families: what several exact rules have in common, and how a rule is shown so nothing about it is
|
|
3
|
+
* left to the font.
|
|
4
|
+
*
|
|
5
|
+
* A signature is exact on purpose (signature.ts), so a project that runs `ls -la`, `ls -la src` and
|
|
6
|
+
* `ls -R docs` has three rules — which is right for trust and wrong for reading: a ledger of forty
|
|
7
|
+
* exact lines does not say "you trust ls". A family is the part of a command that names *what it
|
|
8
|
+
* does*: the program, and for a tool with subcommands, the subcommand (`git status`, `docker compose
|
|
9
|
+
* up`, `npm run test`). The ledger groups by it, and it is the one unit a person can widen trust to,
|
|
10
|
+
* on purpose (`w` in the ledger) — never Trust by itself (docs/roadmap/trust.md).
|
|
11
|
+
*
|
|
12
|
+
* The rules, and why each one leans the way it does:
|
|
13
|
+
*
|
|
14
|
+
* - **Global flags are not part of the family**: `git -C /x status` is `git status`, and
|
|
15
|
+
* `docker compose -p prod down -v` is `docker compose down` — the flags change *where*, the
|
|
16
|
+
* subcommand says *what*. Read with danger.ts's own tables, so the two never disagree on where the
|
|
17
|
+
* subcommand is.
|
|
18
|
+
* - **A wrapper is part of it**: `sudo ls` is not `ls`, and neither is `timeout 5 ls`. Trusting any
|
|
19
|
+
* `ls` must not quietly cover running it as root.
|
|
20
|
+
* - **So is where it runs and what it is told**: `(in web) bun test` and `NODE_ENV=… npm run build`
|
|
21
|
+
* are their own families. A directory or an environment changes what the same words do.
|
|
22
|
+
* - **Redirections are not**: `ls > out.txt` groups under `ls` — but a widened family does not cover
|
|
23
|
+
* it (`outside`), because writing a file is not what "any ls" was agreed to mean.
|
|
24
|
+
*
|
|
25
|
+
* Other permissions: an edit's family is its folder (`src/`), a fetch's its host, an agent type is
|
|
26
|
+
* its own — the last two already are as wide as they go.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { posix } from "node:path";
|
|
30
|
+
import { COMPOSE_GLOBALS, dangerOf, subcommand, TOOL_GLOBALS, unwrapOnce } from "./danger.js";
|
|
31
|
+
import { canonical } from "./rules.js";
|
|
32
|
+
import { parse } from "./shell.js";
|
|
33
|
+
import { quote } from "./signature.js";
|
|
34
|
+
|
|
35
|
+
/* ─── reading a subject back ─────────────────────────────────────────────────────────────────── */
|
|
36
|
+
|
|
37
|
+
/** `(in web) ` or `(in 'my dir') `, as `signature()` writes a place. */
|
|
38
|
+
const PLACE = /^\(in ('(?:[^']|'\\'')*'|[^\s)]+)\) /;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A bash subject read back into its command. A signature is written to be parsed again — every word
|
|
42
|
+
* quoted by `quote` — so this is exact; anything that does not read as one command is left alone.
|
|
43
|
+
*/
|
|
44
|
+
export function readSubject(subject) {
|
|
45
|
+
let rest = subject;
|
|
46
|
+
let place;
|
|
47
|
+
const found = subject.match(PLACE);
|
|
48
|
+
if (found) {
|
|
49
|
+
const read = parse(found[1]);
|
|
50
|
+
const word = read.kind === "commands" && read.commands.length === 1 ? read.commands[0]?.argv : undefined;
|
|
51
|
+
if (word?.length !== 1) return undefined;
|
|
52
|
+
place = word[0];
|
|
53
|
+
rest = subject.slice(found[0].length);
|
|
54
|
+
}
|
|
55
|
+
const read = parse(rest);
|
|
56
|
+
if (read.kind !== "commands" || read.commands.length !== 1) return undefined;
|
|
57
|
+
const command = read.commands[0];
|
|
58
|
+
if (command.cwd !== undefined) return undefined;
|
|
59
|
+
return place === undefined ? {
|
|
60
|
+
command
|
|
61
|
+
} : {
|
|
62
|
+
place,
|
|
63
|
+
command
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* ─── redirections ───────────────────────────────────────────────────────────────────────────── */
|
|
68
|
+
|
|
69
|
+
/** A redirection that takes the next word as its file: `>`, `2>>`, `&>`, `<`. */
|
|
70
|
+
const TO_FILE = /^(\d*|&)(>>?|<)$/;
|
|
71
|
+
/** One that names another descriptor and takes no file: `2>&1`, `>&2`, `<&-`. */
|
|
72
|
+
const TO_FD = /^\d*[<>]&[\d-]$/;
|
|
73
|
+
export const isRedirect = word => TO_FILE.test(word) || TO_FD.test(word);
|
|
74
|
+
|
|
75
|
+
/** The words without their redirections, and the files the command writes to. */
|
|
76
|
+
function redirections(argv, ops) {
|
|
77
|
+
const words = [];
|
|
78
|
+
const writes = [];
|
|
79
|
+
/** The reader says which words the shell acts on; a quoted `'>'` is an argument (shell.ts). */
|
|
80
|
+
const op = (i, word) => ops ? ops.includes(i) : isRedirect(word);
|
|
81
|
+
for (let i = 0; i < argv.length; i++) {
|
|
82
|
+
const word = argv[i];
|
|
83
|
+
if (op(i, word) && TO_FD.test(word)) continue;
|
|
84
|
+
if (op(i, word) && TO_FILE.test(word)) {
|
|
85
|
+
const target = argv[i + 1];
|
|
86
|
+
if (target !== undefined && word.includes(">") && target !== "/dev/null") writes.push(target);
|
|
87
|
+
i++;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
words.push(word);
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
words,
|
|
94
|
+
writes
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* ─── families ───────────────────────────────────────────────────────────────────────────────── */
|
|
99
|
+
|
|
100
|
+
/** Leading words that are not flags, at most `count` of them. */
|
|
101
|
+
function lead(words, count) {
|
|
102
|
+
const out = [];
|
|
103
|
+
for (const word of words) {
|
|
104
|
+
if (out.length >= count || word.startsWith("-")) break;
|
|
105
|
+
out.push(word);
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** One subcommand word, or two after the ones listed: `git stash drop`, `npm run test`. */
|
|
111
|
+
const pairs = (...two) => words => lead(words, two.includes(words[0] ?? "") ? 2 : 1);
|
|
112
|
+
const one = words => lead(words, 1);
|
|
113
|
+
const two = words => lead(words, 2);
|
|
114
|
+
|
|
115
|
+
/** Docker's management commands: `docker container rm` is about containers, then what to do. */
|
|
116
|
+
const OBJECTS = new Set(["builder", "buildx", "config", "container", "context", "image", "manifest", "network", "node", "plugin", "secret", "service", "stack", "swarm", "system", "trust", "volume"]);
|
|
117
|
+
function container(words) {
|
|
118
|
+
if (words[0] === "compose") return ["compose", ...one(subcommand(words.slice(1), COMPOSE_GLOBALS))];
|
|
119
|
+
return lead(words, OBJECTS.has(words[0] ?? "") ? 2 : 1);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Programs with subcommands. Anything not here is its program alone: `ls`, `echo`, `cat`. A package
|
|
123
|
+
* manager's `run` keeps the script, because `npm run test` and `npm run deploy` are not one thing.
|
|
124
|
+
*/
|
|
125
|
+
const TOOLS = {
|
|
126
|
+
git: {
|
|
127
|
+
take: pairs("stash", "remote", "submodule", "worktree", "notes", "bisect", "lfs", "sparse-checkout")
|
|
128
|
+
},
|
|
129
|
+
docker: {
|
|
130
|
+
take: container
|
|
131
|
+
},
|
|
132
|
+
podman: {
|
|
133
|
+
take: container
|
|
134
|
+
},
|
|
135
|
+
nerdctl: {
|
|
136
|
+
take: container
|
|
137
|
+
},
|
|
138
|
+
"docker-compose": {
|
|
139
|
+
take: one
|
|
140
|
+
},
|
|
141
|
+
kubectl: {
|
|
142
|
+
take: pairs("rollout", "config", "auth", "certificate", "set")
|
|
143
|
+
},
|
|
144
|
+
helm: {
|
|
145
|
+
take: pairs("repo", "plugin")
|
|
146
|
+
},
|
|
147
|
+
gh: {
|
|
148
|
+
take: two
|
|
149
|
+
},
|
|
150
|
+
aws: {
|
|
151
|
+
take: two
|
|
152
|
+
},
|
|
153
|
+
gcloud: {
|
|
154
|
+
take: two
|
|
155
|
+
},
|
|
156
|
+
terraform: {
|
|
157
|
+
take: pairs("state", "workspace")
|
|
158
|
+
},
|
|
159
|
+
tofu: {
|
|
160
|
+
take: pairs("state", "workspace")
|
|
161
|
+
},
|
|
162
|
+
npm: {
|
|
163
|
+
globals: ["-w", "--workspace", "--prefix", "-C"],
|
|
164
|
+
take: pairs("run", "run-script", "exec")
|
|
165
|
+
},
|
|
166
|
+
pnpm: {
|
|
167
|
+
globals: ["-F", "--filter", "-C", "--dir"],
|
|
168
|
+
take: pairs("run", "exec", "dlx")
|
|
169
|
+
},
|
|
170
|
+
yarn: {
|
|
171
|
+
globals: ["--cwd"],
|
|
172
|
+
take: words => lead(words, words[0] === "workspace" ? 3 : ["run", "exec", "dlx"].includes(words[0] ?? "") ? 2 : 1)
|
|
173
|
+
},
|
|
174
|
+
bun: {
|
|
175
|
+
globals: ["--cwd"],
|
|
176
|
+
take: pairs("run", "x", "pm", "create")
|
|
177
|
+
},
|
|
178
|
+
deno: {
|
|
179
|
+
take: pairs("task")
|
|
180
|
+
},
|
|
181
|
+
npx: {
|
|
182
|
+
take: one
|
|
183
|
+
},
|
|
184
|
+
bunx: {
|
|
185
|
+
take: one
|
|
186
|
+
},
|
|
187
|
+
pnpx: {
|
|
188
|
+
take: one
|
|
189
|
+
},
|
|
190
|
+
cargo: {
|
|
191
|
+
take: one
|
|
192
|
+
},
|
|
193
|
+
go: {
|
|
194
|
+
take: pairs("mod", "work", "tool")
|
|
195
|
+
},
|
|
196
|
+
pip: {
|
|
197
|
+
take: one
|
|
198
|
+
},
|
|
199
|
+
pip3: {
|
|
200
|
+
take: one
|
|
201
|
+
},
|
|
202
|
+
uv: {
|
|
203
|
+
take: pairs("pip", "tool", "python")
|
|
204
|
+
},
|
|
205
|
+
brew: {
|
|
206
|
+
take: one
|
|
207
|
+
},
|
|
208
|
+
systemctl: {
|
|
209
|
+
take: one
|
|
210
|
+
},
|
|
211
|
+
launchctl: {
|
|
212
|
+
take: one
|
|
213
|
+
},
|
|
214
|
+
make: {
|
|
215
|
+
globals: ["-C", "-f", "--file", "--directory"],
|
|
216
|
+
take: one
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
/** The words that name a command's family, wrappers included, environment and redirections not. */
|
|
221
|
+
function familyWords(argv, ops) {
|
|
222
|
+
let rest = redirections(argv, ops).words;
|
|
223
|
+
const head = [];
|
|
224
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
225
|
+
const once = unwrapOnce(rest);
|
|
226
|
+
if (!once) break;
|
|
227
|
+
head.push(once.wrapper);
|
|
228
|
+
rest = once.rest;
|
|
229
|
+
}
|
|
230
|
+
const [program, ...args] = rest;
|
|
231
|
+
if (program === undefined) return head;
|
|
232
|
+
const name = posix.basename(program);
|
|
233
|
+
const tool = TOOLS[name];
|
|
234
|
+
if (!tool) return [...head, program];
|
|
235
|
+
const globals = [...(TOOL_GLOBALS[name] ?? []), ...(tool.globals ?? [])];
|
|
236
|
+
return [...head, program, ...tool.take(subcommand(args, globals))];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** `NODE_ENV=prod` as a family says it: the name, not the value. */
|
|
240
|
+
const envName = word => `${word.slice(0, word.indexOf("="))}=…`;
|
|
241
|
+
function bashFamily(command, place) {
|
|
242
|
+
const words = [...command.env.map(envName), ...familyWords(command.argv, command.redirects).map(quote)].join(" ");
|
|
243
|
+
return place === undefined ? words : `(in ${quote(place)}) ${words}`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* A subject's family, under its permission. The same string for every command in it, so it is what
|
|
248
|
+
* a widening is recorded against and what the ledger groups by.
|
|
249
|
+
*/
|
|
250
|
+
export function familyOf(permission, subject) {
|
|
251
|
+
const name = canonical(permission);
|
|
252
|
+
if (name === "bash") {
|
|
253
|
+
const read = readSubject(subject);
|
|
254
|
+
return read && read.command.argv.length > 0 ? bashFamily(read.command, read.place) : subject;
|
|
255
|
+
}
|
|
256
|
+
if (name === "edit") {
|
|
257
|
+
const folder = posix.dirname(subject);
|
|
258
|
+
return folder === "." ? "./" : folder.endsWith("/") ? folder : `${folder}/`;
|
|
259
|
+
}
|
|
260
|
+
return subject;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** The family's own words as a command, for judging the family itself. */
|
|
264
|
+
function familyCommand(family) {
|
|
265
|
+
return readSubject(family)?.command;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Whether a family may be widened at all. A dangerous family never: if `git push` itself is
|
|
270
|
+
* dangerous, "any git push" is a rule that answers force-pushes — every one of them has to earn
|
|
271
|
+
* trust on its own, at the higher count. A fetch or an agent type is already as wide as it goes.
|
|
272
|
+
*/
|
|
273
|
+
export function widenable(permission, family) {
|
|
274
|
+
const name = canonical(permission);
|
|
275
|
+
if (name === "bash") {
|
|
276
|
+
const command = familyCommand(family);
|
|
277
|
+
if (!command || command.argv.length === 0) return {
|
|
278
|
+
ok: false,
|
|
279
|
+
why: "it cannot be read as one command"
|
|
280
|
+
};
|
|
281
|
+
const danger = dangerOf({
|
|
282
|
+
env: [],
|
|
283
|
+
argv: command.argv
|
|
284
|
+
});
|
|
285
|
+
return danger ? {
|
|
286
|
+
ok: false,
|
|
287
|
+
why: `${showSubject("bash", family)} is dangerous${danger === showSubject("bash", family) ? "" : ` (${danger})`} — each one earns trust on its own`
|
|
288
|
+
} : {
|
|
289
|
+
ok: true
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
if (name === "edit") return {
|
|
293
|
+
ok: true
|
|
294
|
+
};
|
|
295
|
+
if (name === "webfetch") return {
|
|
296
|
+
ok: false,
|
|
297
|
+
why: "a fetch rule already covers the whole host"
|
|
298
|
+
};
|
|
299
|
+
if (name === "task") return {
|
|
300
|
+
ok: false,
|
|
301
|
+
why: "an agent type is already one rule"
|
|
302
|
+
};
|
|
303
|
+
return {
|
|
304
|
+
ok: false,
|
|
305
|
+
why: `a ${name} rule is already as wide as it goes`
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Programs that run another program named in their arguments: what runs is not the family. */
|
|
310
|
+
function runsAnother(argv) {
|
|
311
|
+
let rest = argv;
|
|
312
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
313
|
+
const once = unwrapOnce(rest);
|
|
314
|
+
if (!once) break;
|
|
315
|
+
rest = once.rest;
|
|
316
|
+
}
|
|
317
|
+
const [program, ...args] = rest;
|
|
318
|
+
if (program === undefined) return false;
|
|
319
|
+
const name = posix.basename(program);
|
|
320
|
+
if (name === "find") return args.some(arg => ["-exec", "-execdir", "-ok", "-okdir"].includes(arg));
|
|
321
|
+
if (name === "git") {
|
|
322
|
+
/** `git -c core.pager=…` and friends run whatever the value says, whatever the subcommand. */
|
|
323
|
+
const globals = args.slice(0, args.length - subcommand(args, TOOL_GLOBALS.git).length);
|
|
324
|
+
return globals.some(arg => /^(-c|--config-env|--exec-path)(=|$)/.test(arg));
|
|
325
|
+
}
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Why a widened family would still ask about this subject — or nothing when it covers it. A widening
|
|
331
|
+
* is "any `ls`", not "anything that starts with ls": a dangerous command, one that writes a file
|
|
332
|
+
* through a redirection, and one that hands its arguments to another program each still ask.
|
|
333
|
+
*/
|
|
334
|
+
export function outside(permission, subject) {
|
|
335
|
+
if (canonical(permission) !== "bash") return undefined;
|
|
336
|
+
const read = readSubject(subject);
|
|
337
|
+
if (!read) return "it cannot be read as one command";
|
|
338
|
+
const danger = dangerOf(read.command);
|
|
339
|
+
if (danger) return `dangerous (${danger})`;
|
|
340
|
+
if (redirections(read.command.argv, read.command.redirects).writes.length > 0) return "it writes to a file";
|
|
341
|
+
if (runsAnother(read.command.argv)) return "it runs another program";
|
|
342
|
+
return undefined;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** A widened `family` answers `subject`. */
|
|
346
|
+
export const covers = (permission, family, subject) => familyOf(permission, subject) === family && outside(permission, subject) === undefined;
|
|
347
|
+
|
|
348
|
+
/* ─── showing it ─────────────────────────────────────────────────────────────────────────────── */
|
|
349
|
+
|
|
350
|
+
/** Words that read as themselves with no quotes, in any shell and any font. */
|
|
351
|
+
const BARE = /^[A-Za-z0-9_@%+=:,./~^-]+$/;
|
|
352
|
+
/**
|
|
353
|
+
* Runs of characters that programming fonts draw as one glyph (Fira Code, JetBrains Mono, Cascadia):
|
|
354
|
+
* `---` became `──` on a user's screen and `echo ---` read as `echo ──`. Quoted, the run is still
|
|
355
|
+
* merged, but the quotes say there is one argument and where it ends.
|
|
356
|
+
*/
|
|
357
|
+
const LIGATURE = /---|-->|->|<-|=>|==|!=|<=|>=|<>|www|\.\.|::|\/\/|&&|\|\||\*\*|~~|\+\+|##|\/\*|\*\//;
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* One word as the ledger shows it: bare when nothing about it can be misread, otherwise in double
|
|
361
|
+
* quotes — easier to read than the signature's single quotes, and this is display, never parsed.
|
|
362
|
+
* A punctuation-only word is always quoted (a lone `-` or `.` aside: one character cannot merge).
|
|
363
|
+
*/
|
|
364
|
+
export function shown(word) {
|
|
365
|
+
if (word === "") return '""';
|
|
366
|
+
const plain = BARE.test(word) && (/[A-Za-z0-9]/.test(word) || word.length === 1) && !LIGATURE.test(word);
|
|
367
|
+
if (plain) return word;
|
|
368
|
+
if ([...word].some(isControl)) return `$'${[...word].map(controlEscape).join("")}'`;
|
|
369
|
+
return `"${word.replace(/["\\$`]/g, "\\$&")}"`;
|
|
370
|
+
}
|
|
371
|
+
const isControl = char => {
|
|
372
|
+
const code = char.codePointAt(0) ?? 0;
|
|
373
|
+
return code < 0x20 || code === 0x7f;
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
/** One character inside `$'…'`: a newline is `\n`, a quote or backslash escaped, the rest itself. */
|
|
377
|
+
function controlEscape(char) {
|
|
378
|
+
const named = {
|
|
379
|
+
"\n": "\\n",
|
|
380
|
+
"\t": "\\t",
|
|
381
|
+
"\r": "\\r",
|
|
382
|
+
"'": "\\'",
|
|
383
|
+
"\\": "\\\\"
|
|
384
|
+
};
|
|
385
|
+
const known = named[char];
|
|
386
|
+
if (known !== undefined) return known;
|
|
387
|
+
return isControl(char) ? `\\x${(char.codePointAt(0) ?? 0).toString(16).padStart(2, "0")}` : char;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** What each character a font may merge is called, so a run can be said in words. */
|
|
391
|
+
const NAMES = {
|
|
392
|
+
"-": ["hyphen", "hyphens"],
|
|
393
|
+
"=": ["equals sign", "equals signs"],
|
|
394
|
+
">": ["greater-than", "greater-thans"],
|
|
395
|
+
"<": ["less-than", "less-thans"],
|
|
396
|
+
"!": ["exclamation mark", "exclamation marks"],
|
|
397
|
+
".": ["dot", "dots"],
|
|
398
|
+
":": ["colon", "colons"],
|
|
399
|
+
"/": ["slash", "slashes"],
|
|
400
|
+
"&": ["ampersand", "ampersands"],
|
|
401
|
+
"|": ["pipe", "pipes"],
|
|
402
|
+
"*": ["asterisk", "asterisks"],
|
|
403
|
+
"~": ["tilde", "tildes"],
|
|
404
|
+
"+": ["plus", "pluses"],
|
|
405
|
+
"#": ["hash", "hashes"]
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* The runs of a word a font may draw as one glyph, said in words: `---` is "3 hyphens", `->` is
|
|
410
|
+
* "hyphen, greater-than". Quotes were not enough — inside them `"---"` still drew as `"──"` on the
|
|
411
|
+
* user's screen — so the ledger says it in letters, which no font merges.
|
|
412
|
+
*/
|
|
413
|
+
export function spelled(word) {
|
|
414
|
+
const runs = word.match(new RegExp(LIGATURE.source, "g"));
|
|
415
|
+
if (!runs) return undefined;
|
|
416
|
+
const said = runs.map(run => {
|
|
417
|
+
const parts = [];
|
|
418
|
+
for (const group of run.match(/(.)\1*/g) ?? []) {
|
|
419
|
+
const name = NAMES[group[0]];
|
|
420
|
+
if (!name) return undefined;
|
|
421
|
+
parts.push(group.length === 1 ? name[0] : `${group.length} ${name[1]}`);
|
|
422
|
+
}
|
|
423
|
+
return parts.join(", ");
|
|
424
|
+
});
|
|
425
|
+
if (said.some(each => each === undefined)) return undefined;
|
|
426
|
+
return [...new Set(said)].join("; ");
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Each argument of a subject a font may draw as something else, as the ledger shows it (`"---"`)
|
|
431
|
+
* beside what it is in words (`3 hyphens`). The panel says which word it means: a bare `3 hyphens`
|
|
432
|
+
* after a command read as a riddle (a user's screenshot).
|
|
433
|
+
*/
|
|
434
|
+
export function spelledWords(permission, subject) {
|
|
435
|
+
if (canonical(permission) !== "bash") return [];
|
|
436
|
+
const read = readSubject(subject);
|
|
437
|
+
if (!read) return [];
|
|
438
|
+
/** Only words that are punctuation and nothing else: `---` is unreadable, `../src` and URLs are not. */
|
|
439
|
+
const words = read.command.argv.filter((word, i) => !(read.command.redirects ?? []).includes(i) && !isRedirect(word) && /^[^A-Za-z0-9\s]+$/.test(word));
|
|
440
|
+
const out = [];
|
|
441
|
+
for (const word of new Set(words)) {
|
|
442
|
+
const said = spelled(word);
|
|
443
|
+
if (said !== undefined) out.push({
|
|
444
|
+
word: shown(word),
|
|
445
|
+
said
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
return out;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** The spelled runs of every argument of a subject, for the ledger to put beside it. */
|
|
452
|
+
export function spelledSubject(permission, subject) {
|
|
453
|
+
const said = [...new Set(spelledWords(permission, subject).map(each => each.said))];
|
|
454
|
+
return said.length > 0 ? said.join("; ") : undefined;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** A command as the ledger shows it: every argument unambiguous, redirections as redirections. */
|
|
458
|
+
export function showCommand(command) {
|
|
459
|
+
const env = command.env.map(word => {
|
|
460
|
+
const at = word.indexOf("=");
|
|
461
|
+
const value = word.slice(at + 1);
|
|
462
|
+
return `${word.slice(0, at + 1)}${value === "…" ? value : value === "" ? "" : shown(value)}`;
|
|
463
|
+
});
|
|
464
|
+
const argv = command.argv.map(word => isRedirect(word) ? word : shown(word));
|
|
465
|
+
return [...env, ...argv].join(" ");
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** A subject — or a family — as the ledger and the sidebar show it. Paths and hosts are themselves. */
|
|
469
|
+
export function showSubject(permission, subject) {
|
|
470
|
+
if (canonical(permission) !== "bash") return subject;
|
|
471
|
+
const read = readSubject(subject);
|
|
472
|
+
if (!read) return subject;
|
|
473
|
+
const words = showCommand(read.command);
|
|
474
|
+
return read.place === undefined ? words : `(in ${shown(read.place)}) ${words}`;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** `any ls …`, `any file in src/`: what a widened family answers, in words. */
|
|
478
|
+
export function anyOf(permission, family) {
|
|
479
|
+
const name = canonical(permission);
|
|
480
|
+
if (name === "edit") return family === "./" ? "any file at the project's top" : `any file in ${family}`;
|
|
481
|
+
return `any ${showSubject(name, family)} …`;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* A command a little different from `subject`, for the sentence that says what still asks: the
|
|
486
|
+
* command with its last argument dropped, or nothing when it has none past its family.
|
|
487
|
+
*/
|
|
488
|
+
export function narrower(subject) {
|
|
489
|
+
const read = readSubject(subject);
|
|
490
|
+
if (!read) return undefined;
|
|
491
|
+
const {
|
|
492
|
+
words
|
|
493
|
+
} = redirections(read.command.argv, read.command.redirects);
|
|
494
|
+
const family = familyWords(read.command.argv, read.command.redirects);
|
|
495
|
+
if (words.length <= family.length) return undefined;
|
|
496
|
+
return showSubject("bash", dropLast(read, words));
|
|
497
|
+
}
|
|
498
|
+
function dropLast(read, words) {
|
|
499
|
+
const command = {
|
|
500
|
+
env: read.command.env,
|
|
501
|
+
argv: words.slice(0, -1)
|
|
502
|
+
};
|
|
503
|
+
const text = [...command.env, ...command.argv].map(quote).join(" ");
|
|
504
|
+
return read.place === undefined ? text : `(in ${quote(read.place)}) ${text}`;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** The subject with its output sent to a file: the other thing that still asks. */
|
|
508
|
+
export function redirected(subject) {
|
|
509
|
+
const read = readSubject(subject);
|
|
510
|
+
if (!read || redirections(read.command.argv, read.command.redirects).writes.length > 0) return undefined;
|
|
511
|
+
return `${showSubject("bash", subject)} > out.txt`;
|
|
512
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What happened, and when — read from the same events the state is folded from, for the screens
|
|
3
|
+
* that say *why*: what Trust answered today, how the week went, and the approvals that earned a rule.
|
|
4
|
+
*
|
|
5
|
+
* The state (ledger.ts) keeps what a rule adds up to — a streak, a count, the last time — because
|
|
6
|
+
* that is all a decision needs. A person asking "why is this trusted?" needs the moments themselves:
|
|
7
|
+
* `✓ 9h ✓ 9h ✓ 9h → trusted`. So this is a second fold over the same sequence, beside the state and
|
|
8
|
+
* never consulted by `decide`: nothing here changes what Trust answers.
|
|
9
|
+
*
|
|
10
|
+
* Bounded, because a ledger only grows: the last `MARKS` moments per rule (a run of answers is one
|
|
11
|
+
* moment with a count, so a rule answered a thousand times does not push out the approvals that
|
|
12
|
+
* earned it), the last `ANSWERS` answers, and a count of answers per day for the last `DAYS` days.
|
|
13
|
+
* A rule older than its window still has its totals in the state; the screens fall back to those.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { keyOf } from "./ledger.js";
|
|
17
|
+
|
|
18
|
+
/** One moment in a rule's life. A run of answers in a row is one mark, `count` of them. */
|
|
19
|
+
|
|
20
|
+
/** One answer Trust gave, from any window on the project. */
|
|
21
|
+
|
|
22
|
+
/** Moments kept per rule. */
|
|
23
|
+
export const MARKS = 24;
|
|
24
|
+
/** Answers kept for the activity feed: far more than a screen lists. */
|
|
25
|
+
export const ANSWERS = 200;
|
|
26
|
+
/** Days of answers counted: a week, and a week before it to spare. */
|
|
27
|
+
export const DAYS = 14;
|
|
28
|
+
export const emptyHistory = () => ({
|
|
29
|
+
marks: new Map(),
|
|
30
|
+
answers: [],
|
|
31
|
+
days: new Map()
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/** The first millisecond of the local day `at` falls in. */
|
|
35
|
+
export function dayOf(at) {
|
|
36
|
+
const day = new Date(at);
|
|
37
|
+
day.setHours(0, 0, 0, 0);
|
|
38
|
+
return day.getTime();
|
|
39
|
+
}
|
|
40
|
+
function mark(history, key, next) {
|
|
41
|
+
let list = history.marks.get(key);
|
|
42
|
+
if (!list) {
|
|
43
|
+
list = [];
|
|
44
|
+
history.marks.set(key, list);
|
|
45
|
+
}
|
|
46
|
+
const last = list.at(-1);
|
|
47
|
+
if (next.kind === "auto" && last?.kind === "auto") {
|
|
48
|
+
last.count += next.count;
|
|
49
|
+
last.at = Math.max(last.at, next.at);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
list.push(next);
|
|
53
|
+
if (list.length > MARKS) list.splice(0, list.length - MARKS);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* One event into the history. `settled` is the state's set of requests already counted, read before
|
|
58
|
+
* the state applies this event: two windows writing one outcome are one moment here as there.
|
|
59
|
+
*/
|
|
60
|
+
export function note(history, event, settled) {
|
|
61
|
+
switch (event.type) {
|
|
62
|
+
case "approved":
|
|
63
|
+
case "rejected":
|
|
64
|
+
case "auto":
|
|
65
|
+
{
|
|
66
|
+
if (settled.has(event.request)) return;
|
|
67
|
+
for (const item of event.items) {
|
|
68
|
+
const key = keyOf(event.permission, event.agent, item.subject);
|
|
69
|
+
mark(history, key, event.type === "auto" ? {
|
|
70
|
+
kind: "auto",
|
|
71
|
+
at: event.at,
|
|
72
|
+
count: 1
|
|
73
|
+
} : {
|
|
74
|
+
kind: event.type,
|
|
75
|
+
at: event.at
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
if (event.type !== "auto") return;
|
|
79
|
+
history.answers.push({
|
|
80
|
+
at: event.at,
|
|
81
|
+
request: event.request,
|
|
82
|
+
permission: event.permission,
|
|
83
|
+
agent: event.agent,
|
|
84
|
+
items: event.items,
|
|
85
|
+
rule: event.rule
|
|
86
|
+
});
|
|
87
|
+
if (history.answers.length > ANSWERS) history.answers.splice(0, history.answers.length - ANSWERS);
|
|
88
|
+
const day = dayOf(event.at);
|
|
89
|
+
history.days.set(day, (history.days.get(day) ?? 0) + 1);
|
|
90
|
+
if (history.days.size > DAYS) {
|
|
91
|
+
const newest = Math.max(...history.days.keys());
|
|
92
|
+
for (const each of history.days.keys()) if (newest - each > DAYS * 86_400_000) history.days.delete(each);
|
|
93
|
+
}
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
case "revoked":
|
|
97
|
+
mark(history, keyOf(event.permission, event.agent, event.subject), {
|
|
98
|
+
kind: "revoked",
|
|
99
|
+
at: event.at
|
|
100
|
+
});
|
|
101
|
+
return;
|
|
102
|
+
default:
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/* ─── reading it ─────────────────────────────────────────────────────────────────────────────── */
|
|
108
|
+
|
|
109
|
+
/** Answers newest first. */
|
|
110
|
+
export const latestAnswers = (history, limit = ANSWERS) => history.answers.slice(-limit).reverse();
|
|
111
|
+
|
|
112
|
+
/** Answers on each of the last `days` local days, the oldest first and today last. */
|
|
113
|
+
export function answersPerDay(history, now, days = 7) {
|
|
114
|
+
const out = [];
|
|
115
|
+
const today = dayOf(now);
|
|
116
|
+
for (let back = days - 1; back >= 0; back--) {
|
|
117
|
+
/** Noon of each day back, so a day with a clock change is still found by its own midnight. */
|
|
118
|
+
const day = dayOf(today + 12 * 3_600_000 - back * 86_400_000);
|
|
119
|
+
out.push(history.days.get(day) ?? 0);
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** How a rule came to be trusted, read back from its moments. */
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The streak a rule's moments add up to, the way the state folds it (a reject or revoke starts it
|
|
128
|
+
* over, an approval after `expireMs` unused starts it over) — and when it reached `need`.
|
|
129
|
+
*/
|
|
130
|
+
export function earned(marks, need, expireMs, upTo = Number.POSITIVE_INFINITY) {
|
|
131
|
+
let streak = 0;
|
|
132
|
+
let since;
|
|
133
|
+
let brokenAt;
|
|
134
|
+
let broken;
|
|
135
|
+
let last = 0;
|
|
136
|
+
for (const each of marks) {
|
|
137
|
+
if (each.at > upTo) break;
|
|
138
|
+
if (each.kind === "rejected" || each.kind === "revoked") {
|
|
139
|
+
streak = 0;
|
|
140
|
+
since = undefined;
|
|
141
|
+
brokenAt = each.at;
|
|
142
|
+
broken = each.kind;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (expireMs > 0 && last > 0 && each.at - last > expireMs) {
|
|
146
|
+
streak = 0;
|
|
147
|
+
since = undefined;
|
|
148
|
+
brokenAt = each.at;
|
|
149
|
+
broken = "expired";
|
|
150
|
+
}
|
|
151
|
+
last = Math.max(last, each.at);
|
|
152
|
+
if (each.kind === "approved") {
|
|
153
|
+
streak++;
|
|
154
|
+
if (streak === need) since = each.at;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
streak,
|
|
159
|
+
...(since !== undefined ? {
|
|
160
|
+
since
|
|
161
|
+
} : {}),
|
|
162
|
+
...(brokenAt !== undefined ? {
|
|
163
|
+
brokenAt
|
|
164
|
+
} : {}),
|
|
165
|
+
...(broken !== undefined ? {
|
|
166
|
+
broken
|
|
167
|
+
} : {})
|
|
168
|
+
};
|
|
169
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Published entry point: `@opencode-cockpit/trust/core` — the pure half, no OpenCode, no terminal. */
|
|
2
|
+
export { commandOf } from "./adapt/seen.js";
|
|
3
|
+
export { fromV1Event, fromV1Pending, V1_EVENTS } from "./adapt/v1.js";
|
|
4
|
+
export { fromV2Event, fromV2Pending } from "./adapt/v2.js";
|
|
5
|
+
export * from "./config.js";
|
|
6
|
+
export { dangerOf, dangerous } from "./danger.js";
|
|
7
|
+
export * from "./engine.js";
|
|
8
|
+
export { anyOf, covers, familyOf, outside, shown, showSubject, widenable } from "./family.js";
|
|
9
|
+
export { answersPerDay, earned, latestAnswers } from "./history.js";
|
|
10
|
+
export * from "./keys.js";
|
|
11
|
+
export * from "./ledger.js";
|
|
12
|
+
export * from "./paths.js";
|
|
13
|
+
export * from "./policy.js";
|
|
14
|
+
export * from "./rules.js";
|
|
15
|
+
export { parse } from "./shell.js";
|
|
16
|
+
export { place, quote, signature } from "./signature.js";
|