polygram 0.38.1 → 0.38.3
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/.claude-plugin/plugin.json +1 -1
- package/lib/approvals/store.js +45 -7
- package/lib/codex/runtime-controller.js +107 -1
- package/lib/db/event-detail-schema.js +523 -0
- package/lib/db/pairings.js +9 -1
- package/lib/db/secret-sweep.js +6 -3
- package/lib/db/sessions.js +2 -2
- package/lib/db.js +353 -49
- package/lib/handlers/abort.js +20 -3
- package/lib/handlers/approvals.js +2 -2
- package/lib/handlers/config-callback.js +7 -7
- package/lib/handlers/dispatcher.js +24 -9
- package/lib/handlers/questions.js +546 -45
- package/lib/handlers/slash-commands.js +54 -13
- package/lib/handlers/voice.js +1 -1
- package/lib/ipc/handlers.js +4 -0
- package/lib/ops/clean-restart-qualification.js +1 -0
- package/lib/ops/clean-shutdown.js +5 -0
- package/lib/ops/deploy-restart-handler.js +111 -0
- package/lib/ops/foreground-canary-target.js +454 -0
- package/lib/questions/questions.js +51 -0
- package/lib/questions/store.js +61 -5
- package/lib/rewind/execute.js +12 -2
- package/lib/rewind/rewind.js +1 -1
- package/lib/sdk/build-options.js +2 -2
- package/lib/sdk/callbacks.js +14 -9
- package/lib/secret-detect.js +280 -12
- package/lib/telegram/api.js +10 -2
- package/lib/telegram/rich-edit.js +9 -5
- package/lib/telegram/rich-send.js +6 -6
- package/migrations/020-drop-secret-fingerprint.sql +13 -0
- package/package.json +2 -2
- package/polygram.js +117 -68
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed content-free schema for `events.detail_json`.
|
|
3
|
+
*
|
|
4
|
+
* Telemetry is a durable text sink written from hundreds of call sites, in
|
|
5
|
+
* this repository and in Orchestra, which calls `db.logEvent` directly. A
|
|
6
|
+
* field-name allowlist is not enough on its own: names like `error` or
|
|
7
|
+
* `reason` are permanent invitations for a message body to be logged under an
|
|
8
|
+
* approved name. So every field declares a TYPE, and a value that does not
|
|
9
|
+
* satisfy it is dropped.
|
|
10
|
+
*
|
|
11
|
+
* The permitted types are all content-free by construction:
|
|
12
|
+
*
|
|
13
|
+
* id opaque identifier (session key, turn id, chat id, digest-like)
|
|
14
|
+
* token short enum / code / classification, no spaces
|
|
15
|
+
* int count, length, byte size, duration, epoch
|
|
16
|
+
* number cost, ratio
|
|
17
|
+
* bool flag
|
|
18
|
+
* digest hex fingerprint of a non-secret (a request id, a config)
|
|
19
|
+
* ids bounded array of ids or tokens
|
|
20
|
+
* shape nested object whose own fields are typed the same way
|
|
21
|
+
*
|
|
22
|
+
* There is no free-text type. A caller with something to say says it as a
|
|
23
|
+
* code (`error_code`), a classification (`error_class`), a size
|
|
24
|
+
* (`stderr_len`) or a flag (`quarantined`) — never as prose. Fields that used
|
|
25
|
+
* to carry prose (`error`, `message`, `path`, `old_value`, `value`, `topics`,
|
|
26
|
+
* `stderr_tail`, `pane_tail`, `excerpt_head`, `text`, `original`, `note`,
|
|
27
|
+
* `name`) are absent from the schema, so they are dropped wherever they are
|
|
28
|
+
* logged, including from Orchestra.
|
|
29
|
+
*
|
|
30
|
+
* Dropped field NAMES are reported so a loss is visible; values never are.
|
|
31
|
+
* A field carrying `undefined` is simply absent, not a loss, and is not
|
|
32
|
+
* reported.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
'use strict';
|
|
36
|
+
|
|
37
|
+
// Fields deliberately absent. Kept as a list so the intent survives review:
|
|
38
|
+
// each one carried a message body, a reply, a TUI tail or a filesystem path.
|
|
39
|
+
const REMOVED_FIELDS = Object.freeze([
|
|
40
|
+
'text', 'text_preview', 'original', 'pane_tail', 'paneTail', 'excerpt_head',
|
|
41
|
+
'note', 'name', 'error', 'message', 'path', 'old_value', 'value', 'topics',
|
|
42
|
+
'stderr_tail', 'prompt', 'caption', 'transcript', 'body', 'content',
|
|
43
|
+
'snippet', 'preview', 'answer', 'question', 'raw_truncated', 'parse_error',
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
// `trigger` is KEPT, typed as a token: producers use it for a cause label
|
|
47
|
+
// ('boot', 'auto'), and the type rejects the user's own phrasing that once
|
|
48
|
+
// rode in on this name. `name` is not kept — a filename is often a single
|
|
49
|
+
// token, so typing alone would not stop it.
|
|
50
|
+
|
|
51
|
+
const ID = /^[A-Za-z0-9_.:@\/-]{1,128}$/;
|
|
52
|
+
const TOKEN = /^[A-Za-z0-9/][A-Za-z0-9_./:@-]{0,63}$/;
|
|
53
|
+
const DIGEST = /^[a-f0-9]{8,64}$/;
|
|
54
|
+
|
|
55
|
+
const isInt = (v) => Number.isSafeInteger(v);
|
|
56
|
+
const isNumber = (v) => typeof v === 'number' && Number.isFinite(v);
|
|
57
|
+
|
|
58
|
+
const TYPES = {
|
|
59
|
+
id: (v) => (typeof v === 'string' && ID.test(v)) || isInt(v),
|
|
60
|
+
token: (v) => typeof v === 'string' && TOKEN.test(v),
|
|
61
|
+
int: isInt,
|
|
62
|
+
number: isNumber,
|
|
63
|
+
bool: (v) => typeof v === 'boolean',
|
|
64
|
+
digest: (v) => typeof v === 'string' && DIGEST.test(v),
|
|
65
|
+
// A code is a short symbol OR a numeric exit/status code — both bounded,
|
|
66
|
+
// neither able to carry a sentence.
|
|
67
|
+
code: (v) => (typeof v === 'string' && TOKEN.test(v)) || isInt(v),
|
|
68
|
+
counts: (v) => {
|
|
69
|
+
if (v === null || typeof v !== 'object' || Array.isArray(v)) return false;
|
|
70
|
+
const proto = Object.getPrototypeOf(v);
|
|
71
|
+
if (proto !== Object.prototype && proto !== null) return false;
|
|
72
|
+
const entries = Object.entries(v);
|
|
73
|
+
return entries.length <= 32
|
|
74
|
+
&& entries.every(([k, n]) => TOKEN.test(k) && Number.isSafeInteger(n));
|
|
75
|
+
},
|
|
76
|
+
ids: (v) => Array.isArray(v) && v.length <= 64
|
|
77
|
+
&& v.every((item) => (typeof item === 'string' && ID.test(item)) || isInt(item)),
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// Nested shapes. Only these keys may appear inside the named field, with the
|
|
81
|
+
// same typing rules, so a transcript tail cannot ride one level down.
|
|
82
|
+
const SHAPES = {
|
|
83
|
+
busy_probe: {
|
|
84
|
+
busy: 'bool', streaming: 'bool', in_flight: 'bool',
|
|
85
|
+
pending_turns: 'int', captured: 'bool',
|
|
86
|
+
},
|
|
87
|
+
before: { model: 'token', effort: 'token', agent: 'token', pm: 'token' },
|
|
88
|
+
after: { model: 'token', effort: 'token', agent: 'token', pm: 'token' },
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* field name → type. Grouped by where the field comes from so the list stays
|
|
93
|
+
* reviewable: Orchestra's process/guard telemetry lands here under its own
|
|
94
|
+
* names, unchanged.
|
|
95
|
+
*/
|
|
96
|
+
const FIELDS = {
|
|
97
|
+
// ── identity / routing ──────────────────────────────────────────────
|
|
98
|
+
bot: 'token',
|
|
99
|
+
bot_name: 'token',
|
|
100
|
+
chat_id: 'id',
|
|
101
|
+
thread_id: 'id',
|
|
102
|
+
session_key: 'id',
|
|
103
|
+
session_id: 'id',
|
|
104
|
+
claude_session_id: 'id',
|
|
105
|
+
provider_session_id: 'id',
|
|
106
|
+
msg_id: 'id',
|
|
107
|
+
message_id: 'id',
|
|
108
|
+
source_msg_id: 'id',
|
|
109
|
+
source_message_id: 'id',
|
|
110
|
+
target_msg_id: 'id',
|
|
111
|
+
anchor_msg_id: 'id',
|
|
112
|
+
update_id: 'id',
|
|
113
|
+
turn_id: 'id',
|
|
114
|
+
tool_call_id: 'id',
|
|
115
|
+
tool_use_id: 'id',
|
|
116
|
+
attempt_id: 'id',
|
|
117
|
+
target_attempt_id: 'id',
|
|
118
|
+
retry_attempt_id: 'id',
|
|
119
|
+
generation_id: 'id',
|
|
120
|
+
request_id: 'id',
|
|
121
|
+
restart_request_id: 'id',
|
|
122
|
+
restartRequestId: 'id',
|
|
123
|
+
invocationId: 'id',
|
|
124
|
+
reservation_id: 'id',
|
|
125
|
+
linked_input_id: 'id',
|
|
126
|
+
item_id: 'id',
|
|
127
|
+
agent_id: 'id',
|
|
128
|
+
daemon_instance_id: 'id',
|
|
129
|
+
invocation_id: 'id',
|
|
130
|
+
pid: 'int',
|
|
131
|
+
tmux_name: 'id',
|
|
132
|
+
tmux_session: 'id',
|
|
133
|
+
user_id: 'id',
|
|
134
|
+
// The Telegram display name is user-controlled free text, so it is not a
|
|
135
|
+
// telemetry field: `user_id` identifies the actor without it.
|
|
136
|
+
preview_turn_id: 'id',
|
|
137
|
+
reply_turn_id: 'id',
|
|
138
|
+
resumed_session_id: 'id',
|
|
139
|
+
threadId: 'id',
|
|
140
|
+
from_user: 'id',
|
|
141
|
+
from_chat: 'id',
|
|
142
|
+
by: 'id',
|
|
143
|
+
owner: 'id',
|
|
144
|
+
id: 'id',
|
|
145
|
+
new_id: 'id',
|
|
146
|
+
old_chat_id: 'id',
|
|
147
|
+
new_chat_id: 'id',
|
|
148
|
+
original_thread_id: 'id',
|
|
149
|
+
requested_thread_id: 'id',
|
|
150
|
+
echoed_thread_id: 'id',
|
|
151
|
+
expected: 'id',
|
|
152
|
+
topic_ids: 'ids',
|
|
153
|
+
pending_turn_ids: 'ids',
|
|
154
|
+
fields: 'ids',
|
|
155
|
+
reasons: 'ids',
|
|
156
|
+
signals: 'ids',
|
|
157
|
+
|
|
158
|
+
// ── classification / state ──────────────────────────────────────────
|
|
159
|
+
kind: 'token',
|
|
160
|
+
action: 'token',
|
|
161
|
+
status: 'token',
|
|
162
|
+
state: 'token',
|
|
163
|
+
from_state: 'token',
|
|
164
|
+
to_state: 'token',
|
|
165
|
+
decision: 'token',
|
|
166
|
+
behavior: 'token',
|
|
167
|
+
outcome: 'token',
|
|
168
|
+
outcome_code: 'code',
|
|
169
|
+
disposition: 'token',
|
|
170
|
+
phase: 'token',
|
|
171
|
+
stage: 'token',
|
|
172
|
+
branch: 'token',
|
|
173
|
+
reason: 'token',
|
|
174
|
+
backend: 'token',
|
|
175
|
+
provider: 'token',
|
|
176
|
+
model: 'token',
|
|
177
|
+
from_model: 'token',
|
|
178
|
+
effort: 'token',
|
|
179
|
+
from_effort: 'token',
|
|
180
|
+
agent: 'token',
|
|
181
|
+
agent_type: 'token',
|
|
182
|
+
pipeline: 'token',
|
|
183
|
+
source: 'token',
|
|
184
|
+
session_source: 'token',
|
|
185
|
+
transport: 'token',
|
|
186
|
+
method: 'token',
|
|
187
|
+
match_type: 'token',
|
|
188
|
+
scope: 'token',
|
|
189
|
+
field: 'token',
|
|
190
|
+
command: 'token',
|
|
191
|
+
command_kind: 'token',
|
|
192
|
+
cancel_mode: 'token',
|
|
193
|
+
kill_reason: 'token',
|
|
194
|
+
shutdown_reason: 'token',
|
|
195
|
+
restart_trigger: 'token',
|
|
196
|
+
trigger: 'token',
|
|
197
|
+
hook_event_name: 'token',
|
|
198
|
+
// SDK hook telemetry: the hook's own classification, whether a Stop hook
|
|
199
|
+
// was already active, and when the daemon received it. All bounded; the
|
|
200
|
+
// hook's payload body is not a field here.
|
|
201
|
+
hook_type: 'token',
|
|
202
|
+
stop_hook_active: 'bool',
|
|
203
|
+
received_at_ms: 'int',
|
|
204
|
+
parse_error_code: 'code',
|
|
205
|
+
result_subtype: 'token',
|
|
206
|
+
subtype: 'token',
|
|
207
|
+
type: 'token',
|
|
208
|
+
tier: 'token',
|
|
209
|
+
priority: 'token',
|
|
210
|
+
language: 'token',
|
|
211
|
+
api_root: 'token',
|
|
212
|
+
tool: 'token',
|
|
213
|
+
tool_name: 'token',
|
|
214
|
+
via: 'token',
|
|
215
|
+
migration: 'token',
|
|
216
|
+
process_state: 'token',
|
|
217
|
+
package_version: 'token',
|
|
218
|
+
from_emoji: 'token',
|
|
219
|
+
to_emoji: 'token',
|
|
220
|
+
error_code: 'code',
|
|
221
|
+
error_class: 'code',
|
|
222
|
+
error_name: 'code',
|
|
223
|
+
errCode: 'code',
|
|
224
|
+
api_error_code: 'code',
|
|
225
|
+
queue_drain_error_code: 'code',
|
|
226
|
+
stop_error_code: 'code',
|
|
227
|
+
feedback_clear_error_code: 'code',
|
|
228
|
+
cause_code: 'code',
|
|
229
|
+
code: 'code',
|
|
230
|
+
prev: 'token',
|
|
231
|
+
next: 'token',
|
|
232
|
+
value_type: 'token',
|
|
233
|
+
when: 'token',
|
|
234
|
+
|
|
235
|
+
// ── digests (of non-secret material) ────────────────────────────────
|
|
236
|
+
restart_request_sha256: 'digest',
|
|
237
|
+
generation_digest: 'digest',
|
|
238
|
+
attempt_id_hash: 'digest',
|
|
239
|
+
from_hint_hash: 'digest',
|
|
240
|
+
to_hint_hash: 'digest',
|
|
241
|
+
|
|
242
|
+
// ── sizes, counts, timings ──────────────────────────────────────────
|
|
243
|
+
text_len: 'int',
|
|
244
|
+
original_len: 'int',
|
|
245
|
+
final_len: 'int',
|
|
246
|
+
reply_len: 'int',
|
|
247
|
+
rescued_len: 'int',
|
|
248
|
+
ack_len: 'int',
|
|
249
|
+
prev_len: 'int',
|
|
250
|
+
new_len: 'int',
|
|
251
|
+
old_len: 'int',
|
|
252
|
+
len: 'int',
|
|
253
|
+
length: 'int',
|
|
254
|
+
error_len: 'int',
|
|
255
|
+
stderr_len: 'int',
|
|
256
|
+
parse_error_len: 'int',
|
|
257
|
+
raw_len: 'int',
|
|
258
|
+
chars: 'int',
|
|
259
|
+
char_count: 'int',
|
|
260
|
+
bytes: 'int',
|
|
261
|
+
byte_size: 'int',
|
|
262
|
+
size: 'int',
|
|
263
|
+
transcript_bytes: 'int',
|
|
264
|
+
post_tokens: 'int',
|
|
265
|
+
pre_tokens: 'int',
|
|
266
|
+
count: 'int',
|
|
267
|
+
total: 'int',
|
|
268
|
+
cap: 'int',
|
|
269
|
+
default: 'int',
|
|
270
|
+
diagnostic: 'int',
|
|
271
|
+
limit: 'int',
|
|
272
|
+
threshold: 'int',
|
|
273
|
+
before: 'shape',
|
|
274
|
+
after: 'shape',
|
|
275
|
+
row_count: 'int',
|
|
276
|
+
chunks: 'int',
|
|
277
|
+
replies: 'int',
|
|
278
|
+
reply_count: 'int',
|
|
279
|
+
receipt_count: 'int',
|
|
280
|
+
interim_count: 'int',
|
|
281
|
+
block_count: 'int',
|
|
282
|
+
media_count: 'int',
|
|
283
|
+
photo_count: 'int',
|
|
284
|
+
video_count: 'int',
|
|
285
|
+
animation_count: 'int',
|
|
286
|
+
shell_count: 'int',
|
|
287
|
+
open_count: 'int',
|
|
288
|
+
pending_count: 'int',
|
|
289
|
+
pending_turns: 'int',
|
|
290
|
+
queue_len: 'int',
|
|
291
|
+
queue_cap: 'int',
|
|
292
|
+
accepted_count: 'int',
|
|
293
|
+
rejected_count: 'int',
|
|
294
|
+
dropped_count: 'int',
|
|
295
|
+
dropped_turns: 'int',
|
|
296
|
+
deleted_attempts: 'int',
|
|
297
|
+
deleted_generations: 'int',
|
|
298
|
+
drained_pendings: 'int',
|
|
299
|
+
resume_intents_recorded: 'int',
|
|
300
|
+
topics_cleared: 'int',
|
|
301
|
+
imported: 'int',
|
|
302
|
+
files: 'int', // the inbox sweep's file COUNT, not a list of names
|
|
303
|
+
q_index: 'int',
|
|
304
|
+
calls: 'int',
|
|
305
|
+
attempt: 'int',
|
|
306
|
+
active_turn_count: 'int',
|
|
307
|
+
pending_delivery_count: 'int',
|
|
308
|
+
background_owner_count: 'int',
|
|
309
|
+
background_terminal_count: 'int',
|
|
310
|
+
num_assistant_messages: 'int',
|
|
311
|
+
num_tool_uses: 'int',
|
|
312
|
+
policy_version: 'int',
|
|
313
|
+
retention_days: 'int',
|
|
314
|
+
days_left: 'int',
|
|
315
|
+
oom_kill_delta: 'int',
|
|
316
|
+
observed_at_ms: 'int',
|
|
317
|
+
expected_activity_epoch: 'int',
|
|
318
|
+
observed_activity_epoch: 'int',
|
|
319
|
+
original_ts: 'int',
|
|
320
|
+
ts: 'int',
|
|
321
|
+
duration_ms: 'int',
|
|
322
|
+
duration_sec: 'number',
|
|
323
|
+
elapsed_ms: 'int',
|
|
324
|
+
elapsed: 'number',
|
|
325
|
+
idle_ms: 'int',
|
|
326
|
+
lag_ms: 'int',
|
|
327
|
+
stall_ms: 'int',
|
|
328
|
+
wait_ms: 'int',
|
|
329
|
+
window_ms: 'int',
|
|
330
|
+
timeout_ms: 'int',
|
|
331
|
+
turn_timeout_ms: 'int',
|
|
332
|
+
hard_backstop_ms: 'int',
|
|
333
|
+
retry_after_ms: 'int',
|
|
334
|
+
last_hook_age_ms: 'int',
|
|
335
|
+
refresh_token_expires_at: 'int',
|
|
336
|
+
after_failures: 'int',
|
|
337
|
+
failures: 'int',
|
|
338
|
+
cost_usd: 'number',
|
|
339
|
+
cost: 'number',
|
|
340
|
+
total_cost: 'number',
|
|
341
|
+
budget: 'number',
|
|
342
|
+
pct: 'number',
|
|
343
|
+
|
|
344
|
+
// ── flags ───────────────────────────────────────────────────────────
|
|
345
|
+
ok: 'bool',
|
|
346
|
+
applied: 'bool',
|
|
347
|
+
aborted: 'bool',
|
|
348
|
+
ambiguous: 'bool',
|
|
349
|
+
active: 'int', // a live-process COUNT, not a flag
|
|
350
|
+
attempted: 'bool',
|
|
351
|
+
attributed: 'bool',
|
|
352
|
+
already_delivered: 'bool',
|
|
353
|
+
busy: 'bool',
|
|
354
|
+
busy_probe: 'shape',
|
|
355
|
+
captured: 'bool',
|
|
356
|
+
clean: 'bool',
|
|
357
|
+
closed: 'bool',
|
|
358
|
+
consumed_acked: 'bool',
|
|
359
|
+
continuation_authorized: 'bool',
|
|
360
|
+
deferred: 'bool',
|
|
361
|
+
deferred_for_subagent: 'bool',
|
|
362
|
+
deletion_failed: 'bool',
|
|
363
|
+
delivered: 'bool',
|
|
364
|
+
eligible: 'bool',
|
|
365
|
+
exact_match: 'bool',
|
|
366
|
+
failed: 'bool',
|
|
367
|
+
first_event: 'bool',
|
|
368
|
+
had_active: 'bool',
|
|
369
|
+
had_stop: 'bool',
|
|
370
|
+
has_stop_data: 'bool',
|
|
371
|
+
in_flight: 'bool',
|
|
372
|
+
in_flight_at_signal: 'bool',
|
|
373
|
+
interim_only: 'bool',
|
|
374
|
+
is_error: 'bool',
|
|
375
|
+
killed_background_shell: 'bool',
|
|
376
|
+
media_delivered: 'bool',
|
|
377
|
+
media_failed: 'bool',
|
|
378
|
+
noticed: 'bool',
|
|
379
|
+
notice_failed: 'bool',
|
|
380
|
+
oom_shutdown: 'bool',
|
|
381
|
+
pinned: 'bool',
|
|
382
|
+
quarantined: 'bool',
|
|
383
|
+
queue_drained: 'bool',
|
|
384
|
+
recovered: 'bool',
|
|
385
|
+
replay: 'bool',
|
|
386
|
+
replay_marked: 'bool',
|
|
387
|
+
requested: 'bool',
|
|
388
|
+
rescued: 'bool',
|
|
389
|
+
resumed: 'bool',
|
|
390
|
+
sent: 'bool',
|
|
391
|
+
should_query: 'bool',
|
|
392
|
+
skipped: 'bool',
|
|
393
|
+
stop_verified: 'bool',
|
|
394
|
+
streaming: 'bool',
|
|
395
|
+
stopped: 'bool',
|
|
396
|
+
text_failed: 'bool',
|
|
397
|
+
via_text_fallback: 'bool',
|
|
398
|
+
background_terminal_registry_complete: 'bool',
|
|
399
|
+
|
|
400
|
+
// ── background sweep + lifecycle diagnostics ────────────────────────
|
|
401
|
+
// The secret sweep's own summary: counts of what it scanned, changed and
|
|
402
|
+
// flagged, plus per-rule counts. Nothing here is derived from a secret's
|
|
403
|
+
// value.
|
|
404
|
+
scanned: 'int',
|
|
405
|
+
redactedMsgs: 'int',
|
|
406
|
+
redactions: 'int',
|
|
407
|
+
flagged: 'int',
|
|
408
|
+
ruleCounts: 'counts',
|
|
409
|
+
dryRun: 'bool',
|
|
410
|
+
reachedCap: 'bool',
|
|
411
|
+
remaining: 'int',
|
|
412
|
+
deleted: 'bool',
|
|
413
|
+
dropped: 'bool',
|
|
414
|
+
overflow: 'bool',
|
|
415
|
+
|
|
416
|
+
// ── Orchestra's camelCase counters and identifiers ──────────────────
|
|
417
|
+
// Orchestra logs directly into this sink and names some fields in camel
|
|
418
|
+
// case. They are the same content-free counters and ids as their snake_case
|
|
419
|
+
// neighbours; listing them keeps its telemetry intact without asking an
|
|
420
|
+
// external package to rename anything.
|
|
421
|
+
sessionKey: 'id',
|
|
422
|
+
turnId: 'id',
|
|
423
|
+
totalCost: 'number',
|
|
424
|
+
newCost: 'number',
|
|
425
|
+
turnTimeoutMs: 'int',
|
|
426
|
+
queueCap: 'int',
|
|
427
|
+
drainedPendings: 'int',
|
|
428
|
+
pinnedSkipped: 'int',
|
|
429
|
+
callback: 'token',
|
|
430
|
+
background_shell: 'bool',
|
|
431
|
+
fired: 'bool',
|
|
432
|
+
|
|
433
|
+
// ── audit of this boundary itself ───────────────────────────────────
|
|
434
|
+
dropped_fields: 'ids',
|
|
435
|
+
dropped_field_count: 'int',
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
const REMOVED_NAMES = new Set(REMOVED_FIELDS);
|
|
439
|
+
const SHAPE_FIELD_NAMES = new Set(
|
|
440
|
+
Object.values(SHAPES).flatMap((shape) => Object.keys(shape)),
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
// Deeper than this is malformed rather than legitimate telemetry.
|
|
444
|
+
const MAX_DEPTH = 8;
|
|
445
|
+
|
|
446
|
+
const isPlainObject = (v) => {
|
|
447
|
+
if (v === null || typeof v !== 'object') return false;
|
|
448
|
+
const proto = Object.getPrototypeOf(v);
|
|
449
|
+
return proto === Object.prototype || proto === null;
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
function accepts(type, value, key) {
|
|
453
|
+
if (type === 'shape') {
|
|
454
|
+
if (!isPlainObject(value)) return false;
|
|
455
|
+
return true; // fields are checked individually by the caller
|
|
456
|
+
}
|
|
457
|
+
const check = TYPES[type];
|
|
458
|
+
return typeof check === 'function' ? check(value, key) : false;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Filter a telemetry payload down to the typed schema.
|
|
463
|
+
*
|
|
464
|
+
* A rejected field's NAME is only echoed back when it belongs to this
|
|
465
|
+
* schema's own closed vocabulary — a declared field whose value failed its
|
|
466
|
+
* type, or one of the deliberately removed names. Anything else is counted
|
|
467
|
+
* and nothing more: a key can be caller-controlled (a payload spread from a
|
|
468
|
+
* map keyed by user data puts arbitrary text in the key position), so an
|
|
469
|
+
* unknown name is content, not a diagnostic.
|
|
470
|
+
*
|
|
471
|
+
* @param {object} detail
|
|
472
|
+
* @returns {{ detail:object, dropped:string[], droppedCount:number }} Absent
|
|
473
|
+
* (`undefined`) fields are not losses and are not counted.
|
|
474
|
+
*/
|
|
475
|
+
function enforceEventDetailSchema(detail) {
|
|
476
|
+
const dropped = new Set();
|
|
477
|
+
let droppedCount = 0;
|
|
478
|
+
|
|
479
|
+
const knownName = (key) => (
|
|
480
|
+
Object.prototype.hasOwnProperty.call(FIELDS, key)
|
|
481
|
+
|| REMOVED_NAMES.has(key)
|
|
482
|
+
|| SHAPE_FIELD_NAMES.has(key)
|
|
483
|
+
);
|
|
484
|
+
const reject = (key) => {
|
|
485
|
+
droppedCount += 1;
|
|
486
|
+
if (knownName(key)) dropped.add(key);
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
const filter = (obj, fieldMap, depth) => {
|
|
490
|
+
const out = {};
|
|
491
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
492
|
+
if (value === undefined) continue; // absent, not dropped
|
|
493
|
+
const type = fieldMap[key];
|
|
494
|
+
if (!type) { reject(key); continue; }
|
|
495
|
+
if (type === 'shape') {
|
|
496
|
+
const shape = SHAPES[key];
|
|
497
|
+
if (!shape || depth >= MAX_DEPTH || !accepts(type, value, key)) {
|
|
498
|
+
reject(key);
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
out[key] = filter(value, shape, depth + 1);
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
if (value === null) { out[key] = null; continue; }
|
|
505
|
+
if (!accepts(type, value, key)) { reject(key); continue; }
|
|
506
|
+
out[key] = value;
|
|
507
|
+
}
|
|
508
|
+
return out;
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
return {
|
|
512
|
+
detail: isPlainObject(detail) ? filter(detail, FIELDS, 0) : {},
|
|
513
|
+
dropped: [...dropped].sort(),
|
|
514
|
+
droppedCount,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
module.exports = {
|
|
519
|
+
enforceEventDetailSchema,
|
|
520
|
+
EVENT_DETAIL_FIELDS: FIELDS,
|
|
521
|
+
EVENT_DETAIL_SHAPES: SHAPES,
|
|
522
|
+
REMOVED_FIELDS,
|
|
523
|
+
};
|
package/lib/db/pairings.js
CHANGED
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
const crypto = require('crypto');
|
|
13
|
+
const { sanitizeForDurableWrite } = require('../secret-detect');
|
|
14
|
+
|
|
15
|
+
const durableText = (value) => (
|
|
16
|
+
typeof value === 'string' ? sanitizeForDurableWrite(value).text : value
|
|
17
|
+
);
|
|
13
18
|
|
|
14
19
|
// Crockford-ish base32: no 0/O/1/I/L to avoid hand-entry confusion.
|
|
15
20
|
const ALPHA = '23456789ABCDEFGHJKMNPQRSTVWXYZ';
|
|
@@ -165,7 +170,10 @@ function createStore(rawDb, now = () => Date.now()) {
|
|
|
165
170
|
issued_by_user_id,
|
|
166
171
|
issued_ts,
|
|
167
172
|
expires_ts,
|
|
168
|
-
note
|
|
173
|
+
// The note is operator-typed free text; a declared credential in it
|
|
174
|
+
// must not reach the row, and the pairing copy below inherits the
|
|
175
|
+
// masked value.
|
|
176
|
+
note: durableText(note),
|
|
169
177
|
});
|
|
170
178
|
return { code, issued_ts, expires_ts, bot_name, chat_id, scope, note };
|
|
171
179
|
},
|
package/lib/db/secret-sweep.js
CHANGED
|
@@ -34,8 +34,11 @@ function sweepSecrets(rawDb, opts = {}) {
|
|
|
34
34
|
ORDER BY id LIMIT ?`);
|
|
35
35
|
const updText = rawDb.prepare('UPDATE messages SET text = ? WHERE id = ?');
|
|
36
36
|
const stamp = rawDb.prepare('UPDATE messages SET secret_scanned_at = ? WHERE id = ?');
|
|
37
|
+
// The audit records what was redacted and where. It deliberately stores no
|
|
38
|
+
// digest of the value: an unsalted hash of a guessable secret is a
|
|
39
|
+
// correlation handle for the secret itself.
|
|
37
40
|
const insAudit = rawDb.prepare(`INSERT INTO secret_redactions
|
|
38
|
-
(chat_id, msg_id, rule, tier, length,
|
|
41
|
+
(chat_id, msg_id, rule, tier, length, action, ts) VALUES (?,?,?,?,?,?,?)`);
|
|
39
42
|
|
|
40
43
|
let scanned = 0; let redactedMsgs = 0; let redactions = 0; let flagged = 0;
|
|
41
44
|
const ruleCounts = {};
|
|
@@ -49,8 +52,8 @@ function sweepSecrets(rawDb, opts = {}) {
|
|
|
49
52
|
const res = redactText(row.text, { redactTiers });
|
|
50
53
|
if (!dryRun) {
|
|
51
54
|
if (res.changed) updText.run(res.text, row.id); // FTS re-indexes via the UPDATE trigger
|
|
52
|
-
for (const r of res.redacted) insAudit.run(String(row.chat_id), row.msg_id, r.rule, r.tier, r.length,
|
|
53
|
-
for (const f of res.flagged) insAudit.run(String(row.chat_id), row.msg_id, f.rule, f.tier, f.length,
|
|
55
|
+
for (const r of res.redacted) insAudit.run(String(row.chat_id), row.msg_id, r.rule, r.tier, r.length, 'redacted', now);
|
|
56
|
+
for (const f of res.flagged) insAudit.run(String(row.chat_id), row.msg_id, f.rule, f.tier, f.length, 'flagged', now);
|
|
54
57
|
stamp.run(now, row.id);
|
|
55
58
|
}
|
|
56
59
|
if (res.changed) redactedMsgs += 1;
|
package/lib/db/sessions.js
CHANGED
|
@@ -63,7 +63,7 @@ function migrateJsonToDb(db, sessionsJsonPath, configChats = {}) {
|
|
|
63
63
|
const quarantine = `${sessionsJsonPath}.malformed-${stamp}`;
|
|
64
64
|
try { fs.renameSync(sessionsJsonPath, quarantine); } catch {}
|
|
65
65
|
if (db?.logEvent) {
|
|
66
|
-
try { db.logEvent('sessions-json-malformed', {
|
|
66
|
+
try { db.logEvent('sessions-json-malformed', { error_class: err.name || 'Error', quarantined: true }); } catch {}
|
|
67
67
|
}
|
|
68
68
|
return { imported: 0, renamed: true, reason: `malformed-json: ${err.message}` };
|
|
69
69
|
}
|
|
@@ -72,7 +72,7 @@ function migrateJsonToDb(db, sessionsJsonPath, configChats = {}) {
|
|
|
72
72
|
const quarantine = `${sessionsJsonPath}.malformed-${stamp}`;
|
|
73
73
|
try { fs.renameSync(sessionsJsonPath, quarantine); } catch {}
|
|
74
74
|
if (db?.logEvent) {
|
|
75
|
-
try { db.logEvent('sessions-json-malformed', {
|
|
75
|
+
try { db.logEvent('sessions-json-malformed', { error_code: 'not-an-object', quarantined: true }); } catch {}
|
|
76
76
|
}
|
|
77
77
|
return { imported: 0, renamed: true, reason: 'malformed-json: not an object' };
|
|
78
78
|
}
|