baesian 0.0.0-stage → 1.0.1
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.md +105 -0
- package/README.md +217 -2
- package/dist/agents.js +249 -0
- package/dist/api.js +13 -0
- package/dist/bin.js +14 -0
- package/dist/ci.js +82 -0
- package/dist/cite.js +66 -0
- package/dist/cli.js +831 -0
- package/dist/client/assets/alegreya-cyrillic-ext-wght-italic-DzWRXZb0.woff2 +0 -0
- package/dist/client/assets/alegreya-cyrillic-ext-wght-normal-CN0-BfWD.woff2 +0 -0
- package/dist/client/assets/alegreya-cyrillic-wght-italic-Dhrv2xfk.woff2 +0 -0
- package/dist/client/assets/alegreya-cyrillic-wght-normal-CU5lb38f.woff2 +0 -0
- package/dist/client/assets/alegreya-greek-ext-wght-italic-CoUI9EEu.woff2 +0 -0
- package/dist/client/assets/alegreya-greek-ext-wght-normal-C8PPRmwJ.woff2 +0 -0
- package/dist/client/assets/alegreya-greek-wght-italic-CVt9-Jr4.woff2 +0 -0
- package/dist/client/assets/alegreya-greek-wght-normal-BPx8yWcZ.woff2 +0 -0
- package/dist/client/assets/alegreya-latin-ext-wght-italic-BIuo_2sR.woff2 +0 -0
- package/dist/client/assets/alegreya-latin-ext-wght-normal-o8axeAGu.woff2 +0 -0
- package/dist/client/assets/alegreya-latin-wght-italic-Bxkcgmge.woff2 +0 -0
- package/dist/client/assets/alegreya-latin-wght-normal-BG9rTJo4.woff2 +0 -0
- package/dist/client/assets/alegreya-vietnamese-wght-italic-BAPuWgnQ.woff2 +0 -0
- package/dist/client/assets/alegreya-vietnamese-wght-normal-DOV9JYup.woff2 +0 -0
- package/dist/client/assets/archivo-latin-ext-wdth-normal-7khWdh9v.woff2 +0 -0
- package/dist/client/assets/archivo-latin-wdth-normal-DY7AcnAa.woff2 +0 -0
- package/dist/client/assets/archivo-vietnamese-wdth-normal-rJmnGBSt.woff2 +0 -0
- package/dist/client/assets/index-9yf70Vqv.css +1 -0
- package/dist/client/assets/index-Dwg9e0YR.js +15 -0
- package/dist/client/index.html +15 -0
- package/dist/facts.js +278 -0
- package/dist/folder-storage.js +71 -0
- package/dist/frontmatter.js +38 -0
- package/dist/gate-paths.js +77 -0
- package/dist/gate.js +237 -0
- package/dist/git.js +100 -0
- package/dist/globs.js +120 -0
- package/dist/hookblock.js +146 -0
- package/dist/hooks.js +911 -0
- package/dist/ids.js +23 -0
- package/dist/login.js +301 -0
- package/dist/mcp.js +303 -0
- package/dist/merges.js +849 -0
- package/dist/modes.js +245 -0
- package/dist/notify.js +91 -0
- package/dist/ops.js +381 -0
- package/dist/passkey.js +86 -0
- package/dist/personal.js +163 -0
- package/dist/practice-answers.js +42 -0
- package/dist/proposals.js +197 -0
- package/dist/quote.js +28 -0
- package/dist/report.js +261 -0
- package/dist/routing.js +411 -0
- package/dist/schema.js +1178 -0
- package/dist/server.js +514 -0
- package/dist/similarity.js +45 -0
- package/dist/sshsig.js +159 -0
- package/dist/state.js +135 -0
- package/dist/storage.js +61 -0
- package/dist/store.js +2259 -0
- package/dist/style.js +408 -0
- package/dist/sync.js +797 -0
- package/dist/trailers.js +121 -0
- package/dist/trust.js +222 -0
- package/dist/usage-fields.js +161 -0
- package/dist/vault.js +849 -0
- package/dist/verify.js +443 -0
- package/dist/version.js +70 -0
- package/dist/wait.js +449 -0
- package/dist/wikilinks.js +25 -0
- package/package.json +71 -4
package/dist/trailers.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { CARD_ID } from "./ids.js";
|
|
3
|
+
// D52: `Baesian-Card: <id>` (one per line) and `Baesian-Ack: <fact-id> <reason>`.
|
|
4
|
+
export const CARD_TRAILER = "Baesian-Card";
|
|
5
|
+
export const ACK_TRAILER = "Baesian-Ack";
|
|
6
|
+
const FACT_ID = /^(dep|schema|infra|api):\S+$/;
|
|
7
|
+
/** The message as git will record it: no `#` comment lines, nothing below a `--verbose` scissors line. */
|
|
8
|
+
export function cleanMessage(raw) {
|
|
9
|
+
const lines = [];
|
|
10
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
11
|
+
if (/^# -+ >8 -+$/.test(line))
|
|
12
|
+
break;
|
|
13
|
+
if (!line.startsWith("#"))
|
|
14
|
+
lines.push(line);
|
|
15
|
+
}
|
|
16
|
+
return lines.join("\n");
|
|
17
|
+
}
|
|
18
|
+
/** The trailers git has parsed out of a message, one `Key: value` per line, as `--parse` and `%(trailers:only,unfold)` print them. */
|
|
19
|
+
function trailerLines(parsed) {
|
|
20
|
+
return parsed.split("\n").flatMap((line) => {
|
|
21
|
+
const m = /^([^:]+):\s*(.*)$/.exec(line);
|
|
22
|
+
return m ? [{ key: m[1].trim().toLowerCase(), value: m[2].trim() }] : [];
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
/** Parse the trailer block with git's own rules (`git interpret-trailers --parse`). */
|
|
26
|
+
export function parseTrailers(message, cwd) {
|
|
27
|
+
const out = execFileSync("git", ["interpret-trailers", "--parse"], {
|
|
28
|
+
cwd,
|
|
29
|
+
input: cleanMessage(message),
|
|
30
|
+
encoding: "utf8",
|
|
31
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
32
|
+
});
|
|
33
|
+
const result = { cards: [], acks: new Map(), malformed: [] };
|
|
34
|
+
for (const { key, value } of trailerLines(out)) {
|
|
35
|
+
if (key === CARD_TRAILER.toLowerCase()) {
|
|
36
|
+
for (const id of value.split(/[\s,]+/).filter(Boolean)) {
|
|
37
|
+
if (CARD_ID.test(id))
|
|
38
|
+
result.cards.push(id);
|
|
39
|
+
else
|
|
40
|
+
result.malformed.push(`${CARD_TRAILER}: ${value} — "${id}" isn't a card ID, which looks like B-7f3kq`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
else if (key === ACK_TRAILER.toLowerCase()) {
|
|
44
|
+
const a = /^(\S+)\s+(.+)$/.exec(value);
|
|
45
|
+
if (!a || !FACT_ID.test(a[1])) {
|
|
46
|
+
result.malformed.push(`${ACK_TRAILER}: ${value} — expected "<fact-id> <reason>", e.g. "dep:httpx needed for the client"`);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
result.acks.set(a[1], a[2].trim());
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
result.cards = [...new Set(result.cards)];
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
const BRANCH_REFS = ["refs/heads", "refs/remotes"];
|
|
57
|
+
const histories = new Map();
|
|
58
|
+
function gitOut(cwd, args) {
|
|
59
|
+
try {
|
|
60
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 256 * 1024 * 1024 });
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Search the history of every branch, local and remote-tracking, for `Baesian-Card` trailers (B-9v5xj), so the commits
|
|
68
|
+
* that built on a card are found after a rebase, or a squash that keeps the trailer, rewrites them. Trailers are read
|
|
69
|
+
* with git's own rules, as `parseTrailers` reads them: only from the message's last paragraph. A brief may ask about
|
|
70
|
+
* many cards, so the search is cached per process for as long as the branches point where they did.
|
|
71
|
+
*/
|
|
72
|
+
export function readHistory(cwd) {
|
|
73
|
+
const refs = gitOut(cwd, ["for-each-ref", "--format=%(objectname) %(refname)", ...BRANCH_REFS]) ?? "";
|
|
74
|
+
const cached = histories.get(cwd);
|
|
75
|
+
if (cached && cached.refs === refs)
|
|
76
|
+
return cached.history;
|
|
77
|
+
// Only commits that mention the trailer at all are parsed; git's grep reads every message faster than we could.
|
|
78
|
+
const out = gitOut(cwd, ["log", "--branches", "--remotes", "--source", "--reverse", "-i", `--grep=${CARD_TRAILER}`, "--format=%x1e%H%x00%at%x00%S%x00%(trailers:only,unfold)"]) ?? "";
|
|
79
|
+
const citing = new Map();
|
|
80
|
+
const found = new Set();
|
|
81
|
+
for (const record of out.split("\x1e").slice(1)) {
|
|
82
|
+
const [commit, at, source, trailers = ""] = record.split("\0");
|
|
83
|
+
if (!commit || !at)
|
|
84
|
+
continue;
|
|
85
|
+
found.add(commit);
|
|
86
|
+
const branch = (source ?? "").replace(/^refs\/(heads|remotes)\//, "");
|
|
87
|
+
const ids = new Set(trailerLines(trailers)
|
|
88
|
+
.filter((t) => t.key === CARD_TRAILER.toLowerCase())
|
|
89
|
+
.flatMap((t) => t.value.split(/[\s,]+/).filter((id) => CARD_ID.test(id))));
|
|
90
|
+
for (const id of ids)
|
|
91
|
+
citing.set(id, [...(citing.get(id) ?? []), { commit, branch, authored: Number(at) * 1000 }]);
|
|
92
|
+
}
|
|
93
|
+
const memo = new Map();
|
|
94
|
+
const onBranch = (commit) => {
|
|
95
|
+
if (found.has(commit))
|
|
96
|
+
return true;
|
|
97
|
+
if (!/^[0-9a-f]{4,64}$/i.test(commit))
|
|
98
|
+
return false;
|
|
99
|
+
if (!memo.has(commit))
|
|
100
|
+
memo.set(commit, gitOut(cwd, ["cat-file", "-e", `${commit}^{commit}`]) === undefined || !!gitOut(cwd, ["for-each-ref", "--count=1", `--contains=${commit}`, "--format=%(refname)", ...BRANCH_REFS])?.trim());
|
|
101
|
+
return memo.get(commit);
|
|
102
|
+
};
|
|
103
|
+
const history = { citing, onBranch };
|
|
104
|
+
histories.set(cwd, { refs, history });
|
|
105
|
+
return history;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The cards one commit's message cites, by its `Baesian-Card` trailers, read with git's own trailer rules as
|
|
109
|
+
* `readHistory()` reads them, and git's time for it. Only the IDs leave this function, never the message.
|
|
110
|
+
*/
|
|
111
|
+
export function citedIn(cwd, commit) {
|
|
112
|
+
const out = gitOut(cwd, ["log", "-1", "--format=%cI%x00%(trailers:only,unfold)", commit]);
|
|
113
|
+
if (!out)
|
|
114
|
+
return undefined;
|
|
115
|
+
const [at = "", trailers = ""] = out.split("\0");
|
|
116
|
+
const cards = new Set(trailerLines(trailers)
|
|
117
|
+
.filter((t) => t.key === CARD_TRAILER.toLowerCase())
|
|
118
|
+
.flatMap((t) => t.value.split(/[\s,]+/).filter((id) => CARD_ID.test(id))));
|
|
119
|
+
return { cards: [...cards], at: at.trim() };
|
|
120
|
+
}
|
|
121
|
+
//# sourceMappingURL=trailers.js.map
|
package/dist/trust.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { Told, cite } from "./cite.js";
|
|
4
|
+
import { BadSignature, DRAW_NAMESPACE, NAMESPACE, parseSignature, publicKeyBlob, signatureMatches } from "./sshsig.js";
|
|
5
|
+
import { NewerFormat, PasskeyKey, PinnedProject, ServiceKey, ServiceKeyRevocation, asWritten, describeError, newestFormat, upgradeFile } from "./schema.js";
|
|
6
|
+
// The keys `baesian verify` and the commit gate trust in a hosted repo (D138, D142, D159), and the checks made with them.
|
|
7
|
+
// In CI, the keys are pinned in CI's own settings, outside the repo, where a commit can't change them, with the repo's
|
|
8
|
+
// project beside them (B-mar8x); the record keeps a copy of the keys for checks on your machine, and the two must agree.
|
|
9
|
+
// docs/vault/Modules/src-trust.md describes it.
|
|
10
|
+
/** CI's pin: the trust files' JSON, one per line (D142, D159), and the line naming the repo's project (B-mar8x). */
|
|
11
|
+
export const TRUST_ENV = "BAESIAN_TRUST";
|
|
12
|
+
/** Or a path to a file of the same lines, for a protected file on a CI runner. */
|
|
13
|
+
export const TRUST_FILE_ENV = "BAESIAN_TRUST_FILE";
|
|
14
|
+
/** The pinned keys are unreadable: CI is misconfigured, and that must stop the check, never switch it off. */
|
|
15
|
+
export class BadTrust extends Told {
|
|
16
|
+
}
|
|
17
|
+
const KINDS = {
|
|
18
|
+
"service-key": { kind: "service-key", schema: ServiceKey },
|
|
19
|
+
"service-key-revocation": { kind: "service-key-revocation", schema: ServiceKeyRevocation },
|
|
20
|
+
"passkey-key": { kind: "passkey-key", schema: PasskeyKey },
|
|
21
|
+
};
|
|
22
|
+
/** One trust file's fields, read as its kind (D156), or why not. A newer format throws `NewerFormat` (D157). */
|
|
23
|
+
export function readTrustFile(fields, where) {
|
|
24
|
+
const type = typeof fields === "object" && fields !== null ? fields.type : undefined;
|
|
25
|
+
const k = typeof type === "string" ? KINDS[type] : undefined;
|
|
26
|
+
if (!k)
|
|
27
|
+
throw new BadTrust(`${where} isn't a service key, a revocation or a passkey key`);
|
|
28
|
+
const parsed = k.schema.safeParse(upgradeFile(k.kind, fields, where));
|
|
29
|
+
if (!parsed.success)
|
|
30
|
+
throw new BadTrust(`${where} is not valid:\n${describeError(parsed.error)}`);
|
|
31
|
+
return parsed.data;
|
|
32
|
+
}
|
|
33
|
+
/** Add a trust file to a root, by its kind. */
|
|
34
|
+
export function addTo(root, f) {
|
|
35
|
+
if (f.type === "service-key")
|
|
36
|
+
root.service.push(f);
|
|
37
|
+
else if (f.type === "service-key-revocation")
|
|
38
|
+
root.revoked.push(f);
|
|
39
|
+
else
|
|
40
|
+
root.passkeys.push(f);
|
|
41
|
+
}
|
|
42
|
+
/** What to do about a pin that's empty, or lacks a line it needs: `baesian ci` prints every line (B-pt52b). */
|
|
43
|
+
export const RUN_CI = "run `baesian ci` in the linked repo, and set it to the lines it prints";
|
|
44
|
+
/** The pin's line naming the repo's project (B-mar8x), as `baesian ci` prints it: compact JSON, as each key's line is. */
|
|
45
|
+
export function projectLine(project) {
|
|
46
|
+
return JSON.stringify(asWritten("project", PinnedProject.parse({ type: "project", format: newestFormat("project"), project })));
|
|
47
|
+
}
|
|
48
|
+
/** Set a pin's project from one of its lines: a pin names one project, however many of its lines name it. */
|
|
49
|
+
function pinProject(root, project, where) {
|
|
50
|
+
if (root.project !== undefined && root.project !== project) {
|
|
51
|
+
throw new BadTrust(`${where} names project ${project}, but the pin already names ${root.project}: a pin names one project, the repo's${cite("B-mar8x")}`);
|
|
52
|
+
}
|
|
53
|
+
root.project = project;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* A pin from text: one trust file's JSON per line, and the line naming the repo's project (`PinnedProject`, B-mar8x);
|
|
57
|
+
* blank lines and lines starting with `#` are skipped.
|
|
58
|
+
*/
|
|
59
|
+
export function parseTrust(text, where) {
|
|
60
|
+
const root = { service: [], revoked: [], passkeys: [] };
|
|
61
|
+
text.split(/\r?\n/).forEach((line, i) => {
|
|
62
|
+
const l = line.trim();
|
|
63
|
+
if (!l || l.startsWith("#"))
|
|
64
|
+
return;
|
|
65
|
+
const at = `${where}, line ${i + 1}`;
|
|
66
|
+
let fields;
|
|
67
|
+
try {
|
|
68
|
+
fields = JSON.parse(l);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
throw new BadTrust(`${at}, isn't JSON: each line is one trust file's JSON, or the project's`);
|
|
72
|
+
}
|
|
73
|
+
if (typeof fields === "object" && fields !== null && fields.type === "project") {
|
|
74
|
+
const parsed = PinnedProject.safeParse(upgradeFile("project", fields, at));
|
|
75
|
+
if (!parsed.success)
|
|
76
|
+
throw new BadTrust(`${at} is not valid:\n${describeError(parsed.error)}`);
|
|
77
|
+
pinProject(root, parsed.data.project, at);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
addTo(root, readTrustFile(fields, at));
|
|
81
|
+
});
|
|
82
|
+
return root;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* A pin must hold the service's key, since one with only passkeys would check nothing the service signs; and the repo's
|
|
86
|
+
* project, since a commit can name another in its config (B-mar8x).
|
|
87
|
+
*/
|
|
88
|
+
export function pinComplete(root, where) {
|
|
89
|
+
if (!root.service.length)
|
|
90
|
+
throw new BadTrust(`${where} pins no service key: give at least the service's key${cite("D142")}; ${RUN_CI}`);
|
|
91
|
+
if (root.project === undefined) {
|
|
92
|
+
throw new BadTrust(`${where} pins no project: CI must pin the repo's project beside its keys, so a commit can't link the repo to another${cite("B-mar8x")}; ${RUN_CI}`);
|
|
93
|
+
}
|
|
94
|
+
return root;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* CI's pin (D142, D159): the service's keys and passkey keys, and the repo's project beside them (B-mar8x), from
|
|
98
|
+
* `BAESIAN_TRUST` or the file `BAESIAN_TRUST_FILE` names, or both; undefined when neither is set. Set but empty or
|
|
99
|
+
* unreadable throws, as does a pin with no service key or no project: a misconfigured pin must fail the check, not quietly
|
|
100
|
+
* turn it off.
|
|
101
|
+
*/
|
|
102
|
+
export function pinnedTrust(env = process.env) {
|
|
103
|
+
const inline = env[TRUST_ENV];
|
|
104
|
+
const path = env[TRUST_FILE_ENV];
|
|
105
|
+
if (inline === undefined && path === undefined)
|
|
106
|
+
return undefined;
|
|
107
|
+
const parts = [];
|
|
108
|
+
if (inline !== undefined) {
|
|
109
|
+
// GitHub Actions gives a repository variable that isn't set as an empty string.
|
|
110
|
+
if (!inline.trim())
|
|
111
|
+
throw new BadTrust(`${TRUST_ENV} is set but empty, as GitHub Actions gives a variable that isn't set: ${RUN_CI}; or, in a repo that isn't linked, unset it`);
|
|
112
|
+
parts.push(parseTrust(inline, TRUST_ENV));
|
|
113
|
+
}
|
|
114
|
+
if (path !== undefined) {
|
|
115
|
+
let text;
|
|
116
|
+
try {
|
|
117
|
+
text = readFileSync(path, "utf8");
|
|
118
|
+
}
|
|
119
|
+
catch (e) {
|
|
120
|
+
throw new BadTrust(`${TRUST_FILE_ENV} names ${path}, which can't be read (${e instanceof Error ? e.message : String(e)})`);
|
|
121
|
+
}
|
|
122
|
+
parts.push(parseTrust(text, path));
|
|
123
|
+
}
|
|
124
|
+
const where = [inline !== undefined ? TRUST_ENV : "", path ?? ""].filter(Boolean).join(" and ");
|
|
125
|
+
const root = { service: parts.flatMap((p) => p.service), revoked: parts.flatMap((p) => p.revoked), passkeys: parts.flatMap((p) => p.passkeys) };
|
|
126
|
+
for (const p of parts)
|
|
127
|
+
if (p.project !== undefined)
|
|
128
|
+
pinProject(root, p.project, where);
|
|
129
|
+
return pinComplete(root, where);
|
|
130
|
+
}
|
|
131
|
+
/** Why `key` couldn't sign at `at`: before it was valid, after it expired, or once it was revoked. */
|
|
132
|
+
function invalidAt(root, key, at) {
|
|
133
|
+
const t = Date.parse(at);
|
|
134
|
+
if (t < Date.parse(key.valid_after))
|
|
135
|
+
return `before key ${key.key} was valid`;
|
|
136
|
+
if (key.valid_before && t >= Date.parse(key.valid_before))
|
|
137
|
+
return `after key ${key.key} expired`;
|
|
138
|
+
const gone = root.revoked.find((r) => r.key === key.key && Date.parse(r.at) <= t);
|
|
139
|
+
if (gone)
|
|
140
|
+
return `after key ${key.key} was revoked`;
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Whether a file counts as the service's word (D138, D160): `sigText` is an OpenSSH signature over `bytes` in the
|
|
145
|
+
* `baesian` namespace, by a trusted service key that was valid at `at`, the file's own time, and not revoked by then.
|
|
146
|
+
* Returns the key, for a passkey proof's origin, or why not. A signature in a newer format throws `NewerFormat` (D157).
|
|
147
|
+
*/
|
|
148
|
+
export function checkSigned(root, bytes, sigText, at, file) {
|
|
149
|
+
let sig;
|
|
150
|
+
try {
|
|
151
|
+
sig = parseSignature(sigText, `${file}.sig`);
|
|
152
|
+
}
|
|
153
|
+
catch (e) {
|
|
154
|
+
if (e instanceof NewerFormat)
|
|
155
|
+
throw e;
|
|
156
|
+
return { problem: `its signature can't be read (${e instanceof Error ? e.message : String(e)})` };
|
|
157
|
+
}
|
|
158
|
+
if (sig.namespace !== NAMESPACE)
|
|
159
|
+
return { problem: `it's signed for ${JSON.stringify(sig.namespace)}, not ${JSON.stringify(NAMESPACE)}` };
|
|
160
|
+
const keys = root.service.filter((k) => sameKey(k.public_key, sig.publicKey));
|
|
161
|
+
if (!keys.length)
|
|
162
|
+
return { problem: "it's signed by a key that isn't trusted" };
|
|
163
|
+
if (!signatureMatches(sig, bytes, NAMESPACE))
|
|
164
|
+
return { problem: "its signature doesn't match its bytes" };
|
|
165
|
+
const valid = keys.find((k) => !invalidAt(root, k, at));
|
|
166
|
+
return valid ? { key: valid } : { problem: `it was signed ${invalidAt(root, keys[0], at)}` };
|
|
167
|
+
}
|
|
168
|
+
function sameKey(openssh, blob) {
|
|
169
|
+
try {
|
|
170
|
+
return publicKeyBlob(openssh).equals(blob);
|
|
171
|
+
}
|
|
172
|
+
catch (e) {
|
|
173
|
+
if (e instanceof BadSignature)
|
|
174
|
+
return false;
|
|
175
|
+
throw e;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/** A file's fields in one order, so two copies of a trust file compare by content. */
|
|
179
|
+
const canonical = (f) => JSON.stringify(Object.fromEntries(Object.entries(f).sort(([a], [b]) => a.localeCompare(b))));
|
|
180
|
+
/**
|
|
181
|
+
* The record's keys that CI doesn't pin, or pins differently (D142, D159). A key in the record that CI doesn't pin fails
|
|
182
|
+
* the run: the commit gate on your machine trusts the record's copy, so an added key could pass forged answers there.
|
|
183
|
+
* Revocations only take trust away, so the record's apply whether or not CI pins them.
|
|
184
|
+
*/
|
|
185
|
+
export function pinProblems(pinned, record) {
|
|
186
|
+
const pins = new Set([...pinned.service, ...pinned.passkeys].map(canonical));
|
|
187
|
+
return record
|
|
188
|
+
.filter(({ key }) => !pins.has(canonical(key)))
|
|
189
|
+
.map(({ file, key }) => key.type === "service-key"
|
|
190
|
+
? `${file} isn't pinned in CI, or differs from the pinned copy${cite("D142")}. If the service rotated its key, add its line to ${TRUST_ENV}; if not, someone else added it: remove it and find out how. Its line: ${JSON.stringify(key)}`
|
|
191
|
+
: `${file}, ${key.handle}'s passkey, isn't pinned in CI, or differs from the pinned copy${cite("D159")}. If you enrolled it, add its line to ${TRUST_ENV}; if not, someone else added it: remove it and find out how. Its line: ${JSON.stringify(key)}`);
|
|
192
|
+
}
|
|
193
|
+
/** What the relay signs for a card revision's draw (P16 section 3): its ID, revision and file digest. */
|
|
194
|
+
export function drawMessage(id, revision, card) {
|
|
195
|
+
return Buffer.from(`${id} r${revision} ${createHash("sha256").update(card).digest("hex")}\n`);
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* A relay's draw for audit, from its proof (D158): the proof must be a trusted service key's signature, in the
|
|
199
|
+
* `baesian-draw` namespace, over the card revision's ID, revision and file digest, made while the key was valid at
|
|
200
|
+
* `at`. Ed25519 signatures are deterministic, so the relay can't redraw, and nobody without its key can predict one.
|
|
201
|
+
* The draw is the signature's SHA-256 digest, its first six bytes read as a number from 0 to 1, as the CLI's is.
|
|
202
|
+
*/
|
|
203
|
+
export function drawOfProof(root, proof, id, revision, card, at) {
|
|
204
|
+
let sig;
|
|
205
|
+
try {
|
|
206
|
+
sig = parseSignature(proof, `${id}'s draw proof`);
|
|
207
|
+
}
|
|
208
|
+
catch (e) {
|
|
209
|
+
if (e instanceof NewerFormat)
|
|
210
|
+
throw e;
|
|
211
|
+
return { problem: `its draw proof can't be read (${e instanceof Error ? e.message : String(e)})` };
|
|
212
|
+
}
|
|
213
|
+
const keys = root.service.filter((k) => sameKey(k.public_key, sig.publicKey));
|
|
214
|
+
if (!keys.length)
|
|
215
|
+
return { problem: "its draw proof is signed by a key that isn't trusted" };
|
|
216
|
+
if (!signatureMatches(sig, drawMessage(id, revision, card), DRAW_NAMESPACE))
|
|
217
|
+
return { problem: "its draw proof isn't the service's signature over this card revision" };
|
|
218
|
+
if (!keys.some((k) => !invalidAt(root, k, at)))
|
|
219
|
+
return { problem: `its draw proof was signed ${invalidAt(root, keys[0], at)}` };
|
|
220
|
+
return { draw: createHash("sha256").update(sig.signature).digest().readUIntBE(0, 6) / 2 ** 48 };
|
|
221
|
+
}
|
|
222
|
+
//# sourceMappingURL=trust.js.map
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// What the hosted service counts of its use (B-7nq85, "Ten kinds of event, fixed values only"; D141, "everyone, with an
|
|
2
|
+
// opt-out"). One list, shared: the service records an event only if it passes `usageProblem()` against it, and the
|
|
3
|
+
// website's public page (/counts) lists it, so the page and what's kept can't drift apart. Every value is from a fixed
|
|
4
|
+
// list, a number or yes/no, so no field can carry text. No imports: the website's build takes this file as it is.
|
|
5
|
+
export const USAGE_LEVELS = ["provisional", "blocking", "hard"];
|
|
6
|
+
export const USAGE_RESPONSES = ["accept", "alternative", "correction", "reject", "question", "need_context", "see_it_run", "not_my_area", "defer"];
|
|
7
|
+
export const USAGE_FACTS = ["schema", "public-api", "dependency", "user-facing", "publishes", "security", "privacy", "money", "data-loss", "infra"];
|
|
8
|
+
export const USAGE_REVERSE = ["cheap", "moderate", "one-way"];
|
|
9
|
+
/** The service's addresses, by name, for errors: a project's (`/v1/projects/{project}/…`) and the rest of `/v1/`. */
|
|
10
|
+
export const USAGE_ROUTES = [
|
|
11
|
+
"log",
|
|
12
|
+
"state",
|
|
13
|
+
"presets",
|
|
14
|
+
"live",
|
|
15
|
+
"pushes",
|
|
16
|
+
"upload",
|
|
17
|
+
"stop-waiting",
|
|
18
|
+
"verdicts",
|
|
19
|
+
"aliases",
|
|
20
|
+
"preferences",
|
|
21
|
+
"presence",
|
|
22
|
+
"proofs",
|
|
23
|
+
"config",
|
|
24
|
+
"signup",
|
|
25
|
+
"signin",
|
|
26
|
+
"recover",
|
|
27
|
+
"account",
|
|
28
|
+
"globus",
|
|
29
|
+
"machines",
|
|
30
|
+
"usage",
|
|
31
|
+
"other",
|
|
32
|
+
];
|
|
33
|
+
const oneOf = (values) => ({ type: "one-of", values });
|
|
34
|
+
const NUMBER = { type: "number" };
|
|
35
|
+
const YES_NO = { type: "yes-no" };
|
|
36
|
+
/** The ten kinds (B-7nq85), with their fields. */
|
|
37
|
+
export const USAGE_KINDS = {
|
|
38
|
+
account_created: {
|
|
39
|
+
when: "an account is made",
|
|
40
|
+
why: "how many people sign up, and whether they come through Globus",
|
|
41
|
+
fields: { via: { value: oneOf(["email", "globus"]), what: "how they signed up" } },
|
|
42
|
+
ids: true,
|
|
43
|
+
},
|
|
44
|
+
machine_approved: {
|
|
45
|
+
when: "a machine is approved to sign in to a project",
|
|
46
|
+
why: "how many machines and repos people link",
|
|
47
|
+
fields: { new_project: { value: YES_NO, what: "whether approving it made a new project" } },
|
|
48
|
+
ids: true,
|
|
49
|
+
},
|
|
50
|
+
card_filed: {
|
|
51
|
+
when: "a machine sends a card, or a revision of one, to the service",
|
|
52
|
+
why: "what agents ask about, how often the gate holds cards back, and which harnesses are used",
|
|
53
|
+
fields: {
|
|
54
|
+
revision: { value: NUMBER, what: "which revision of its card it is: 1 for a new card" },
|
|
55
|
+
level: { value: oneOf(USAGE_LEVELS), what: "its level in force" },
|
|
56
|
+
gate: { value: oneOf(["shown", "held", "shadow"]), what: "whether the gate showed it, held it back, or would have in shadow" },
|
|
57
|
+
touches: { value: { type: "any-of", values: USAGE_FACTS }, what: "which of the ten facts it says it touches" },
|
|
58
|
+
reverse: { value: oneOf(USAGE_REVERSE), what: "how costly it says it is to undo" },
|
|
59
|
+
harness: { value: oneOf(["claude-code", "codex", "other"]), what: "which harness filed it; any other agent's name is kept only as other" },
|
|
60
|
+
by_baesian: { value: YES_NO, what: "whether Baesian filed it itself (a proposal from your answers)" },
|
|
61
|
+
},
|
|
62
|
+
ids: true,
|
|
63
|
+
},
|
|
64
|
+
card_answered: {
|
|
65
|
+
when: "you answer a card on the website",
|
|
66
|
+
why: "how often answers overturn the agent's pick or ask for more context, how long cards wait, and whether you answer from a phone",
|
|
67
|
+
fields: {
|
|
68
|
+
response: { value: oneOf(USAGE_RESPONSES), what: "which answer it was" },
|
|
69
|
+
client: { value: oneOf(["desktop", "phone"]), what: "the desktop layout or the phone's" },
|
|
70
|
+
level: { value: oneOf(USAGE_LEVELS), what: "the card's level in force" },
|
|
71
|
+
minutes_waiting: { value: NUMBER, what: "minutes from the card's filing to the answer" },
|
|
72
|
+
blind_match: { value: YES_NO, what: "whether your choice before the agent's pick was shown matched it; left out for a card you'd answered in chat first" },
|
|
73
|
+
switched: { value: YES_NO, what: "whether you changed your choice after seeing the agent's pick" },
|
|
74
|
+
was_held: { value: YES_NO, what: "whether the gate had held the card back" },
|
|
75
|
+
made_rule: { value: YES_NO, what: "whether you asked for a rule from it" },
|
|
76
|
+
},
|
|
77
|
+
ids: true,
|
|
78
|
+
},
|
|
79
|
+
preferences_changed: {
|
|
80
|
+
when: "you change your preferences",
|
|
81
|
+
why: "whether the preference sliders are used",
|
|
82
|
+
fields: {
|
|
83
|
+
mode: { value: oneOf(["shadow", "on"]), what: "whether holding back is in shadow or on" },
|
|
84
|
+
loosening: { value: YES_NO, what: "whether the change lets more cards past you" },
|
|
85
|
+
rows_changed: { value: NUMBER, what: "how many rows changed" },
|
|
86
|
+
},
|
|
87
|
+
ids: true,
|
|
88
|
+
},
|
|
89
|
+
topic_merged: {
|
|
90
|
+
when: "you merge two topics",
|
|
91
|
+
why: "whether merging topics is used",
|
|
92
|
+
fields: {},
|
|
93
|
+
ids: true,
|
|
94
|
+
},
|
|
95
|
+
push_sent: {
|
|
96
|
+
when: "the service sends a notification",
|
|
97
|
+
why: "how often notifications go, and whether they arrive",
|
|
98
|
+
fields: {
|
|
99
|
+
reason: { value: oneOf(["at once", "digest"]), what: "a blocking or hard card at once, or a digest" },
|
|
100
|
+
urgency: { value: oneOf(["high", "normal"]), what: "the urgency the push service was given" },
|
|
101
|
+
cards: { value: NUMBER, what: "how many cards it counted" },
|
|
102
|
+
result: { value: oneOf(["delivered", "gone", "failed"]), what: "what the push services answered" },
|
|
103
|
+
},
|
|
104
|
+
ids: true,
|
|
105
|
+
},
|
|
106
|
+
push_opened: {
|
|
107
|
+
when: "you open a notification",
|
|
108
|
+
why: "how long after a notification you open it",
|
|
109
|
+
fields: { minutes_after: { value: NUMBER, what: "minutes from the notification to opening it" } },
|
|
110
|
+
ids: true,
|
|
111
|
+
},
|
|
112
|
+
visit: {
|
|
113
|
+
when: "you open a project's page, the first time each day on each kind of screen",
|
|
114
|
+
why: "how many people answer each day, and on which kind of screen",
|
|
115
|
+
fields: { client: { value: oneOf(["desktop", "phone"]), what: "the desktop layout or the phone's" } },
|
|
116
|
+
ids: true,
|
|
117
|
+
},
|
|
118
|
+
error: {
|
|
119
|
+
when: "the service fails to answer a request",
|
|
120
|
+
why: "where the service fails",
|
|
121
|
+
fields: {
|
|
122
|
+
route: { value: oneOf(USAGE_ROUTES), what: "which of the service's addresses failed, by name" },
|
|
123
|
+
status: { value: NUMBER, what: "the error's status code" },
|
|
124
|
+
},
|
|
125
|
+
ids: false,
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
/** What's never counted, in words, as the public page says it. */
|
|
129
|
+
export const NEVER_COUNTED = [
|
|
130
|
+
"what a card says: its title, question, options, crux, body or comments",
|
|
131
|
+
"topics, deck names, project names and repo names or addresses",
|
|
132
|
+
"branch names, file paths and code",
|
|
133
|
+
"handles, email addresses, machine names and agents' own names",
|
|
134
|
+
"card IDs, project IDs and account IDs",
|
|
135
|
+
"network addresses, locations and browsers' names",
|
|
136
|
+
"the exact time anything happened: only its hour",
|
|
137
|
+
];
|
|
138
|
+
/** Raw events are kept this many days, then only daily counts with no ID, key or hour. */
|
|
139
|
+
export const USAGE_RAW_DAYS = 90;
|
|
140
|
+
/** The fields an event of a kind may record, checked: undefined if they pass, otherwise why not. Nothing else is kept. */
|
|
141
|
+
export function usageProblem(kind, fields) {
|
|
142
|
+
const k = Object.hasOwn(USAGE_KINDS, kind) ? USAGE_KINDS[kind] : undefined;
|
|
143
|
+
if (!k)
|
|
144
|
+
return `${kind} isn't a kind of event the service counts`;
|
|
145
|
+
for (const [name, v] of Object.entries(fields)) {
|
|
146
|
+
const f = Object.hasOwn(k.fields, name) ? k.fields[name] : undefined;
|
|
147
|
+
if (!f)
|
|
148
|
+
return `${kind} has no field ${name}`;
|
|
149
|
+
const spec = f.value;
|
|
150
|
+
if (spec.type === "number" && !(typeof v === "number" && Number.isInteger(v) && v >= 0 && v <= 10_000_000))
|
|
151
|
+
return `${kind}.${name} is a whole number`;
|
|
152
|
+
if (spec.type === "yes-no" && typeof v !== "boolean")
|
|
153
|
+
return `${kind}.${name} is yes or no`;
|
|
154
|
+
if (spec.type === "one-of" && !(typeof v === "string" && spec.values.includes(v)))
|
|
155
|
+
return `${kind}.${name} is one of ${spec.values.join(", ")}`;
|
|
156
|
+
if (spec.type === "any-of" && !(Array.isArray(v) && v.every((x) => typeof x === "string" && spec.values.includes(x)) && new Set(v).size === v.length))
|
|
157
|
+
return `${kind}.${name} is a list from ${spec.values.join(", ")}`;
|
|
158
|
+
}
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
//# sourceMappingURL=usage-fields.js.map
|