@vieko/pi-redact 0.1.1 → 0.2.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/NOTICE CHANGED
@@ -7,6 +7,9 @@ Local changes in src/detect.ts are marked with "pi-redact:" comments:
7
7
  - keep the macOS per-user temp dir (/var/folders/xx/<random>/T/)
8
8
  - name Linear (lin_api_) and Vercel AI Gateway (vck_) keys as vendor shapes
9
9
  - export findVendorKeys() for the history scan
10
+ - mint placeholders as a keyed HMAC of the value (setTagKey()) instead of
11
+ random bytes, so a reloaded process mints the same tags; the vendored
12
+ test/random-tags.vendor.mts is replaced by test/stable-tags.test.ts
10
13
 
11
14
  In test/detect.vendor.mts:
12
15
  - the 5 s timing limit can be raised with REDACT_PERF_LIMIT_MS (CI uses 10 s)
package/README.md CHANGED
@@ -25,9 +25,13 @@ pi install npm:@vieko/pi-redact
25
25
 
26
26
  Placeholders stay placeholders in `git commit`/`git tag` and `gh pr|issue|api|...`, and in every tool that is not in the restore list (`codemode` itself, `send_message`, MCP tools, web tools).
27
27
 
28
+ ## Placeholders
29
+
30
+ A placeholder is an HMAC-SHA256 of the value. The key belongs to the session lineage (a fork or clone uses its root's key) and comes from a machine key in `~/.pi/agent/pi-redact.key`, created with mode 0600 on first use. After `/reload`, a restart or a resume, the same value gets the same placeholder, so the history pi sends does not change and the prompt cache holds. Two unrelated sessions get different placeholders. The machine key never reaches the model, so a placeholder cannot be matched to a guessed value. If the key file cannot be read or written, placeholders fall back to random tags per process.
31
+
28
32
  ## Tracking
29
33
 
30
- Each value seen for the first time in a session is logged as a `pi-redact` custom entry: `{ "source": "tool:bash", "values": 1, "vendor": 1 }`. The entry holds counts and the channel only, never the value, and is not sent to the model. `vendor` counts values with a known vendor shape; the rest came from entropy rules.
34
+ Each value seen for the first time in a session is logged as a `pi-redact` custom entry. Its fields are `source` (channel), `values` (new distinct values), `vendor` (new values with a known vendor shape), and `families` (family name to count, summing to `values`). The entry holds counts only, never the value or a piece of it, and is not sent to the model. Families are `uuid`, `prefixed-id`, `identifier`, and `high-entropy`; vendor-shaped values use the detector's public prefix or pattern as their family.
31
35
 
32
36
  ## Old sessions
33
37
 
@@ -56,7 +60,7 @@ Optional `~/.pi/agent/pi-redact.json`, same keys as the upstream plugin:
56
60
 
57
61
  ## Limits
58
62
 
59
- - The vault lives in memory. After `/reload`, a restart or a resume, old placeholders no longer map to a value and reach tools as written.
63
+ - The vault lives in memory. After `/reload`, a restart or a resume, an old placeholder maps to its value again only when that value turns up again; until then it reaches tools as written.
60
64
  - Output of `!cmd` is persisted by pi before any hook sees it. The model gets placeholders (the `context` net), but the raw output stays in the session file.
61
65
  - Custom messages appended while pi is idle are persisted without `message_end`; the `context` net covers what the model sees.
62
66
  - Sessions recorded before you install it keep their raw values on disk until you run `scripts/scrub-sessions.ts`.
@@ -21,11 +21,20 @@
21
21
  * custom entry (counts and channel only, never the value), so audits can
22
22
  * tell how often and how secrets reach a session.
23
23
  *
24
- * The vault lives in memory. After /reload or a resume, old placeholders no
25
- * longer map to a value and reach tools as written.
24
+ * Placeholders are keyed per session lineage (a fork or clone shares its
25
+ * root's key) with a machine key in ~/.pi/agent/pi-redact.key, so /reload,
26
+ * a restart or a resume mints the same placeholder for the same value and
27
+ * the prompt cache survives. The vault (placeholder -> value) lives in
28
+ * memory: after /reload or a resume, old placeholders no longer map to a
29
+ * value until the value is seen again, and reach tools as written.
30
+ *
31
+ * The footer shows `redact:N`: distinct values kept out of this session's
32
+ * transcript. It counts the `pi-redact` entries on the branch, so the
33
+ * number survives /reload and resume even though the vault does not.
26
34
  */
27
35
 
28
- import { existsSync, readFileSync } from "node:fs";
36
+ import { createHash, createHmac, randomBytes } from "node:crypto";
37
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
29
38
  import { homedir } from "node:os";
30
39
  import { join } from "node:path";
31
40
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
@@ -39,12 +48,15 @@ import {
39
48
  restore,
40
49
  scrubPii,
41
50
  scrubSecrets,
51
+ setTagKey,
42
52
  type WalkState,
43
53
  walk,
44
54
  } from "../src/detect.ts";
55
+ import { familyOf } from "../src/family.ts";
45
56
 
46
57
  const NAME = "pi-redact";
47
58
  const CONFIG_PATH = join(homedir(), ".pi", "agent", "pi-redact.json");
59
+ const KEY_PATH = join(homedir(), ".pi", "agent", "pi-redact.key");
48
60
 
49
61
  /** Tools that run on this machine and may receive real values. */
50
62
  export const DEFAULT_RESTORE_TOOLS = "bash,powershell,write,edit,read,grep,find,ls";
@@ -109,15 +121,93 @@ function mapContent<T>(content: T, fn: (s: string) => string): T {
109
121
  export interface RedactOptions {
110
122
  /** Overrides ~/.pi/agent/pi-redact.json, for tests. */
111
123
  options?: Record<string, unknown>;
124
+ /** Machine key file; defaults to ~/.pi/agent/pi-redact.key. */
125
+ keyPath?: string;
126
+ /** Machine key itself, skipping the file, for tests. */
127
+ machineKey?: string;
128
+ }
129
+
130
+ /**
131
+ * The machine key, created on first use (0600). It never reaches the model;
132
+ * without it a placeholder cannot be tied to a guessed value. Undefined when
133
+ * the file cannot be read or written: tags then stay per-process random.
134
+ */
135
+ export function loadMachineKey(path: string): string | undefined {
136
+ try {
137
+ const key = readFileSync(path, "utf8").trim();
138
+ if (/^[0-9a-f]{64}$/.test(key)) return key;
139
+ } catch {
140
+ // Missing: create it below.
141
+ }
142
+ try {
143
+ writeFileSync(path, `${randomBytes(32).toString("hex")}\n`, { mode: 0o600, flag: "wx" });
144
+ } catch {
145
+ // Another process won the race, or the directory is read-only.
146
+ }
147
+ try {
148
+ const key = readFileSync(path, "utf8").trim();
149
+ return /^[0-9a-f]{64}$/.test(key) ? key : undefined;
150
+ } catch {
151
+ return undefined;
152
+ }
153
+ }
154
+
155
+ type Header = { id?: string; parentSession?: string } | null | undefined;
156
+
157
+ /** Id of the oldest session this one was forked or cloned from. */
158
+ export function lineageId(header: Header): string | undefined {
159
+ let id = header?.id;
160
+ let parent = header?.parentSession;
161
+ for (let hop = 0; parent && hop < 16; hop++) {
162
+ try {
163
+ const first = readFileSync(parent, "utf8").split("\n", 1)[0] ?? "";
164
+ const up = JSON.parse(first) as { type?: string; id?: string; parentSession?: string };
165
+ if (up.type !== "session" || !up.id) break;
166
+ id = up.id;
167
+ parent = up.parentSession;
168
+ } catch {
169
+ break;
170
+ }
171
+ }
172
+ return id;
112
173
  }
113
174
 
114
175
  export function createRedactor(pi: ExtensionAPI, init: RedactOptions = {}): void {
115
176
  const cfg = buildConfig(init.options ?? loadOptions(CONFIG_PATH));
116
177
  let enabled = true;
117
178
  let hiddenTotal = 0;
179
+ let kept = 0;
180
+ let ui: ExtensionContext | undefined;
181
+ const showStatus = (): void => {
182
+ if (!ui?.hasUI || kept <= 0) return;
183
+ try {
184
+ ui.ui.setStatus(NAME, ui.ui.theme.fg("dim", `redact:${kept}`));
185
+ } catch {
186
+ // The context went stale (session replaced); the next session_start resets it.
187
+ }
188
+ };
118
189
  const handedById = new Map<string, Set<string>>();
119
- const contextMemo = new WeakMap<object, AnyMessage>();
190
+ let contextMemo = new WeakMap<object, AnyMessage>();
191
+ let contextMemoById = new Map<string, AnyMessage>();
120
192
  const tracked = new Set<string>();
193
+ let machineKey = init.machineKey;
194
+ let sessionKey: string | undefined;
195
+
196
+ // One key per session lineage. A new lineage starts a new vault, so tags
197
+ // do not link one session to another.
198
+ const keySession = (header: Header): void => {
199
+ machineKey ??= loadMachineKey(init.keyPath ?? KEY_PATH);
200
+ const lineage = lineageId(header);
201
+ if (!machineKey || !lineage) return;
202
+ const key = createHmac("sha256", machineKey).update(`pi-redact/tags/v1\u0000${lineage}`).digest();
203
+ const hex = key.toString("hex");
204
+ if (hex === sessionKey) return;
205
+ sessionKey = hex;
206
+ setTagKey(key);
207
+ contextMemo = new WeakMap();
208
+ contextMemoById = new Map();
209
+ tracked.clear();
210
+ };
121
211
 
122
212
  // One entry per channel event that brought in values not seen before.
123
213
  // `vendor` counts the ones with a known vendor shape (high confidence);
@@ -126,9 +216,17 @@ export function createRedactor(pi: ExtensionAPI, init: RedactOptions = {}): void
126
216
  const fresh = [...tags].filter((t) => !t.startsWith("[REDACTED-LITERAL-") && !tracked.has(t));
127
217
  if (fresh.length === 0) return;
128
218
  for (const t of fresh) tracked.add(t);
129
- const vendor = fresh.filter((t) => findVendorKeys(restore(t)).length > 0).length;
219
+ kept += fresh.length;
220
+ showStatus();
221
+ const values = fresh.map((t) => restore(t));
222
+ const vendor = values.filter((value) => findVendorKeys(value).length > 0).length;
223
+ const families: Record<string, number> = {};
224
+ for (const value of values) {
225
+ const family = familyOf(value);
226
+ families[family] = (families[family] ?? 0) + 1;
227
+ }
130
228
  try {
131
- pi.appendEntry(NAME, { source, values: fresh.length, vendor });
229
+ pi.appendEntry(NAME, { source, values: fresh.length, vendor, families });
132
230
  } catch {
133
231
  // Tracking is best-effort; redaction already happened.
134
232
  }
@@ -146,7 +244,6 @@ export function createRedactor(pi: ExtensionAPI, init: RedactOptions = {}): void
146
244
  if (n <= 0) return;
147
245
  hiddenTotal += n;
148
246
  if (!ctx?.hasUI) return;
149
- ctx.ui.setStatus(NAME, `redact ${hiddenTotal}`);
150
247
  if (cfg.notify && where) ctx.ui.notify(`${NAME}: hid ${n} value(s) from ${where}`, "info");
151
248
  };
152
249
 
@@ -161,6 +258,18 @@ export function createRedactor(pi: ExtensionAPI, init: RedactOptions = {}): void
161
258
  return out;
162
259
  };
163
260
 
261
+ pi.on("session_start", (_event, ctx) => {
262
+ ui = ctx;
263
+ kept = 0;
264
+ const sm = ctx.sessionManager as { getHeader?: () => Header };
265
+ keySession(typeof sm.getHeader === "function" ? sm.getHeader() : undefined);
266
+ for (const e of ctx.sessionManager.getBranch() as { type: string; customType?: string; data?: { values?: number } }[]) {
267
+ if (e.type === "custom" && e.customType === NAME) kept += e.data?.values ?? 0;
268
+ }
269
+ if (ctx.hasUI) ctx.ui.setStatus(NAME, undefined);
270
+ showStatus();
271
+ });
272
+
164
273
  // 1. What the user types. `!cmd` is the user's own shell and runs as typed.
165
274
  pi.on("input", (event, ctx) => {
166
275
  if (!enabled || event.text.startsWith("!")) return { action: "continue" as const };
@@ -264,12 +373,30 @@ export function createRedactor(pi: ExtensionAPI, init: RedactOptions = {}): void
264
373
  }
265
374
  });
266
375
 
376
+ // pi may rebuild message objects on each request, so key the memo by the
377
+ // unscrubbed message's digest as well as by object. A digest, not role +
378
+ // timestamp: two messages that share a timestamp must never share output.
379
+ const contextMessageId = (message: AnyMessage): string | undefined => {
380
+ try {
381
+ return createHash("sha256").update(JSON.stringify(message)).digest("hex");
382
+ } catch {
383
+ return undefined;
384
+ }
385
+ };
386
+
267
387
  // 6. The last net, request-local: bashExecution output, custom messages
268
388
  // appended while idle, and history from sessions recorded before this
269
- // extension. Same value -> same placeholder, so the cached prefix holds.
389
+ // extension. Freeze each message's first scrub result: the echo set may
390
+ // grow later, but already-sent bytes must not change.
270
391
  const cleanMessage = (message: AnyMessage): AnyMessage => {
271
392
  const cached = contextMemo.get(message);
272
393
  if (cached) return cached;
394
+ const id = contextMessageId(message);
395
+ const stableCached = id === undefined ? undefined : contextMemoById.get(id);
396
+ if (stableCached) {
397
+ contextMemo.set(message, stableCached);
398
+ return stableCached;
399
+ }
273
400
  let out = message;
274
401
  switch (message.role) {
275
402
  case "user":
@@ -293,6 +420,7 @@ export function createRedactor(pi: ExtensionAPI, init: RedactOptions = {}): void
293
420
  }
294
421
  }
295
422
  contextMemo.set(message, out);
423
+ if (id !== undefined) contextMemoById.set(id, out);
296
424
  return out;
297
425
  };
