getbotons 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +505 -2
- package/installer/admin.mjs +6 -0
- package/installer/botons.mjs +430 -0
- package/installer/brain-clis.mjs +28 -0
- package/installer/linger.mjs +39 -0
- package/installer/output.mjs +27 -0
- package/installer/revision.mjs +30 -0
- package/installer/shell-path.mjs +66 -0
- package/installer/source-lock.json +2891 -0
- package/package.json +26 -4
- package/payload/LICENSE +21 -0
- package/payload/SECURITY.md +33 -0
- package/payload/installer/admin.mjs +6 -0
- package/payload/installer/botons.mjs +430 -0
- package/payload/installer/brain-clis.mjs +28 -0
- package/payload/installer/linger.mjs +39 -0
- package/payload/installer/output.mjs +27 -0
- package/payload/installer/revision.mjs +30 -0
- package/payload/installer/shell-path.mjs +66 -0
- package/payload/installer/source-lock.json +2891 -0
- package/payload/npm-shrinkwrap.json +51 -0
- package/payload/package.json +13 -0
- package/payload/scripts/backup.ts +47 -0
- package/payload/scripts/botons-backup.service +15 -0
- package/payload/scripts/botons-backup.timer +11 -0
- package/payload/scripts/botons.service +36 -0
- package/payload/scripts/check-browser.ts +90 -0
- package/payload/scripts/doctor.ts +790 -0
- package/payload/scripts/install-backup-timer.ts +52 -0
- package/payload/scripts/install-service.ts +206 -0
- package/payload/scripts/login.ts +17 -0
- package/payload/scripts/migrate-names.ts +47 -0
- package/payload/scripts/pair.ts +163 -0
- package/payload/scripts/restore.ts +108 -0
- package/payload/server/bin/botons +4 -0
- package/payload/server/bin/legacy/minion +4 -0
- package/payload/server/bin/legacy/tots +4 -0
- package/payload/server/src/acting.ts +16 -0
- package/payload/server/src/api-keys.ts +544 -0
- package/payload/server/src/api.ts +1484 -0
- package/payload/server/src/app.ts +546 -0
- package/payload/server/src/approvals.ts +873 -0
- package/payload/server/src/ask-rules.ts +181 -0
- package/payload/server/src/audit.ts +158 -0
- package/payload/server/src/auth.ts +497 -0
- package/payload/server/src/backup.ts +382 -0
- package/payload/server/src/brain-login.ts +75 -0
- package/payload/server/src/browser-cert.ts +67 -0
- package/payload/server/src/browser-handoff.ts +119 -0
- package/payload/server/src/browser-handover.ts +175 -0
- package/payload/server/src/browser-policy.ts +210 -0
- package/payload/server/src/browser-proxy.ts +580 -0
- package/payload/server/src/browser-request.ts +335 -0
- package/payload/server/src/browser-screenshot.ts +56 -0
- package/payload/server/src/browser-send.ts +155 -0
- package/payload/server/src/browser.ts +2164 -0
- package/payload/server/src/callback.ts +218 -0
- package/payload/server/src/capabilities.ts +90 -0
- package/payload/server/src/cards.ts +243 -0
- package/payload/server/src/cli/approve-mcp.ts +119 -0
- package/payload/server/src/cli/botons.ts +629 -0
- package/payload/server/src/cli/browser-mcp.ts +168 -0
- package/payload/server/src/cli/workspace-file.ts +78 -0
- package/payload/server/src/cli-homes.ts +38 -0
- package/payload/server/src/cli-install.ts +104 -0
- package/payload/server/src/cli-updates.ts +112 -0
- package/payload/server/src/codex-config-repair.ts +80 -0
- package/payload/server/src/config.ts +473 -0
- package/payload/server/src/connector-access.ts +158 -0
- package/payload/server/src/connector-launch.ts +203 -0
- package/payload/server/src/connectors.ts +947 -0
- package/payload/server/src/disk-usage.ts +62 -0
- package/payload/server/src/engine.ts +2328 -0
- package/payload/server/src/env-init.ts +5 -0
- package/payload/server/src/events.ts +46 -0
- package/payload/server/src/files.ts +528 -0
- package/payload/server/src/github-access.ts +52 -0
- package/payload/server/src/health.ts +117 -0
- package/payload/server/src/held-dir.ts +96 -0
- package/payload/server/src/hooks.ts +141 -0
- package/payload/server/src/http.ts +207 -0
- package/payload/server/src/index.ts +139 -0
- package/payload/server/src/install-type.ts +11 -0
- package/payload/server/src/instructions.ts +204 -0
- package/payload/server/src/isolation.ts +234 -0
- package/payload/server/src/legacy-upgrade.ts +668 -0
- package/payload/server/src/limits.ts +44 -0
- package/payload/server/src/lock.ts +75 -0
- package/payload/server/src/log.ts +137 -0
- package/payload/server/src/managed-clis.ts +65 -0
- package/payload/server/src/mcp-client.ts +229 -0
- package/payload/server/src/notify.ts +193 -0
- package/payload/server/src/oauth-connections.ts +650 -0
- package/payload/server/src/oauth.ts +311 -0
- package/payload/server/src/outbound.ts +142 -0
- package/payload/server/src/portable.ts +1669 -0
- package/payload/server/src/prompt.ts +676 -0
- package/payload/server/src/providers/api-brain.ts +612 -0
- package/payload/server/src/providers/api-history.ts +131 -0
- package/payload/server/src/providers/api-tools.ts +481 -0
- package/payload/server/src/providers/api-wire.ts +690 -0
- package/payload/server/src/providers/claude-cli.ts +620 -0
- package/payload/server/src/providers/cli-check.ts +265 -0
- package/payload/server/src/providers/cli-turn.ts +20 -0
- package/payload/server/src/providers/codex-cli.ts +552 -0
- package/payload/server/src/providers/codex-workspace.ts +61 -0
- package/payload/server/src/providers/failures.ts +143 -0
- package/payload/server/src/providers/index.ts +131 -0
- package/payload/server/src/providers/mcp-stdio.ts +147 -0
- package/payload/server/src/providers/mock.ts +94 -0
- package/payload/server/src/providers/process.ts +268 -0
- package/payload/server/src/providers/readiness.ts +43 -0
- package/payload/server/src/providers/sse.ts +73 -0
- package/payload/server/src/providers/types.ts +173 -0
- package/payload/server/src/rates.ts +128 -0
- package/payload/server/src/reactions.ts +266 -0
- package/payload/server/src/recent-logs.ts +489 -0
- package/payload/server/src/rehearsal.ts +593 -0
- package/payload/server/src/rehearsals.ts +303 -0
- package/payload/server/src/replay.ts +165 -0
- package/payload/server/src/sandbox.ts +767 -0
- package/payload/server/src/schedule.ts +214 -0
- package/payload/server/src/scheduler.ts +24 -0
- package/payload/server/src/schema.ts +882 -0
- package/payload/server/src/secrets.ts +46 -0
- package/payload/server/src/setup.ts +356 -0
- package/payload/server/src/skill-packages.ts +363 -0
- package/payload/server/src/skills.ts +527 -0
- package/payload/server/src/spend.ts +255 -0
- package/payload/server/src/ssrf.ts +126 -0
- package/payload/server/src/starter-gate.ts +168 -0
- package/payload/server/src/starters.ts +610 -0
- package/payload/server/src/storage.ts +32 -0
- package/payload/server/src/store.ts +3087 -0
- package/payload/server/src/tailnet.ts +132 -0
- package/payload/server/src/templates.ts +454 -0
- package/payload/server/src/trash.ts +772 -0
- package/payload/server/src/util.ts +97 -0
- package/payload/server/src/webhooks.ts +216 -0
- package/payload/server/src/work-feed.ts +473 -0
- package/payload/shared/app-version.d.mts +1 -0
- package/payload/shared/app-version.mjs +3 -0
- package/payload/shared/boton.ts +323 -0
- package/payload/shared/brand.ts +107 -0
- package/payload/shared/cards.ts +427 -0
- package/payload/shared/effort.ts +91 -0
- package/payload/shared/files.ts +104 -0
- package/payload/shared/handoff.ts +97 -0
- package/payload/shared/install-commands.ts +7 -0
- package/payload/shared/legacy-names.d.mts +35 -0
- package/payload/shared/legacy-names.mjs +244 -0
- package/payload/shared/live-view.ts +94 -0
- package/payload/shared/look-critter.ts +189 -0
- package/payload/shared/look-tater.ts +127 -0
- package/payload/shared/look.ts +200 -0
- package/payload/shared/reactions.ts +43 -0
- package/payload/shared/seeded.ts +34 -0
- package/payload/shared/services.ts +90 -0
- package/payload/shared/types.ts +3016 -0
- package/payload/shared/util.ts +68 -0
- package/payload/shared/versions.ts +44 -0
- package/payload/shared/wake.ts +164 -0
- package/payload/web/dist/apple-touch-icon.png +0 -0
- package/payload/web/dist/assets/Icons-nytVAa9g.js +1 -0
- package/payload/web/dist/assets/NewBotDialog-DXr2XhDP.js +1 -0
- package/payload/web/dist/assets/PairScreen-CMcJO7cQ.js +1 -0
- package/payload/web/dist/assets/Panel-Bvqz3I3m.js +4 -0
- package/payload/web/dist/assets/ReactionPicker-B0SKsjiy.js +1 -0
- package/payload/web/dist/assets/SettingRows-CvLomtuo.js +4 -0
- package/payload/web/dist/assets/SettingsDialog-CQ41LV-P.js +8 -0
- package/payload/web/dist/assets/Setup-C-3P7Mtn.js +1 -0
- package/payload/web/dist/assets/Skills-DXMH69kH.js +1 -0
- package/payload/web/dist/assets/StarterSetup-Ce2V1_Cs.js +5 -0
- package/payload/web/dist/assets/Steps-CKtB3GAM.js +1 -0
- package/payload/web/dist/assets/emoji-catalog-DfpPz8qw.js +540 -0
- package/payload/web/dist/assets/index-BEU7FDxV.css +1 -0
- package/payload/web/dist/assets/index-Dhwn-rf0.js +3 -0
- package/payload/web/dist/assets/react-C1iE8mnY.js +9 -0
- package/payload/web/dist/assets/reactions-rw0YeCmA.js +1 -0
- package/payload/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- package/payload/web/dist/assets/util-Cx5feSJu.js +1 -0
- package/payload/web/dist/assets/vendor-B3qdcjGo.js +14 -0
- package/payload/web/dist/favicon.svg +1 -0
- package/payload/web/dist/icon-192.png +0 -0
- package/payload/web/dist/icon-512.png +0 -0
- package/payload/web/dist/icon.svg +1 -0
- package/payload/web/dist/index.html +67 -0
- package/payload/web/dist/manifest.webmanifest +12 -0
- package/shared/legacy-names.d.mts +35 -0
- package/shared/legacy-names.mjs +244 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
2
|
+
import { actionKey, HttpError } from './store.ts';
|
|
3
|
+
import { orNull, truncate } from './util.ts';
|
|
4
|
+
|
|
5
|
+
// Ask first rules are Claude Code permission rules (`Bash(git push:*)`, `mcp__slack__send_message`).
|
|
6
|
+
// Claude Code decides when a call matches; matchRule only finds which rule it was, so it errs towards matching.
|
|
7
|
+
|
|
8
|
+
const ASK_FIRST_MAX = 50;
|
|
9
|
+
const RULE_MAX = 200;
|
|
10
|
+
|
|
11
|
+
/** `Tool` or `Tool(specifier)`: a tool name (an MCP one may end in `*`), and anything but a line break inside the parentheses. */
|
|
12
|
+
const RULE = /^([A-Za-z][\w-]*\*?)(?:\((.+)\))?$/;
|
|
13
|
+
|
|
14
|
+
function askRule(v: unknown): string {
|
|
15
|
+
const field = 'each askFirst rule';
|
|
16
|
+
if (typeof v !== 'string') throw new HttpError(400, `${field} must be a string`);
|
|
17
|
+
const rule = v.trim();
|
|
18
|
+
if (!rule) throw new HttpError(400, `${field} is empty`);
|
|
19
|
+
if (rule.length > RULE_MAX) throw new HttpError(400, `${field} is too long (max ${RULE_MAX} characters)`);
|
|
20
|
+
if (/[\r\n]/.test(rule) || !RULE.test(rule)) {
|
|
21
|
+
throw new HttpError(400, `${field} must be a Claude Code permission rule, such as Bash(git push:*), WebFetch(domain:example.com) or mcp__slack__send_message (got "${rule}")`);
|
|
22
|
+
}
|
|
23
|
+
return rule;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Bot.askFirst as given: valid rules, without repeats. */
|
|
27
|
+
export function askFirstList(v: unknown): string[] {
|
|
28
|
+
if (!Array.isArray(v)) throw new HttpError(400, 'askFirst must be a list of permission rules');
|
|
29
|
+
const list = [...new Set(v.map(askRule))];
|
|
30
|
+
if (list.length > ASK_FIRST_MAX) throw new HttpError(400, `askFirst can hold at most ${ASK_FIRST_MAX} rules`);
|
|
31
|
+
return list;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What "Always allow" adds to Bot.alwaysAllow for a rule, or null if no action kind can hold it. */
|
|
35
|
+
export function ruleKey(rule: string): string | null {
|
|
36
|
+
return orNull(() => actionKey(rule));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The rules a turn asks about, and the always-allowed ones (never in a sandboxed turn). */
|
|
40
|
+
export function splitRules(askFirst: string[], alwaysAllow: string[], sandboxed: boolean): { ask: string[]; allow: string[] } {
|
|
41
|
+
const always = new Set(alwaysAllow);
|
|
42
|
+
const allow = sandboxed ? [] : askFirst.filter((r) => always.has(ruleKey(r) ?? ''));
|
|
43
|
+
return { ask: askFirst.filter((r) => !allow.includes(r)), allow };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A glob as a regex: `**` any path, `*` any part of one, `?` one character. */
|
|
47
|
+
function globRegex(glob: string): RegExp {
|
|
48
|
+
let out = '';
|
|
49
|
+
for (let i = 0; i < glob.length; i++) {
|
|
50
|
+
const c = glob[i];
|
|
51
|
+
if (c === '*' && glob[i + 1] === '*') {
|
|
52
|
+
out += '.*';
|
|
53
|
+
i++;
|
|
54
|
+
if (glob[i + 1] === '/') i++;
|
|
55
|
+
} else if (c === '*') out += '[^/]*';
|
|
56
|
+
else if (c === '?') out += '[^/]';
|
|
57
|
+
else out += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
58
|
+
}
|
|
59
|
+
return new RegExp(`^${out}$`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The line itself and each command in it (split on && || ; | & and line breaks), without leading VAR=value assignments. */
|
|
63
|
+
function commandsOf(line: string): string[] {
|
|
64
|
+
const parts = [line, ...line.split(/&&|\|\||[;|&\n]/)].map((p) => p.trim().replace(/^(?:\w+=\S*\s+)+/, '').trim());
|
|
65
|
+
return [...new Set(parts.filter(Boolean))];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function bashMatches(spec: string, command: string): boolean {
|
|
69
|
+
const commands = commandsOf(command);
|
|
70
|
+
// Prefix rules, Claude Code's `:*` form: `git push:*`.
|
|
71
|
+
if (spec.endsWith(':*')) {
|
|
72
|
+
const prefix = spec.slice(0, -2).trim();
|
|
73
|
+
return commands.some((c) => c.startsWith(prefix));
|
|
74
|
+
}
|
|
75
|
+
if (spec.includes('*')) {
|
|
76
|
+
// `*` is anything (slashes too), and `git push *` also matches a bare `git push`.
|
|
77
|
+
const body = spec
|
|
78
|
+
.trim()
|
|
79
|
+
.split('*')
|
|
80
|
+
.map((part) => part.replace(/[.+?^${}()|[\]\\]/g, '\\$&'))
|
|
81
|
+
.join('.*');
|
|
82
|
+
const re = new RegExp(`^${body.endsWith(' .*') ? `${body.slice(0, -3)}(?: .*)?` : body}$`, 's');
|
|
83
|
+
return commands.some((c) => re.test(c));
|
|
84
|
+
}
|
|
85
|
+
return commands.some((c) => c === spec.trim());
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A path rule as an absolute glob: `//abs`, `~/home`, else relative to the working folder. */
|
|
89
|
+
function pathGlob(spec: string, ctx: { cwd: string; home: string }): string {
|
|
90
|
+
if (spec.startsWith('//')) return spec.slice(1);
|
|
91
|
+
if (spec.startsWith('~/')) return join(ctx.home, spec.slice(2));
|
|
92
|
+
if (spec.startsWith('/')) return join(ctx.cwd, spec.slice(1));
|
|
93
|
+
return join(ctx.cwd, spec.replace(/^\.\//, ''));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function pathMatches(spec: string, file: string, ctx: { cwd: string; home: string }): boolean {
|
|
97
|
+
const abs = isAbsolute(file) ? resolve(file) : resolve(ctx.cwd, file);
|
|
98
|
+
const glob = pathGlob(spec, ctx);
|
|
99
|
+
// `dir` alone covers what is in it too.
|
|
100
|
+
return globRegex(glob).test(abs) || (!/[*?]/.test(glob) && abs.startsWith(`${glob.replace(/\/$/, '')}/`));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit']);
|
|
104
|
+
const READ_TOOLS = new Set(['Read', 'Grep', 'Glob', 'LS']);
|
|
105
|
+
const str = (v: unknown) => (typeof v === 'string' ? v : '');
|
|
106
|
+
|
|
107
|
+
function covers(rule: string, toolName: string, input: Record<string, unknown>, ctx: { cwd: string; home: string }): boolean {
|
|
108
|
+
const m = RULE.exec(rule);
|
|
109
|
+
if (!m) return false;
|
|
110
|
+
const [, name, spec] = m;
|
|
111
|
+
if (name.startsWith('mcp__')) {
|
|
112
|
+
// `mcp__server`, `mcp__server__*` and `mcp__server__tool` (an MCP rule has no specifier).
|
|
113
|
+
const base = name.replace(/(?:__)?\*$/, '');
|
|
114
|
+
return toolName === base || toolName.startsWith(`${base}__`);
|
|
115
|
+
}
|
|
116
|
+
if (spec === undefined || spec === '*') return name === toolName || (name === 'Edit' && EDIT_TOOLS.has(toolName)) || (name === 'Read' && READ_TOOLS.has(toolName));
|
|
117
|
+
if (name === 'Bash') return toolName === 'Bash' && bashMatches(spec, str(input.command));
|
|
118
|
+
if (name === 'Edit' || name === 'Read') {
|
|
119
|
+
const tools = name === 'Edit' ? EDIT_TOOLS : READ_TOOLS;
|
|
120
|
+
if (!tools.has(toolName)) return false;
|
|
121
|
+
const file = str(input.file_path) || str(input.notebook_path) || str(input.path);
|
|
122
|
+
// No path (a search of the working folder): ask rather than miss it.
|
|
123
|
+
return !file || pathMatches(spec, file, ctx);
|
|
124
|
+
}
|
|
125
|
+
if (name === 'WebFetch' && spec.startsWith('domain:')) {
|
|
126
|
+
if (toolName !== 'WebFetch') return false;
|
|
127
|
+
const domain = spec.slice(7).toLowerCase();
|
|
128
|
+
try {
|
|
129
|
+
const host = new URL(str(input.url)).hostname.toLowerCase();
|
|
130
|
+
return host === domain || host.endsWith(`.${domain}`);
|
|
131
|
+
} catch {
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// A specifier this doesn't know: any call of the tool.
|
|
136
|
+
return name === toolName;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The first of `rules` that covers a tool call, or null. `ctx` resolves path rules. */
|
|
140
|
+
export function matchRule(rules: string[], toolName: string, input: unknown, ctx: { cwd: string; home: string }): string | null {
|
|
141
|
+
const args = input && typeof input === 'object' ? (input as Record<string, unknown>) : {};
|
|
142
|
+
return rules.find((r) => covers(r, toolName, args, ctx)) ?? null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Identifies exactly the call the owner approves, so only that same call is allowed: every argument, in any key
|
|
147
|
+
* order. Only Bash's description is left out, since Claude Code defines it as the model's words about the command;
|
|
148
|
+
* any other tool's description (an MCP tool's, say) may be what the call does.
|
|
149
|
+
*/
|
|
150
|
+
export function callKey(toolName: string, input: unknown): string {
|
|
151
|
+
const args: Record<string, unknown> = input && typeof input === 'object' && !Array.isArray(input) ? { ...(input as Record<string, unknown>) } : { value: input };
|
|
152
|
+
if (toolName === 'Bash') delete args.description;
|
|
153
|
+
return JSON.stringify([toolName, sortKeys(args)]);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function sortKeys(v: unknown): unknown {
|
|
157
|
+
if (Array.isArray(v)) return v.map(sortKeys);
|
|
158
|
+
if (v && typeof v === 'object') {
|
|
159
|
+
return Object.fromEntries(
|
|
160
|
+
Object.keys(v as object)
|
|
161
|
+
.sort()
|
|
162
|
+
.map((k) => [k, sortKeys((v as Record<string, unknown>)[k])]),
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
return v;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** A tool call's card, made from the call itself, never from what the Boton says about it. */
|
|
169
|
+
export function describeCall(toolName: string, input: unknown): { title: string; details: string } {
|
|
170
|
+
const args = input && typeof input === 'object' ? (input as Record<string, unknown>) : {};
|
|
171
|
+
const mcp = /^mcp__(.+?)__(.+)$/.exec(toolName);
|
|
172
|
+
let title = mcp ? `Use ${mcp[2]} (${mcp[1]})` : `Use ${toolName}`;
|
|
173
|
+
let details = JSON.stringify(input ?? {}, null, 2);
|
|
174
|
+
if (toolName === 'Bash') {
|
|
175
|
+
title = `Run \`${str(args.command).replace(/\s+/g, ' ').trim()}\``;
|
|
176
|
+
details = str(args.command);
|
|
177
|
+
} else if (EDIT_TOOLS.has(toolName)) title = `${toolName === 'Write' ? 'Write' : 'Edit'} ${str(args.file_path) || str(args.notebook_path) || 'a file'}`;
|
|
178
|
+
else if (toolName === 'Read') title = `Read ${str(args.file_path) || 'a file'}`;
|
|
179
|
+
else if (toolName === 'WebFetch') title = `Fetch ${str(args.url) || 'a web page'}`;
|
|
180
|
+
return { title: truncate(title, 200), details };
|
|
181
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import type { AuditAction, AuditActorKind, AuditEvent } from '../../shared/types.ts';
|
|
3
|
+
import type { Principal } from './auth.ts';
|
|
4
|
+
import { log } from './log.ts';
|
|
5
|
+
import { nowIso, parseJson, type Row } from './util.ts';
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* The audit log: who did what to which thing, for the actions that change who
|
|
9
|
+
* can reach the server or what a Boton may do. Callers pass ids, names and
|
|
10
|
+
* small facts only, never a token, code, secret, URL query or message text;
|
|
11
|
+
* `clean` drops anything else that looks like one.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Newest rows kept; older ones are deleted as new ones arrive. */
|
|
15
|
+
export const AUDIT_KEEP = 10_000;
|
|
16
|
+
|
|
17
|
+
export interface AuditActor {
|
|
18
|
+
kind: AuditActorKind;
|
|
19
|
+
id: string | null;
|
|
20
|
+
label: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type Detail = string | number | boolean | null | string[];
|
|
24
|
+
|
|
25
|
+
export interface AuditEntry {
|
|
26
|
+
action: AuditAction;
|
|
27
|
+
target?: { kind: string; id: string; label?: string } | null;
|
|
28
|
+
details?: Record<string, Detail | undefined>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Someone at the server's machine (`npm run pair`): proven by access to the data folder. */
|
|
32
|
+
export const HOST_ACTOR: AuditActor = { kind: 'host', id: null, label: 'Server machine' };
|
|
33
|
+
export const SYSTEM_ACTOR: AuditActor = { kind: 'system', id: null, label: 'Botons' };
|
|
34
|
+
|
|
35
|
+
const SECRETISH = /(token|secret|password|code|key|authorization|cookie)/i;
|
|
36
|
+
|
|
37
|
+
function clean(details: Record<string, Detail | undefined> = {}): Record<string, Detail> {
|
|
38
|
+
const out: Record<string, Detail> = {};
|
|
39
|
+
for (const [k, v] of Object.entries(details)) {
|
|
40
|
+
if (v === undefined || SECRETISH.test(k)) continue;
|
|
41
|
+
if (Array.isArray(v)) out[k] = v.slice(0, 50).map((x) => String(x).slice(0, 200));
|
|
42
|
+
else out[k] = typeof v === 'string' ? v.slice(0, 200) : v;
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Also used by `npm run pair`, which works on the database directly. Never throws: an audit row must not undo the
|
|
49
|
+
* action. With `required`, it throws instead: inside the caller's transaction, so the action and its record are kept
|
|
50
|
+
* together or not at all.
|
|
51
|
+
*/
|
|
52
|
+
export function recordAudit(db: DatabaseSync, actor: AuditActor, entry: AuditEntry, opts: { required?: boolean } = {}): void {
|
|
53
|
+
try {
|
|
54
|
+
db.prepare(
|
|
55
|
+
'insert into audit_events (at, actor_kind, actor_id, actor_label, action, target_kind, target_id, target_label, details) values (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
56
|
+
).run(
|
|
57
|
+
nowIso(),
|
|
58
|
+
actor.kind,
|
|
59
|
+
actor.id,
|
|
60
|
+
actor.label.slice(0, 120),
|
|
61
|
+
entry.action,
|
|
62
|
+
entry.target?.kind ?? null,
|
|
63
|
+
entry.target?.id ?? null,
|
|
64
|
+
(entry.target?.label ?? '').slice(0, 120),
|
|
65
|
+
JSON.stringify(clean(entry.details)),
|
|
66
|
+
);
|
|
67
|
+
db.prepare('delete from audit_events where seq <= (select max(seq) from audit_events) - ?').run(AUDIT_KEEP);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
log.error('could not write an audit event', { action: entry.action, required: opts.required || undefined, err });
|
|
70
|
+
if (opts.required) throw err;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function toEvent(r: Row): AuditEvent {
|
|
75
|
+
return {
|
|
76
|
+
seq: Number(r.seq),
|
|
77
|
+
at: r.at,
|
|
78
|
+
actor: { kind: r.actor_kind, id: r.actor_id ?? null, label: r.actor_label },
|
|
79
|
+
action: r.action,
|
|
80
|
+
target: r.target_kind ? { kind: r.target_kind, id: r.target_id ?? '', label: r.target_label } : null,
|
|
81
|
+
details: parseJson(r.details, {}),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export class Audit {
|
|
86
|
+
#db: DatabaseSync;
|
|
87
|
+
|
|
88
|
+
constructor(db: DatabaseSync) {
|
|
89
|
+
this.#db = db;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The device's label or the Boton's name as they are now, so the row still reads after either is gone. */
|
|
93
|
+
actorOf(principal: Principal | null): AuditActor {
|
|
94
|
+
if (principal?.kind === 'boton') {
|
|
95
|
+
const name = this.#db.prepare('select name from bots where id = ?').get(principal.botId)?.name;
|
|
96
|
+
return { kind: 'boton', id: principal.botId, label: String(name ?? principal.botId) };
|
|
97
|
+
}
|
|
98
|
+
if (principal?.kind === 'device' && principal.deviceId) {
|
|
99
|
+
const label = this.#db.prepare('select label from auth_tokens where id = ?').get(principal.deviceId)?.label;
|
|
100
|
+
return { kind: 'device', id: principal.deviceId, label: String(label ?? principal.deviceId) };
|
|
101
|
+
}
|
|
102
|
+
// Only with BOTONS_AUTH=off and no token.
|
|
103
|
+
return { kind: 'device', id: null, label: 'Unpaired (auth off)' };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
record(by: Principal | null | AuditActor, entry: AuditEntry): void {
|
|
107
|
+
const actor = by && 'label' in by ? by : this.actorOf(by);
|
|
108
|
+
recordAudit(this.#db, actor, entry);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** For an action that must not happen unrecorded: throws if the row can't be written, so the caller's transaction rolls back. */
|
|
112
|
+
require(by: Principal | null | AuditActor, entry: AuditEntry): void {
|
|
113
|
+
const actor = by && 'label' in by ? by : this.actorOf(by);
|
|
114
|
+
recordAudit(this.#db, actor, entry, { required: true });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Newest first: the connector's (or skill's) own events and projections of grant events naming it, read only from
|
|
119
|
+
* that kind's own fields.
|
|
120
|
+
* The global audit keeps the complete event; this view never includes unrelated grants or rules.
|
|
121
|
+
*/
|
|
122
|
+
about(targets: { kind: string; id: string }[], mention: string, limit = 50, of: 'connector' | 'skill' = 'connector'): AuditEvent[] {
|
|
123
|
+
const keys = of === 'skill' ? ['skills', 'skillsAdded', 'skillsRemoved'] : ['connections', 'connectionsAdded', 'connectionsRemoved'];
|
|
124
|
+
const quoted = JSON.stringify(mention).replace(/[\\%_]/g, (ch) => `\\${ch}`);
|
|
125
|
+
const where = [...targets.map(() => '(target_kind = ? and target_id = ?)'), "details like ? escape '\\'"].join(' or ');
|
|
126
|
+
const rows = this.#db
|
|
127
|
+
.prepare(`select * from audit_events where ${where} order by seq desc limit 1000`)
|
|
128
|
+
.all(...targets.flatMap((t) => [t.kind, t.id]), `%${quoted}%`)
|
|
129
|
+
.map(toEvent);
|
|
130
|
+
return rows.flatMap((e): AuditEvent[] => {
|
|
131
|
+
if (e.target && targets.some((t) => t.kind === e.target!.kind && t.id === e.target!.id)) return [e];
|
|
132
|
+
const details: AuditEvent['details'] = {};
|
|
133
|
+
for (const key of keys) {
|
|
134
|
+
const value = e.details[key];
|
|
135
|
+
if (Array.isArray(value) && value.includes(mention)) details[key] = [mention];
|
|
136
|
+
}
|
|
137
|
+
if (of === 'connector' && e.details.connector === mention) {
|
|
138
|
+
details.connector = mention;
|
|
139
|
+
for (const key of ['connectorName', 'boton', 'kind', 'status', 'always', 'grant', 'decision']) {
|
|
140
|
+
if (Object.hasOwn(e.details, key)) details[key] = e.details[key];
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (!Object.keys(details).length) return [];
|
|
144
|
+
if (e.details.via === of) details.via = of;
|
|
145
|
+
if (of === 'skill' && e.details.everySkill === true) details.everySkill = true;
|
|
146
|
+
return [{ ...e, details }];
|
|
147
|
+
}).slice(0, limit);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Newest first. */
|
|
151
|
+
list(opts: { before?: number; limit?: number } = {}): AuditEvent[] {
|
|
152
|
+
const limit = Math.min(Math.max(Math.trunc(opts.limit ?? 100) || 100, 1), 500);
|
|
153
|
+
const rows = opts.before
|
|
154
|
+
? this.#db.prepare('select * from audit_events where seq < ? order by seq desc limit ?').all(opts.before, limit)
|
|
155
|
+
: this.#db.prepare('select * from audit_events order by seq desc limit ?').all(limit);
|
|
156
|
+
return rows.map(toEvent);
|
|
157
|
+
}
|
|
158
|
+
}
|