@diffin/cli 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/lib/scrub.mjs ADDED
@@ -0,0 +1,513 @@
1
+ // Deterministic secret/PII scrubber — the single choke point for anything that leaves
2
+ // the user's machine. Every rule here is something that must NEVER appear in a work
3
+ // log; each one was added after a real leak, so the RULES table is carried verbatim
4
+ // from the predecessor and changed only with the same care.
5
+ //
6
+ // Person names can't be caught by regex → alias map in ~/.diffin/redact.json:
7
+ // { "names": { "Tom": "협업자A", "김철수": "협업자B" } } (case-insensitive)
8
+ //
9
+ // Design: identifiers keep a short prefix/suffix so records stay verifiable
10
+ // ("0xfc25…c4e3") without being usable; credentials are replaced whole — a partial
11
+ // key is still a leak surface.
12
+ import { readFileSync } from 'node:fs';
13
+ import { homedir, userInfo } from 'node:os';
14
+ import { join } from 'node:path';
15
+
16
+ // Order matters where noted — a rule that would eat another rule's input runs first.
17
+ // Identifier parts are length-bounded (never nested quantifiers): an unbounded
18
+ // `[..]*(alt)[..]*` backtracks catastrophically on long single-char runs (measured
19
+ // 3.7s on 50k chars). Do not "simplify" a bound away.
20
+ /**
21
+ * A rule that holds only on a line where a command stands before it (`curl … -u user:pass`, `redis-cli … -a pass`).
22
+ * Written as `command[^\n]*?flag` it rescanned the rest of the line from every occurrence of the command — quadratic
23
+ * on a long line of them (measured 0.9 s and 1.6 s on 100–200 KB), and this module also reads what a person uploads to
24
+ * the SERVER. One pass instead: a newline forgets the command, the command is remembered, the flag is rewritten only
25
+ * while it is.
26
+ */
27
+ function onLineOf(command, flag, rewrite) {
28
+ const re = new RegExp(`\\n|${command.source}|${flag.source}`, 'g');
29
+ return (text) => {
30
+ let seen = false;
31
+ return text.replace(re, (m, ...g) => {
32
+ if (m === '\n') { seen = false; return m; }
33
+ if (g[0] === undefined) { seen = true; return m; }
34
+ return seen ? rewrite(m, g[0]) : m;
35
+ });
36
+ };
37
+ }
38
+
39
+ const RULES = [
40
+ // phone (KR mobile/landline) — PII (added after a prod-log leak 2026-07-21)
41
+ [/\b01[016789][-\s.]?\d{3,4}[-\s.]?\d{4}\b/g, '[전화번호]'],
42
+ [/\b0\d{1,2}[-\s.]\d{3,4}[-\s.]\d{4}\b/g, '[전화번호]'],
43
+ // credentials — replaced whole
44
+ [/sk-ant-[A-Za-z0-9_-]{16,}/g, '[API키]'],
45
+ [/sk-[A-Za-z0-9]{32,}/g, '[API키]'],
46
+ [/gh[pousr]_[A-Za-z0-9]{20,}/g, '[API키]'],
47
+ [/github_pat_[A-Za-z0-9_]{20,}/g, '[API키]'],
48
+ [/AKIA[0-9A-Z]{16}/g, '[API키]'],
49
+ [/GOCSPX-[A-Za-z0-9_-]{16,}/g, '[API키]'],
50
+ [/xox[abpr]-[A-Za-z0-9-]{10,}/g, '[API키]'],
51
+ [/eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, '[JWT]'],
52
+ // the body is base64 and line breaks and never holds a '-', so the scan stops at the first one: a run of BEGIN lines
53
+ // with no END cost a rescan of the rest of the text each (measured 1.6 s on 540 KB)
54
+ [/-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----[^-]{0,20000}-----END [A-Z ]{0,40}PRIVATE KEY-----/g, '[개인키]'],
55
+ // vendor credential formats (each unmistakably a secret)
56
+ [/\b[sr]k_(?:live|test)_[A-Za-z0-9]{16,}/g, '[API키]'], // Stripe
57
+ [/\bASIA[0-9A-Z]{16}\b/g, '[API키]'], // AWS temp
58
+ [/\bAIza[0-9A-Za-z_-]{30,}/g, '[API키]'], // Google
59
+ [/\bxapp-\d-[A-Za-z0-9-]{8,}/g, '[API키]'], // Slack app
60
+ [/\bnpm_[A-Za-z0-9]{30,}/g, '[API키]'], // npm
61
+ [/\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/g, '[API키]'], // SendGrid
62
+ [/\bATATT[A-Za-z0-9_-]{20,}/g, '[API키]'], // Atlassian
63
+ [/\bglpat-[A-Za-z0-9_-]{16,}/g, '[API키]'], // GitLab
64
+ [/\bdop_v1_[a-f0-9]{32,}/g, '[API키]'], // DigitalOcean
65
+ [/\bshpat_[a-fA-F0-9]{32}/g, '[API키]'], // Shopify
66
+ // OpenRouter — the provider this product's own pipeline runs on, and the one the
67
+ // vendor list was missing. It was only ever half-hidden, by the 64-hex rule below
68
+ // truncating the tail; a key one character off that length would have left whole.
69
+ [/\bsk-or-v1-[A-Za-z0-9]{32,}/g, '[API키]'],
70
+ // a credential that SAYS it is one and matches no vendor's shape: `apikey_<hex>_<hex>` (TypeSafe), `token_…`,
71
+ // `secret-…`. Found 2026-09-21: a test key pasted into a session passed every rule above and the key=value
72
+ // rules below (there is no `=`), and the next capture would have stored it in the ledger as a user turn.
73
+ // The tail must be long and hold a digit, so `token_usage_per_stage_breakdown` stays an identifier.
74
+ [/\b(?:api[_-]?key|secret|token)[_-](?=[A-Za-z0-9_-]{0,200}\d)[A-Za-z0-9_-]{24,200}/gi, '[API키]'],
75
+ // opaque bearer credential in an auth header
76
+ [/\b(Authorization\s*:\s*(?:Bearer|Basic)\s+)[A-Za-z0-9._~+/=-]{16,}/gi, '$1[비공개값]'],
77
+ // curl -u user:password
78
+ onLineOf(/\bcurl\b/, /(\s-u\s+[^\s:'"]{1,256}:)[^\s'"]{1,1024}/, (m, head) => `${head}[비공개값]`),
79
+ // key=value: keep the key, kill the value. Identifier may carry prefix/suffix
80
+ // (aws_secret_access_key, DB_PASSWORD) — the old anchored form missed those.
81
+ [/((?:api[_-]?key|secret|password|passwd|token|private[_-]?key|mnemonic|seed[_-]?phrase)\s*[=:]\s*["']?)[^\s"',;]{6,}/gi, '$1[비공개값]'],
82
+ [/([A-Za-z0-9_.-]{0,40}(?:secret|passwd|password|credential)[A-Za-z0-9_.-]{0,40}\s*[=:]\s*["']?)[^\s"',;]{6,}/gi, '$1[비공개값]'],
83
+ [/(\b[A-Z][A-Z0-9_]{0,60}_(?:KEY|TOKEN|SECRET)\s*[=:]\s*["']?)[^\s"',;]{6,}/g, '$1[비공개값]'],
84
+ // Telegram bot token (<9-10 digits>:<35-char>) — appears with no keyword. Keep bot id.
85
+ [/(\b\d{9,10}:)[A-Za-z0-9_-]{30,}/g, '$1[비공개값]'],
86
+ // Korean PII in real work logs (fixtures, customer data)
87
+ [/\b\d{6}[-\s]?[1-4]\d{6}\b/g, '[주민번호]'],
88
+ [/\b\d{3}-\d{2}-\d{5}\b/g, '[사업자번호]'],
89
+ // payment card: 4×4 on a known issuer prefix (conservative)
90
+ [/\b[3-6]\d{3}[-\s]\d{4}[-\s]\d{4}[-\s]\d{4}\b/g, '[카드번호]'],
91
+ // URL userinfo password — MUST run before the email rule ("pass@host.com" parses as
92
+ // an email). Password class excludes '/', so "http://localhost:3000/x" never reaches '@'.
93
+ [/([A-Za-z][A-Za-z0-9+.-]{0,32}:\/\/[^\s:@/]{0,128}:)[^\s@/]{1,256}(?=@)/g, '$1[비공개값]'],
94
+ // redis auth
95
+ [/\brequirepass[=\s]+["']?[^\s"';]+["']?/gi, 'requirepass [비공개값]'],
96
+ onLineOf(/\bredis-cli\b/, /(\s-a\s+)(?:'[^'\n]{0,512}'|"[^"\n]{0,512}"|[^\s'"]{1,512})/, (m, head) => `${head}[비공개값]`),
97
+ // 64-hex = private key OR tx hash (indistinguishable) → truncate both
98
+ [/\b(?:0x)?([0-9a-fA-F]{4})[0-9a-fA-F]{56}([0-9a-fA-F]{4})\b/g, '[HEX64:$1…$2]'],
99
+ // wallet address: keep prefix/suffix (verifiable, not usable)
100
+ [/\b0x([0-9a-fA-F]{4})[0-9a-fA-F]{32}([0-9a-fA-F]{4})\b/g, '0x$1…$2'],
101
+ // shell prompt "user@host ~ %" — user/host pair is PII. MUST run before the email
102
+ // rule (a hostname like "MacBook-Pro.local" parses as an email domain).
103
+ [/(?<![\w.-])[A-Za-z0-9._-]+@[A-Za-z0-9][A-Za-z0-9.-]*(?=\s+(?:~\S*|\/\S*|[\w.-]+)\s*[%$](?:\s|$))/g, '[user]@[host]'],
104
+ // email — bounded to RFC-ish lengths (unbounded form is quadratic: 1.6s on 60k chars)
105
+ [/[a-zA-Z0-9._%+-]{1,64}@[a-zA-Z0-9.-]{1,255}\.[a-zA-Z]{2,24}/g, '[이메일]'],
106
+ [/\b(?:192\.168|10\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01]))\.\d{1,3}\.\d{1,3}\b/g, '[내부IP]'],
107
+ // Windows: C:\Users\<name> — with either slash, and with the backslash doubled as it is inside a JSON string that was
108
+ // itself pasted into a turn. And the same folder as Git Bash and WSL spell it (/c/Users/<name>, /mnt/c/Users/<name>):
109
+ // the POSIX rule below refuses those, because its lookbehind sees the drive letter as a word character in front of
110
+ // "/Users". A username with a space in it cannot be delimited here; the machine's own name is matched exactly, before any of these.
111
+ [/(?<![A-Za-z0-9])[A-Za-z]:[\\/]{1,2}Users[\\/]{1,2}(?:\[[^\]\n]{1,64}\]|[A-Za-z0-9._-]+)/g, '~'],
112
+ [/(?<!\w)(?:\/mnt)?\/[A-Za-z]\/Users\/(?:\[[^\]\n]{1,64}\]|[A-Za-z0-9._-]+)/g, '~'],
113
+ // (the Windows rules stand first: "C:/Users/jane" would otherwise lose its tail to the rule below and keep the drive)
114
+ // local home paths: /Users/<name>/… → ~/… (username is PII, rest is evidence).
115
+ // The lookbehind keeps a URL like "example.com/Users/…" untouched — but only a
116
+ // domain char (word char) may precede; a '.' or '-' must not, or "../Users/name"
117
+ // (a relative import path) slips through with the username intact (measured leak).
118
+ [/(?<!\w)\/(?:Users|home)\/(?:\[[^\]\n]{1,64}\]|[A-Za-z0-9._-]+)/g, '~'],
119
+ // dash-encoded cwd ("/Users/x/Desktop/y" → "-Users-x-Desktop-y") in project-dir names
120
+ [/(?<!\w)-(Users|home)-(?:\[[^\]\n]{1,64}\]|[A-Za-z0-9._]+)/g, '-$1-[user]'],
121
+ ];
122
+
123
+ // The path-shaped rules above catch a username inside /Users/<name> and -Users-<name>-,
124
+ // but a transcript is full of it BARE: the owner column of every `ls -l`, `id`,
125
+ // `whoami`, a git log — measured 324 times across one 3-day corpus, all in tool
126
+ // output, none reachable by a path rule. The machine knows its own username and git
127
+ // identity, so they are aliased automatically, the same way redact.json names are.
128
+ // Guarded: a short or dictionary-like value ("user", "dev", "admin") would eat prose.
129
+ const STOP = new Set(['user', 'users', 'admin', 'root', 'test', 'dev', 'guest', 'node', 'ubuntu', 'local', 'home', 'staff', 'wheel', 'name', 'main']);
130
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
131
+ function selfRule(name, alias) {
132
+ if (!name || name.length < 4 || STOP.has(name.toLowerCase())) return null;
133
+ // word boundaries only make sense for ASCII-word names; a Korean name gets a plain match
134
+ const bounded = /^[\w.-]+$/.test(name);
135
+ return [new RegExp(bounded ? `\\b${escapeRe(name)}\\b` : escapeRe(name), 'gi'), `[${alias}]`];
136
+ }
137
+ function selfNames() {
138
+ const out = [];
139
+ try { out.push(selfRule(userInfo().username, 'user')); } catch { /* no account info */ }
140
+ try {
141
+ // [user] section of ~/.gitconfig only — not a `name =` from some other section
142
+ const cfg = readFileSync(join(homedir(), '.gitconfig'), 'utf8');
143
+ const sec = cfg.match(/^\s*\[user\]\s*$([\s\S]*?)(?=^\s*\[|(?![\s\S]))/m)?.[1] ?? '';
144
+ const m = sec.match(/^\s*name\s*=\s*(.+?)\s*$/m);
145
+ out.push(selfRule(m?.[1]?.replace(/^["']|["']$/g, ''), 'git-user'));
146
+ } catch { /* no gitconfig */ }
147
+ return out.filter(Boolean);
148
+ }
149
+
150
+ // The machine's own username in its two path shapes, run BEFORE the generic path
151
+ // rules. The generic dash-encoded rule cannot include '-' in the name (it is the
152
+ // separator), so a hyphenated username was cut: -Users-jane-doe-Desktop became
153
+ // -Users-[user]-doe-Desktop, and by the time the bare-word alias ran, "jane-doe" no
154
+ // longer existed to match. Knowing the exact name, these two need no guessing.
155
+ export function selfPathRulesFor(name) {
156
+ if (!name || name.length < 2 || /[\\/:\n]/.test(name)) return [];
157
+ const n = escapeRe(name);
158
+ // Windows spells the home folder three ways (C:\Users\<name>, and Git Bash's and WSL's /c/Users/<name>), and its
159
+ // account names may hold a space or Hangul — which no generic rule can delimit, so the exact name is the only rule
160
+ // that covers them. Claude Code's project-dir name turns every character that is not a letter or digit into '-'.
161
+ const dashed = escapeRe(name.replace(/[^A-Za-z0-9]/g, '-'));
162
+ const rules = [
163
+ [new RegExp(`(?<![A-Za-z0-9])[A-Za-z]:[\\\\/]{1,2}Users[\\\\/]{1,2}${n}(?![A-Za-z0-9._-])`, 'gi'), '~'],
164
+ [new RegExp(`(?<!\\w)(?:\\/mnt)?\\/[A-Za-z]\\/Users\\/${n}(?![A-Za-z0-9._-])`, 'gi'), '~'],
165
+ [new RegExp(`(?<!\\w)-(Users|home)-${dashed}(?![A-Za-z0-9._])`, 'g'), '-$1-[user]'],
166
+ ];
167
+ if (/^[A-Za-z0-9._-]+$/.test(name)) rules.push(
168
+ [new RegExp(`(?<!\\w)-(Users|home)-${n}(?![A-Za-z0-9._])`, 'g'), '-$1-[user]'],
169
+ [new RegExp(`(?<!\\w)\\/(?:Users|home)\\/${n}(?![A-Za-z0-9._-])`, 'g'), '~'],
170
+ );
171
+ return rules;
172
+ }
173
+
174
+ /**
175
+ * A Windows path in the shape every reader on the server expects. The server finds a turn's folder by prefix, a file's
176
+ * name by its last '/', and a conversation's container by the string itself; taught a second separator, each of those
177
+ * would be a place to forget it. So the three fields that ARE paths — a row's `cwd`, a tool call's `file_path` and
178
+ * `notebook_path` — go up with '/' (after scrubbing: C:\Users\jane\proj is ~/proj, D:\work\proj is D:/work/proj).
179
+ * Nothing else is touched: a backslash in what a person said, or in a command, is theirs.
180
+ */
181
+ export function posixPath(p) {
182
+ if (typeof p !== 'string' || !p.includes('\\')) return p;
183
+ const windows = /^[A-Za-z]:[\\/]/.test(p) || /^~\\/.test(p) || /^\\\\/.test(p) || !p.includes('/');
184
+ if (!windows) return p;
185
+ const unc = /^\\\\/.test(p);
186
+ return (unc ? '/' : '') + p.replace(/\\+/g, '/');
187
+ }
188
+ function posixRow(o) {
189
+ if (typeof o.cwd === 'string') o.cwd = posixPath(o.cwd);
190
+ const blocks = o.message?.content;
191
+ if (Array.isArray(blocks)) for (const b of blocks) {
192
+ if (b?.type !== 'tool_use' || !b.input || typeof b.input !== 'object') continue;
193
+ for (const k of ['file_path', 'notebook_path']) if (typeof b.input[k] === 'string') b.input[k] = posixPath(b.input[k]);
194
+ if (Array.isArray(b.input._wrote)) b.input._wrote = b.input._wrote.map(posixPath);
195
+ }
196
+ return o;
197
+ }
198
+
199
+ function selfPathRules() {
200
+ try { return selfPathRulesFor(userInfo().username); } catch { return []; }
201
+ }
202
+
203
+ let selfPath = null;
204
+ let aliasRules = null;
205
+ function aliases() {
206
+ if (aliasRules) return aliasRules;
207
+ aliasRules = selfNames();
208
+ try {
209
+ const cfg = JSON.parse(readFileSync(join(homedir(), '.diffin', 'redact.json'), 'utf8'));
210
+ for (const [name, alias] of Object.entries(cfg.names ?? {})) {
211
+ if (name.length < 2) continue;
212
+ aliasRules.push([new RegExp(escapeRe(name), 'gi'), `[${alias}]`]);
213
+ }
214
+ } catch { /* no alias map — fine */ }
215
+ return aliasRules;
216
+ }
217
+
218
+ export function scrub(text) {
219
+ if (text == null) return text;
220
+ let s = String(text);
221
+ for (const [re, rep] of (selfPath ??= selfPathRules())) s = s.replace(re, rep);
222
+ for (const rule of RULES) s = typeof rule === 'function' ? rule(s) : s.replace(rule[0], rule[1]);
223
+ for (const [re, rep] of aliases()) s = s.replace(re, rep);
224
+ return s;
225
+ }
226
+
227
+ // Recursively scrub every string in a parsed JSON value — the raw session rows.
228
+ function scrubDeep(v) {
229
+ if (typeof v === 'string') return scrub(v);
230
+ if (Array.isArray(v)) return v.map(scrubDeep);
231
+ if (v && typeof v === 'object') {
232
+ for (const k of Object.keys(v)) v[k] = scrubDeep(v[k]);
233
+ return v;
234
+ }
235
+ return v;
236
+ }
237
+
238
+ // Upload diet, before scrub. Pasted screenshots ride the log as base64 image blocks —
239
+ // measured ~49% of a real corpus's bytes. Extraction reads text turns only, so the
240
+ // blob is dead weight; keep the block structure so nothing downstream breaks on shape.
241
+ function stripBulk(v) {
242
+ if (Array.isArray(v)) return v.map(stripBulk);
243
+ if (v && typeof v === 'object') {
244
+ if (v.type === 'image' && v.source && typeof v.source === 'object'
245
+ && typeof v.source.data === 'string' && v.source.data.length > 256) {
246
+ return { ...v, source: { ...v.source, data: '[image omitted]' } };
247
+ }
248
+ const o = {};
249
+ for (const [k, val] of Object.entries(v)) o[k] = stripBulk(val);
250
+ return o;
251
+ }
252
+ // generic long base64 blob (screenshots inside tool results) — never real prose
253
+ if (typeof v === 'string' && v.length > 20000 && /^[A-Za-z0-9+/=\r\n]+$/.test(v)) return '[binary omitted]';
254
+ return v;
255
+ }
256
+
257
+ // What the server reads of a tool call, and nothing else of it. SCAN turns a call into a
258
+ // one-line marker — the file an edit touched, the command a shell ran, a URL, a query,
259
+ // what a delegated task was called — and reads which file a turn worked on. The BODY of a
260
+ // call is the work product itself: a Write's `content`, an Edit's `old_string` /
261
+ // `new_string`, a subagent's whole `prompt`, the message an MCP tool sent.
262
+ const KEEP_INPUT = new Set(['file_path', 'notebook_path', 'command', 'description', 'url', 'query']);
263
+ // The server reads at most the first 150 characters of any of these.
264
+ const KEEP_INPUT_MAX = 4000;
265
+ /**
266
+ * A shell command is where code rides along — `cat > f <<EOF …`, `node -e "…"`,
267
+ * `python3 -c "…"` — and it used to go up whole for one reason: SCAN searched all of it
268
+ * for the sign that a unit of work ended. (Cutting it at 4,000 characters was tried and
269
+ * lost two work boundaries on real sessions: a long `git commit -F - <<EOF … && gh pr
270
+ * create` carries its sign at the far end.) So the sign is read HERE, on the whole
271
+ * command, and what goes up is `_unitEnded` and the 130 characters SCAN prints as the
272
+ * `[RUN …]` marker — whitespace collapsed first, exactly as SCAN collapses it, so the
273
+ * marker comes out the same. The expression is SCAN's (`pipeline/src/stages/scan.ts`);
274
+ * `pipeline/test/upload-diet.mjs` fails if the two drift apart.
275
+ */
276
+ /**
277
+ * The paths a shell command WRITES. `[EDIT]`/`[WRITE]` markers only exist for the dedicated
278
+ * file tools, so an agent that edits through the shell — `cat > f <<EOF`, `sed -i`, `tee` —
279
+ * leaves no file behind it. Measured over nine real sessions: Edit/Write 4,301 calls against
280
+ * 947 shell writes, so the file signal was seeing 82% of the file work, and for a person
281
+ * whose agent prefers the shell it sees almost none. What reads it: RELEVANCE's `deliverable`
282
+ * (`hasResult || filesTouched`) and its connectedness between goals, and STITCH's assignment.
283
+ *
284
+ * Narrow on purpose — a wrong path is a claim about someone's work:
285
+ * - a heredoc body is DATA, not shell (`cat > f.mjs <<'JS' … x => y …` carries a whole
286
+ * program), and so is a quoted program after `-e`/`--eval`/`-c`. Both are cut before
287
+ * anything is read; a redirection target always stands before them. Without the cut,
288
+ * `=>` read as a redirection produced `t.type`, `console.log` and `g.id` as file names.
289
+ * - a token must carry a file extension and no shell metacharacter, which is what separates
290
+ * a path from `2>&1` and from a `>` inside prose.
291
+ * - /dev, /tmp and the sandbox's own scratch are not the person's work.
292
+ *
293
+ * It can only add a path, never remove one, so its failure is a file too many.
294
+ */
295
+ const WROTE_FD = /^&?\d+$/;
296
+ const WROTE_PATH = /^[~./A-Za-z0-9_-][^\s|;&<>"'`$(){}]*\.[A-Za-z0-9]{1,8}$/;
297
+ const WROTE_SCRATCH = /^\/(dev|tmp|private\/tmp|var\/folders)\//;
298
+ const wroteBad = (p) => !p || WROTE_FD.test(p) || !WROTE_PATH.test(p) || WROTE_SCRATCH.test(p);
299
+ export const WROTE_MAX = 8;
300
+
301
+ export function wrotePaths(command) {
302
+ const whole = String(command ?? '');
303
+ // read the command's own words, not the program it carries
304
+ let end = whole.length;
305
+ for (const m of whole.matchAll(/<<|\s-(?:e|c)\s|--eval\b|--input-type\b/g)) { end = Math.min(end, m.index); break; }
306
+ const s = whole.slice(0, end);
307
+ const out = new Set();
308
+ for (const m of s.matchAll(/(^|[^0-9&>=<!\-])>>?\s*([^\s|;&<>()]+)/g)) if (!wroteBad(m[2])) out.add(m[2]);
309
+ for (const m of s.matchAll(/\btee\s+(?:-a\s+)?([^\s|;&<>()]+)/g)) if (!wroteBad(m[1])) out.add(m[1]);
310
+ for (const m of s.matchAll(/\btouch\s+([^\s|;&<>()]+)/g)) if (!wroteBad(m[1])) out.add(m[1]);
311
+ for (const m of s.matchAll(/\bsed\s+-i\b[^|;&]*/g)) { const last = m[0].trim().split(/\s+/).at(-1); if (!wroteBad(last)) out.add(last); }
312
+ for (const m of s.matchAll(/\b(?:cp|mv)\s+(?:-\S+\s+)*[^\s|;&<>()]+\s+([^\s|;&<>()]+)/g)) if (!wroteBad(m[1])) out.add(m[1]);
313
+ return [...out].slice(0, WROTE_MAX);
314
+ }
315
+
316
+ /**
317
+ * The files a SCRIPT handed to an interpreter writes — `python3 - <<'EOF' … open(p, 'w') … EOF`, `node -e "…writeFileSync('a.ts', …)"`.
318
+ * `wrotePaths` cuts a heredoc body and a `-e`/`-c` program as data, which is right for the shell's grammar and leaves an
319
+ * agent that edits through scripts with no file behind it: on the owner's four-day ledger of 2026-10-04, 87 of 370
320
+ * records carried a file, and a turn that fixed a dozen server files carried none. Read here by the program's own
321
+ * shape, never by the shell's:
322
+ * - a write SINK — `open(X, 'w'|'a'|'x')`, `Path(X).write_text(`, `writeFileSync(X`, `appendFileSync(X`, `File.write(X` —
323
+ * whose target X is a string literal;
324
+ * - or a name assigned a string literal in the program (`p = 'a/b.ts'` … `open(p, 'w')`);
325
+ * - or the FIRST parameter of a function defined in the program, in which case the literal (or such a name) each call
326
+ * passes there (`def sub(p, old, new): … open(p, 'w')` then `sub('a/b.ts', …)`).
327
+ * A path a program only READS is not a sink and is not taken. The path shape and the scratch folders are `wrotePaths`'.
328
+ * Read on this machine only, like the PowerShell rule: the server takes `_wrote` as sent. Bounded: the program is read
329
+ * to SCRIPT_MAX characters, a literal to 300, at most SINK_MAX sinks.
330
+ */
331
+ const SCRIPT_MAX = 200_000;
332
+ const SINK_MAX = 40;
333
+ const S_LIT = `(?:'([^'\\n]{1,300})'|"([^"\\n]{1,300})")`;
334
+ const S_ID = `([A-Za-z_$][\\w$]{0,40})`;
335
+ const S_ARG = `(?:${S_LIT}|${S_ID})`;
336
+ const SINKS = [
337
+ new RegExp(`\\bopen\\(\\s*${S_ARG}\\s*,\\s*['"][wax]`, 'g'),
338
+ new RegExp(`\\b(?:writeFileSync|appendFileSync|writeFile|appendFile)\\(\\s*${S_ARG}`, 'g'),
339
+ new RegExp(`\\bPath\\(\\s*${S_ARG}\\s*\\)\\s*\\.\\s*write_(?:text|bytes)\\(`, 'g'),
340
+ new RegExp(`\\bFile\\.write\\(\\s*${S_ARG}`, 'g'),
341
+ ];
342
+ export function wrotePathsScript(command) {
343
+ const whole = String(command ?? '');
344
+ // the program is the one handed to an INTERPRETER (`python3 - <<EOF`, `node -e "…"`), never another command's heredoc:
345
+ // `node x.js && cat > f <<EOF …` carries a file's text, which is data
346
+ let body = '';
347
+ for (const m of whole.matchAll(/\b(?:python3?|node|ruby|bun|deno)\b[^\n|;&]{0,80}?(<<-?\s*['"]?(\w+)['"]?|\s-(?:e|c)\s|--eval\b)/g)) {
348
+ const from = m.index + m[0].length; let to = whole.length;
349
+ if (m[2]) { const end = whole.indexOf('\n' + m[2], from); if (end >= 0) to = end; }
350
+ body += whole.slice(from, Math.min(to, from + SCRIPT_MAX)) + '\n';
351
+ if (body.length >= SCRIPT_MAX) break;
352
+ }
353
+ if (!body) return [];
354
+ const out = new Set(); const add = (p) => { if (p && !wroteBad(p)) out.add(p); };
355
+ const esc = (v) => v.replace(/[$]/g, '\\$&');
356
+ const assigned = (v) => [...body.matchAll(new RegExp(`(?:^|[^\\w$.])${esc(v)}\\s*=\\s*${S_LIT}`, 'gm'))].map((m) => m[1] ?? m[2]);
357
+ let sinks = 0;
358
+ for (const re of SINKS) for (const m of body.matchAll(re)) {
359
+ if (++sinks > SINK_MAX) break;
360
+ if (m[1] ?? m[2]) { add(m[1] ?? m[2]); continue; }
361
+ const v = m[3]; if (!v) continue;
362
+ for (const p of assigned(v)) add(p);
363
+ // v as the first parameter of a function of the program: what each call passes there
364
+ for (const f of body.matchAll(new RegExp(`(?:\\bdef\\s+|\\bfunction\\s+|\\b(?:const|let|var)\\s+)${S_ID}\\s*(?:=\\s*(?:async\\s*)?)?\\(\\s*${esc(v)}\\b`, 'g'))) {
365
+ for (const c of body.matchAll(new RegExp(`(?:^|[^\\w$.])${esc(f[1])}\\(\\s*${S_ARG}`, 'gm'))) { if (c[1] ?? c[2]) add(c[1] ?? c[2]); else if (c[3]) for (const p of assigned(c[3])) add(p); }
366
+ }
367
+ }
368
+ return [...out].slice(0, WROTE_MAX);
369
+ }
370
+
371
+ /**
372
+ * The same reading for a POWERSHELL command. On Windows the agent's shell tool is `PowerShell` — on by default, and
373
+ * the primary shell when it is (Claude Code's tools reference, read 2026-10-03); the `Bash` tool is there only when
374
+ * Git Bash is installed. Its call carries `command`, like Bash's. Without this a Windows session left no file behind
375
+ * any shell write, which is the blind spot the function above was written for, on every turn.
376
+ *
377
+ * The cmdlets that write a file (and the aliases people's agents use), a redirection, a copy or a move. A here-string
378
+ * (@' … '@, @" … "@) is the heredoc's twin and is cut the same way; so is a program after -Command. A path may be
379
+ * quoted and may hold backslashes; the temp folder is not the person's work. Read on this machine only: the server
380
+ * takes `_wrote` as sent and has no second copy of this rule — no upload from Windows ever went without the flag.
381
+ */
382
+ const PS_SCRATCH = /(^|[\\/])(te?mp|appdata[\\/]local[\\/]temp)[\\/]/i;
383
+ // a quoted path may hold spaces (C:\Users\me\my proj\a.md), which the bare-token shape refuses
384
+ const psBad = (p, quoted) => { const q = quoted ? String(p ?? '').replace(/ /g, '_') : p; return !q || WROTE_FD.test(q) || !WROTE_PATH.test(q) || WROTE_SCRATCH.test(q) || PS_SCRATCH.test(q) || /^nul$/i.test(q); };
385
+ const PS_ARG = `(?:"([^"\\n]+)"|'([^'\\n]+)'|([^\\s|;&<>()"']+))`;
386
+ export function wrotePathsPs(command) {
387
+ // A here-string is DATA, and in PowerShell it usually stands BEFORE the cmdlet it is piped into
388
+ // (@'…'@ | Set-Content a.ts), so its body is taken out rather than the command cut at it; an unclosed one, and a
389
+ // program handed to -Command / -e / -c, end the reading as a heredoc does.
390
+ const whole = String(command ?? '').replace(/@'[\s\S]*?\n'@/g, "''").replace(/@"[\s\S]*?\n"@/g, '""');
391
+ let end = whole.length;
392
+ for (const m of whole.matchAll(/@['"]|\s-(?:Command|EncodedCommand|e|c)\s|--eval\b/gi)) { end = Math.min(end, m.index); break; }
393
+ const s = whole.slice(0, end);
394
+ const out = new Set();
395
+ const add = (m, i) => { const quoted = m[i] ?? m[i + 1]; const p = quoted ?? m[i + 2]; if (!psBad(p, quoted !== undefined)) out.add(p); };
396
+ for (const m of s.matchAll(new RegExp(`(?:^|[^0-9&>=<!\\-])>>?\\s*${PS_ARG}`, 'g'))) add(m, 1);
397
+ // Set-Content a.ts … · Out-File -FilePath a.ts · New-Item -ItemType File -Path a.md · Tee-Object -FilePath a.log
398
+ for (const m of s.matchAll(new RegExp(`\\b(?:Set-Content|Add-Content|Out-File|Tee-Object|New-Item|sc|ac|ni|tee)\\b((?:\\s+-(?!Path\\b|FilePath\\b|LiteralPath\\b)\\w+(?:\\s+(?!-)[^\\s|;]+)?)*)\\s+(?:-(?:Path|FilePath|LiteralPath)\\s+)?${PS_ARG}`, 'gi'))) add(m, 2);
399
+ // Copy-Item src dst · Move-Item src -Destination dst
400
+ for (const m of s.matchAll(new RegExp(`\\b(?:Copy-Item|Move-Item|cp|mv|copy|move|cpi|mi)\\s+(?:-(?:Path|LiteralPath)\\s+)?${PS_ARG}\\s+(?:-Destination\\s+)?${PS_ARG}`, 'gi'))) add(m, 4);
401
+ return [...out].slice(0, WROTE_MAX);
402
+ }
403
+ /** the agent's shell tools: what a `[RUN]` marker, a unit's end and a written file are read from */
404
+ export const SHELL_TOOLS = new Set(['Bash', 'PowerShell']);
405
+
406
+ export const DONE_RUN = /git (commit|push)|gh pr|deploy|npm publish|pm2 (reload|restart)/;
407
+ export const COMMAND_HEAD = 130;
408
+ export const OMITTED = '[omitted]';
409
+
410
+ function dietBlock(b) {
411
+ if (!b || typeof b !== 'object') return b;
412
+ // what the assistant thought, and its signature: no stage reads it
413
+ if (b.type === 'thinking' || b.type === 'redacted_thinking') return { type: b.type, thinking: OMITTED };
414
+ // what a tool returned — file contents, command output, search results: no stage reads it
415
+ if (b.type === 'tool_result') return { type: 'tool_result', tool_use_id: b.tool_use_id, ...(b.is_error ? { is_error: true } : {}), content: OMITTED };
416
+ if (b.type === 'tool_use' && b.input && typeof b.input === 'object' && !Array.isArray(b.input)) {
417
+ const input = {};
418
+ for (const [k, v] of Object.entries(b.input)) input[k] = KEEP_INPUT.has(k) && typeof v === 'string' ? v.slice(0, KEEP_INPUT_MAX) : OMITTED;
419
+ if (typeof b.input.command === 'string') {
420
+ // Scrub FIRST, then take the head. The server used to get the whole scrubbed command
421
+ // and print its first 130 characters; scrubbing shortens a command (a home path
422
+ // becomes `~`), so a head cut from the raw text came out short of what the server
423
+ // printed — 15,050 differing marker lines across nine real sessions, found only by
424
+ // comparing real data. `scrub` hoists, and the later pass over the row is a no-op here.
425
+ const whole = scrub(b.input.command);
426
+ input.command = whole.replace(/\s+/g, ' ').slice(0, COMMAND_HEAD);
427
+ if (SHELL_TOOLS.has(b.name) && DONE_RUN.test(whole)) input._unitEnded = true;
428
+ // The files this command wrote, read here from the WHOLE command: the head the server
429
+ // gets is 130 characters and measured 53% of them — `cd … && cat > server/test/x.mjs`
430
+ // puts the path past the cut. Same shape as `_unitEnded` above.
431
+ if (SHELL_TOOLS.has(b.name)) { const w = [...new Set([...(b.name === 'PowerShell' ? wrotePathsPs(whole) : wrotePaths(whole)), ...wrotePathsScript(whole)])].slice(0, WROTE_MAX); if (w.length) input._wrote = w; }
432
+ }
433
+ return { ...b, input };
434
+ }
435
+ return b;
436
+ }
437
+
438
+ // The rows a stage reads, and the keys it reads off them (pipeline/src/stages/scan.ts `Row`,
439
+ // ship.ts `buildIndex`). Everything else a transcript carries stays on the machine.
440
+ const READ_ROW_TYPES = new Set(['user', 'assistant']);
441
+ const KEEP_ROW = ['type', 'uuid', 'timestamp', 'cwd', 'isMeta', 'isSidechain'];
442
+ const KEEP_MESSAGE = ['role', 'model'];
443
+ const KEEP_USAGE = ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens'];
444
+
445
+ /**
446
+ * The upload diet: what no stage on the server reads does not leave the machine.
447
+ *
448
+ * The capture preview has always told the person "도구 출력·이미지·사고 블록은 보내지
449
+ * 않습니다", and only the images were true — measured 2026-09-20 by running this module on
450
+ * a row: the thinking block, the body of a Write and the tool's output all came out the
451
+ * other side. They are where an employer's code sits, and the server skipped every one of
452
+ * them. What the person SAID and what the assistant SAID go up whole, as before.
453
+ *
454
+ * It is an ALLOWLIST, of row types and of keys, not a list of things to remove. The first
455
+ * cut removed the known bodies and measured well — and Claude Code turned out to keep a
456
+ * second copy of every tool input in a top-level `wireToolInputs`, so a Write's content
457
+ * still left. A transcript row has some fifty top-level keys and gains more with each
458
+ * release; a removal list is wrong again the day a new one appears. Hook output and
459
+ * attached files ride in `attachment` rows, which no stage reads, and go with the rest.
460
+ *
461
+ * Shape is kept where a reader looks at it — a tool_result row is still a row, a call
462
+ * still has its name and its input keys — because the last time scrubbing was widened
463
+ * without checking its readers, uploads lost their timestamps (CLAUDE.md, first table).
464
+ * `pipeline/test/upload-diet.mjs` runs SCAN and SHIP's index over the same session with
465
+ * and without this and requires identical output. A stage that starts reading a new key
466
+ * adds it here in the same change, and that test is what says so.
467
+ *
468
+ * Returns null for a row no stage reads.
469
+ */
470
+ export function dietRow(row) {
471
+ if (!row || typeof row !== 'object') return row;
472
+ row = queuedTurn(row) ?? row;
473
+ if (!READ_ROW_TYPES.has(row.type)) return null;
474
+ const out = {};
475
+ for (const k of KEEP_ROW) if (row[k] !== undefined) out[k] = row[k];
476
+ const m = row.message;
477
+ if (m && typeof m === 'object') {
478
+ const msg = {};
479
+ for (const k of KEEP_MESSAGE) if (m[k] !== undefined) msg[k] = m[k];
480
+ if (m.usage && typeof m.usage === 'object') { msg.usage = {}; for (const k of KEEP_USAGE) if (typeof m.usage[k] === 'number') msg.usage[k] = m.usage[k]; }
481
+ if (m.content !== undefined) msg.content = Array.isArray(m.content) ? m.content.map(dietBlock) : m.content;
482
+ out.message = msg;
483
+ }
484
+ return out;
485
+ }
486
+
487
+ /**
488
+ * A message the person typed while the agent was still working, as a user row — or null for anything else.
489
+ *
490
+ * Claude Code does not write such a message as a `user` row: it is queued and lands in the transcript as an
491
+ * `attachment` of type `queued_command`, which the allowlist above drops. Measured 2026-10-06 on the owner's capture of
492
+ * that night: a front request pasted mid-turn never reached the server, so the work it started was filed under the
493
+ * turn before it and two pieces of work became one record. Over every local transcript, 131 such messages were the
494
+ * person's and 3 of them were also written as a user row (two typed twice on purpose, one resend that SCAN's resend
495
+ * rule folds). The others in the queue are not the person: a background task's notice (`commandMode:
496
+ * 'task-notification'`, 540) and another agent's message (`origin.kind: 'peer'`). A human one carries `origin.kind:
497
+ * 'human'`, or no origin at all on older rows (6, all typed by the person).
498
+ */
499
+ export function queuedTurn(row) {
500
+ const a = row.type === 'attachment' ? row.attachment : null;
501
+ if (!a || a.type !== 'queued_command' || a.commandMode !== 'prompt') return null;
502
+ if (a.origin && a.origin.kind !== 'human') return null;
503
+ if (typeof a.prompt !== 'string' && !Array.isArray(a.prompt)) return null;
504
+ return { ...row, type: 'user', message: { role: 'user', content: a.prompt } };
505
+ }
506
+
507
+ // One parsed session row → the line that may leave the machine, or null to drop it
508
+ // (a row no stage reads: editor snapshots, hook output, attachments, system rows).
509
+ export function cleanRow(row) {
510
+ const kept = dietRow(row);
511
+ if (kept === null || kept === undefined) return null;
512
+ return JSON.stringify(posixRow(scrubDeep(stripBulk(kept))));
513
+ }
@@ -0,0 +1,46 @@
1
+ // What diffin writes into an agent's own settings — one entry each, for Claude Code and for Codex; Hermes and
2
+ // Antigravity need none. Claude Code: `Bash(diffin capture:*)` on its allowlist. The send is the agent's own call,
3
+ // made without a prompt; a permission prompt on it is the thing this design removed — in Claude Code a "no" there
4
+ // ends the turn. Codex: see installCodexAllow.
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
+ import { homedir } from 'node:os';
7
+ import { join } from 'node:path';
8
+
9
+ const HOME = () => process.env.DIFFIN_HOME ?? homedir();
10
+ export function claudeSettingsPath() { return join(HOME(), '.claude', 'settings.json'); }
11
+
12
+ export const ALLOW_RULE = 'Bash(diffin capture:*)';
13
+ export function installClaudeAllow() {
14
+ const p = claudeSettingsPath();
15
+ if (!existsSync(join(HOME(), '.claude'))) return { installed: false, reason: 'no-claude' };
16
+ let settings = {};
17
+ if (existsSync(p)) { try { settings = JSON.parse(readFileSync(p, 'utf8')); } catch { return { installed: false, reason: 'unreadable' }; } }
18
+ settings.permissions ??= {};
19
+ const allow = Array.isArray(settings.permissions.allow) ? settings.permissions.allow : [];
20
+ if (allow.includes(ALLOW_RULE)) return { installed: true, changed: false, path: p };
21
+ settings.permissions.allow = [...allow, ALLOW_RULE];
22
+ writeFileSync(p, JSON.stringify(settings, null, 2) + '\n');
23
+ return { installed: true, changed: true, path: p };
24
+ }
25
+
26
+ /**
27
+ * Codex: a rule that lets `diffin capture` run as the agent's own call. It is more than the missing prompt there —
28
+ * a command Codex runs sits in a sandbox with no network and no write outside the workspace, and a command the rules
29
+ * allow runs without it. Measured 2026-10-04 with a probe script: allowed by a rule, it reached the network and wrote
30
+ * under ~/.diffin; the same script through `sh` could do neither. The rule matches the command called DIRECTLY
31
+ * (`diffin capture …`), which is how the skill says to call it; wrapped in a shell it does not apply.
32
+ *
33
+ * Appended to the person's own rules file, once; the rest of the file is theirs and is not touched.
34
+ */
35
+ export const CODEX_RULE = 'prefix_rule(\n pattern = ["diffin", "capture"],\n decision = "allow",\n)\n';
36
+ export function codexRulesPath() { return join(HOME(), '.codex', 'rules', 'default.rules'); }
37
+ export function installCodexAllow() {
38
+ if (!existsSync(join(HOME(), '.codex'))) return { installed: false, reason: 'no-codex' };
39
+ const p = codexRulesPath();
40
+ let text = '';
41
+ if (existsSync(p)) { try { text = readFileSync(p, 'utf8'); } catch { return { installed: false, reason: 'unreadable' }; } }
42
+ if (/pattern\s*=\s*\[\s*"diffin"\s*,\s*"capture"\s*\]/.test(text)) return { installed: true, changed: false, path: p };
43
+ mkdirSync(join(HOME(), '.codex', 'rules'), { recursive: true });
44
+ writeFileSync(p, text + (text && !text.endsWith('\n') ? '\n' : '') + (text ? '\n' : '') + CODEX_RULE);
45
+ return { installed: true, changed: true, path: p };
46
+ }