298
426
 
package/package.json CHANGED
@@ -1,25 +1,54 @@
1
1
  {
2
2
  "name": "@vieko/pi-redact",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Keeps secrets out of pi session transcripts: stable placeholders in, real values back for local tools.",
5
- "keywords": ["pi-package", "pi-extension", "secrets", "redaction", "privacy"],
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "secrets",
9
+ "redaction",
10
+ "privacy"
11
+ ],
6
12
  "license": "MIT",
7
13
  "author": "Vieko Franetovic",
8
- "repository": { "type": "git", "url": "git+https://github.com/vieko/pi-redact.git" },
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/vieko/pi-redact.git"
17
+ },
9
18
  "homepage": "https://github.com/vieko/pi-redact#readme",
10
- "bugs": { "url": "https://github.com/vieko/pi-redact/issues" },
11
- "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org" },
19
+ "bugs": {
20
+ "url": "https://github.com/vieko/pi-redact/issues"
21
+ },
22
+ "publishConfig": {
23
+ "access": "public",
24
+ "registry": "https://registry.npmjs.org"
25
+ },
12
26
  "type": "module",
13
- "files": ["extensions", "src", "scripts", "README.md", "NOTICE", "LICENSE"],
27
+ "files": [
28
+ "extensions",
29
+ "src",
30
+ "scripts",
31
+ "README.md",
32
+ "NOTICE",
33
+ "LICENSE"
34
+ ],
14
35
  "scripts": {
15
36
  "typecheck": "tsc -p tsconfig.json",
16
- "test:vendor": "node test/detect.vendor.mts && node test/pii-options.vendor.mts && node test/random-tags.vendor.mts && node test/session-privacy.vendor.mts",
37
+ "test:vendor": "node test/detect.vendor.mts && node test/pii-options.vendor.mts && node test/session-privacy.vendor.mts",
17
38
  "test": "node --test \"test/*.test.ts\" && npm run test:vendor",
18
39
  "check": "npm run typecheck && npm run test"
19
40
  },
20
- "engines": { "node": ">=22.18" },
21
- "pi": { "extensions": ["./extensions/pi-redact.ts"] },
22
- "peerDependencies": { "@earendil-works/pi-coding-agent": ">=0.1.0" },
41
+ "engines": {
42
+ "node": ">=22.18"
43
+ },
44
+ "pi": {
45
+ "extensions": [
46
+ "./extensions/pi-redact.ts"
47
+ ]
48
+ },
49
+ "peerDependencies": {
50
+ "@earendil-works/pi-coding-agent": ">=0.1.0"
51
+ },
23
52
  "devDependencies": {
24
53
  "@earendil-works/pi-coding-agent": "*",
25
54
  "@types/node": "^24.0.0",
package/src/detect.ts CHANGED
@@ -16,6 +16,8 @@
16
16
  //
17
17
  // Nothing is written to disk. The vault dies with the session.
18
18
 
19
+ import { createHash, type Hash, randomBytes } from 'node:crypto'
20
+
19
21
  export type Kind = 'SECRET' | 'EMAIL' | 'IP' | 'LITERAL'
20
22
 
21
23
  export type Config = {
@@ -51,12 +53,39 @@ let minted: Set<string> | null = null
51
53
  const TAG = /\[REDACTED-(?:SECRET|EMAIL|IP|LITERAL)-[0-9a-f]{8}\]/g
52
54
  const WHOLE_TAG = /^\[REDACTED-(?:SECRET|EMAIL|IP|LITERAL)-[0-9a-f]{8}\]$/
53
55
 
54
- function randomHex8(): string {
55
- const bytes = new Uint8Array(4)
56
- const crypto = (globalThis as { crypto?: { getRandomValues?: (values: Uint8Array) => Uint8Array } }).crypto
57
- if (crypto?.getRandomValues) crypto.getRandomValues(bytes)
58
- else for (let index = 0; index < bytes.length; index++) bytes[index] = Math.floor(Math.random() * 256)
59
- return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
56
+ // pi-redact: a tag is a keyed hash of the value, not a random draw, so a
57
+ // reloaded or restarted process mints the same tag for the same value and
58
+ // the history it sends keeps its prompt-cache prefix. The host sets the key
59
+ // per session with setTagKey(); without one, each module load draws a random
60
+ // key, so tags stay unlinkable across processes as upstream intends.
61
+ let tagKey: Uint8Array = randomBytes(32)
62
+ let pads = hmacPads(tagKey)
63
+
64
+ // HMAC-SHA256 with the two padded key blocks hashed once and copied per tag:
65
+ // createHmac() per value made the vendored timing cases twice as slow.
66
+ function hmacPads(key: Uint8Array): { inner: Hash; outer: Hash } {
67
+ const block = Buffer.alloc(64)
68
+ Buffer.from(key.length > 64 ? createHash('sha256').update(key).digest() : key).copy(block)
69
+ const inner = createHash('sha256').update(block.map(b => b ^ 0x36))
70
+ const outer = createHash('sha256').update(block.map(b => b ^ 0x5c))
71
+ return { inner, outer }
72
+ }
73
+
74
+ function tagHex(kind: Kind, value: string, attempt: number): string {
75
+ const inner = pads.inner.copy().update(`${kind}\u0000${attempt}\u0000${value}`).digest()
76
+ return pads.outer.copy().update(inner).digest('hex').slice(0, 8)
77
+ }
78
+
79
+ // pi-redact: swaps the tag key. A different key starts a new vault: tags
80
+ // minted under the old key would no longer match what a fresh process mints.
81
+ export function setTagKey(key: Uint8Array): void {
82
+ if (Buffer.from(key).equals(Buffer.from(tagKey))) return
83
+ tagKey = Uint8Array.from(key)
84
+ pads = hmacPads(tagKey)
85
+ byValue.clear()
86
+ byTag.clear()
87
+ echo.clear()
88
+ echoIndex = null
60
89
  }
61
90
 
62
91
  // Swaps a value for its placeholder and remembers both. A value that already
@@ -72,7 +101,8 @@ export function mint(kind: Kind, raw: string): string {
72
101
  return seen
73
102
  }
74
103
  let tag: string
75
- do tag = `[REDACTED-${kind}-${randomHex8()}]`
104
+ let attempt = 0
105
+ do tag = `[REDACTED-${kind}-${tagHex(kind, value, attempt++)}]`
76
106
  while (byTag.has(tag))
77
107
  byValue.set(value, tag)
78
108
  byTag.set(tag, value)
package/src/family.ts ADDED
@@ -0,0 +1,14 @@
1
+ import { findVendorKeys } from "./detect.ts";
2
+
3
+ /** Shape classes for values without a vendor-specific family. */
4
+ export const FAMILIES = ["uuid", "prefixed-id", "identifier", "high-entropy"] as const;
5
+
6
+ // Vendor rules take precedence so audit counts retain their public shape label.
7
+ export function familyOf(value: string): string {
8
+ const vendor = findVendorKeys(value)[0];
9
+ if (vendor) return vendor.family;
10
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)) return "uuid";
11
+ if (/^[a-z][a-z0-9]{1,15}_[A-Za-z0-9]{8,}$/.test(value)) return "prefixed-id";
12
+ if (/^(?=.*[a-z])(?=.*[A-Z])[A-Za-z0-9]+$/.test(value)) return "identifier";
13
+ return "high-entropy";
14
+ }