@vieko/pi-redact 0.0.0-stage → 0.1.2
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 +22 -0
- package/NOTICE +14 -0
- package/README.md +72 -2
- package/extensions/pi-redact.ts +354 -0
- package/package.json +55 -4
- package/scripts/scrub-sessions.ts +209 -0
- package/src/detect.ts +1636 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vieko Franetovic
|
|
4
|
+
Copyright (c) 2026 Ray Amjad (src/detect.ts and test/*.vendor.mts)
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
package/NOTICE
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
src/detect.ts and test/*.vendor.mts are vendored from
|
|
2
|
+
https://github.com/ray-amjad/claude-code-mods (plugins/transcript-redactor)
|
|
3
|
+
at commit 812a5e91bf14f70f210d3f93b7a99f7737b5d38e, MIT, (c) 2026 Ray Amjad.
|
|
4
|
+
|
|
5
|
+
Local changes in src/detect.ts are marked with "pi-redact:" comments:
|
|
6
|
+
- keep pi session file names (<ISO time>Z_<uuid>.jsonl)
|
|
7
|
+
- keep the macOS per-user temp dir (/var/folders/xx/<random>/T/)
|
|
8
|
+
- name Linear (lin_api_) and Vercel AI Gateway (vck_) keys as vendor shapes
|
|
9
|
+
- export findVendorKeys() for the history scan
|
|
10
|
+
|
|
11
|
+
In test/detect.vendor.mts:
|
|
12
|
+
- the 5 s timing limit can be raised with REDACT_PERF_LIMIT_MS (CI uses 10 s)
|
|
13
|
+
|
|
14
|
+
Otherwise the vendored tests only had their import paths changed.
|
package/README.md
CHANGED
|
@@ -1,3 +1,73 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-redact
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A pi extension that keeps secrets out of session transcripts. A key that you paste, that a tool prints, or that sits in `AGENTS.md` becomes a stable placeholder such as `[REDACTED-SECRET-5c1e9a7b]`. The model works with the placeholder. When the model puts the placeholder in a local tool call, the extension puts the real value back first, so `curl -H "Authorization: Bearer [REDACTED-SECRET-5c1e9a7b]"` still authenticates.
|
|
4
|
+
|
|
5
|
+
A key that reaches the conversation stays there: pi sends it on every later turn, stores it in the session `.jsonl`, and replays it into forks, compaction and audits. The only fix is to keep it out.
|
|
6
|
+
|
|
7
|
+
Detection is vendored from [ray-amjad/claude-code-mods](https://github.com/ray-amjad/claude-code-mods/tree/main/plugins/transcript-redactor) (`src/detect.ts`, MIT): about 70 vendor key shapes, entropy tests, base64 that hides a key, passwords in URLs and CLI flags. See `NOTICE` for local changes.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
pi install npm:@vieko/pi-redact
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Where it hooks in
|
|
16
|
+
|
|
17
|
+
| pi event | What it does |
|
|
18
|
+
| :- | :- |
|
|
19
|
+
| `input` | Swaps keys in your prompt. `!cmd` runs as typed. |
|
|
20
|
+
| `before_agent_start` | Swaps keys in context files (secrets only). |
|
|
21
|
+
| `tool_call` | Puts real values back for local tools: `bash`, `powershell`, `write`, `edit`, `read`, `grep`, `find`, `ls`. Nested codemode calls are covered, because they go through the same event. |
|
|
22
|
+
| `tool_result` | Swaps keys in `content`, `structuredContent` and `details`. A value handed to a call is hidden again wherever its output prints it. A scan failure withholds the result. |
|
|
23
|
+
| `message_end` | Backstop for user, custom and tool-result messages before they persist. |
|
|
24
|
+
| `context` | Last net, per request: `!cmd` output, idle custom messages, and history from sessions recorded before the extension. |
|
|
25
|
+
|
|
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
|
+
|
|
28
|
+
## Tracking
|
|
29
|
+
|
|
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.
|
|
31
|
+
|
|
32
|
+
## Old sessions
|
|
33
|
+
|
|
34
|
+
`scripts/scrub-sessions.ts` rewrites vendor-shaped keys out of saved sessions and reports how many leaked, where, and when:
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
node scripts/scrub-sessions.ts --report leaks.json # dry run
|
|
38
|
+
node scripts/scrub-sessions.ts --write --report leaks.json # rewrite in place
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
It trusts only vendor shapes and private key blocks, because rewritten history cannot be restored. Each key becomes `[REDACTED-SECRET-<sha256/8>]`, the same in every file. Signed fields are never touched, files open in a process or modified in the last 15 minutes are skipped, and rewritten files keep their mtime.
|
|
42
|
+
|
|
43
|
+
## Commands
|
|
44
|
+
|
|
45
|
+
`/redact` shows the count and settings. `/redact off` and `/redact on` toggle it for the session.
|
|
46
|
+
|
|
47
|
+
## Settings
|
|
48
|
+
|
|
49
|
+
Optional `~/.pi/agent/pi-redact.json`, same keys as the upstream plugin:
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{ "pii": false, "allow": "", "allowPrefixes": "", "restoreTools": "bash,write,edit,read,grep,find,ls", "notify": true }
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`allowPrefixes` is added to the built-in public ids (`dpl_`, `prj_`, `team_`, `wrun_`, `gen_01`).
|
|
56
|
+
|
|
57
|
+
## Limits
|
|
58
|
+
|
|
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.
|
|
60
|
+
- 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
|
+
- Custom messages appended while pi is idle are persisted without `message_end`; the `context` net covers what the model sees.
|
|
62
|
+
- Sessions recorded before you install it keep their raw values on disk until you run `scripts/scrub-sessions.ts`.
|
|
63
|
+
|
|
64
|
+
## Develop
|
|
65
|
+
|
|
66
|
+
Releases: see `docs/releasing.md`.
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
npm install
|
|
71
|
+
npm run check # tsc + extension tests + vendored detector tests
|
|
72
|
+
pi -ne -e ./extensions/pi-redact.ts
|
|
73
|
+
```
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-redact
|
|
3
|
+
*
|
|
4
|
+
* Keeps secrets out of pi session transcripts. A key that reaches the
|
|
5
|
+
* conversation is sent on every later turn, stored in the session .jsonl, and
|
|
6
|
+
* replayed into forks and audits. This swaps each one for a stable
|
|
7
|
+
* placeholder (`[REDACTED-SECRET-1a2b3c4d]`) and puts the real value back on
|
|
8
|
+
* the way into a tool that runs on this machine, so commands keep working.
|
|
9
|
+
*
|
|
10
|
+
* Detection is vendored from ray-amjad/claude-code-mods (src/detect.ts). This
|
|
11
|
+
* file maps its hooks onto pi events:
|
|
12
|
+
*
|
|
13
|
+
* input prompt text, before the agent sees it
|
|
14
|
+
* before_agent_start AGENTS.md / CLAUDE.md context files (secrets only)
|
|
15
|
+
* tool_call placeholder -> real value for local tools
|
|
16
|
+
* tool_result real value -> placeholder in content/structured/details
|
|
17
|
+
* message_end backstop for user, custom and toolResult messages
|
|
18
|
+
* context request-local net over everything sent to the model
|
|
19
|
+
*
|
|
20
|
+
* Each value seen for the first time in a session is logged as a `pi-redact`
|
|
21
|
+
* custom entry (counts and channel only, never the value), so audits can
|
|
22
|
+
* tell how often and how secrets reach a session.
|
|
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.
|
|
26
|
+
*
|
|
27
|
+
* The footer shows `redact:N`: distinct values kept out of this session's
|
|
28
|
+
* transcript. It counts the `pi-redact` entries on the branch, so the
|
|
29
|
+
* number survives /reload and resume even though the vault does not.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
33
|
+
import { homedir } from "node:os";
|
|
34
|
+
import { join } from "node:path";
|
|
35
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
36
|
+
import {
|
|
37
|
+
type Config,
|
|
38
|
+
collect,
|
|
39
|
+
escapeLiteralTags,
|
|
40
|
+
findVendorKeys,
|
|
41
|
+
makeConfig,
|
|
42
|
+
mint,
|
|
43
|
+
restore,
|
|
44
|
+
scrubPii,
|
|
45
|
+
scrubSecrets,
|
|
46
|
+
type WalkState,
|
|
47
|
+
walk,
|
|
48
|
+
} from "../src/detect.ts";
|
|
49
|
+
|
|
50
|
+
const NAME = "pi-redact";
|
|
51
|
+
const CONFIG_PATH = join(homedir(), ".pi", "agent", "pi-redact.json");
|
|
52
|
+
|
|
53
|
+
/** Tools that run on this machine and may receive real values. */
|
|
54
|
+
export const DEFAULT_RESTORE_TOOLS = "bash,powershell,write,edit,read,grep,find,ls";
|
|
55
|
+
|
|
56
|
+
/** Ids that look random but are public handles in our daily tooling. */
|
|
57
|
+
export const DEFAULT_ALLOW_PREFIXES = "dpl_,prj_,team_,wrun_,gen_01";
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Tools whose result holds live placeholders from nested calls. Their output
|
|
61
|
+
* is not escaped, so the model can pass those placeholders on.
|
|
62
|
+
*/
|
|
63
|
+
export const LIVE_TAG_TOOLS = new Set(["codemode"]);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A command that writes into git history or posts to GitHub. Placeholders
|
|
67
|
+
* stay placeholders, so a commit message quoting one cannot carry the key.
|
|
68
|
+
*/
|
|
69
|
+
export const PUBLISHES =
|
|
70
|
+
/\bgit\b(?:[ \t]+-[cC][ \t]+\S+|[ \t]+--?[\w-]+(?:=\S+)?)*[ \t]+(?:commit|tag|notes|send-email)\b|\bgh[ \t]+(?:issue|pr|release|gist|discussion|api|repo[ \t]+(?:create|edit))\b/;
|
|
71
|
+
|
|
72
|
+
const WITHHELD = `${NAME} could not scan this result, so it was withheld. Narrow the output (head, grep, offset) and retry.`;
|
|
73
|
+
const LIVE_TAG = /\[REDACTED-SECRET-[0-9a-f]{8}\]/g;
|
|
74
|
+
|
|
75
|
+
type Part = { type: string; text?: string };
|
|
76
|
+
type AnyMessage = { role: string; [key: string]: unknown };
|
|
77
|
+
|
|
78
|
+
function loadOptions(path: string): Record<string, unknown> {
|
|
79
|
+
if (!existsSync(path)) return {};
|
|
80
|
+
try {
|
|
81
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
82
|
+
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
|
|
83
|
+
} catch {
|
|
84
|
+
return {};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function buildConfig(options: Record<string, unknown>): Config {
|
|
89
|
+
return makeConfig({
|
|
90
|
+
restoreTools: DEFAULT_RESTORE_TOOLS,
|
|
91
|
+
...options,
|
|
92
|
+
allowPrefixes: [DEFAULT_ALLOW_PREFIXES, options.allowPrefixes].filter(Boolean).join(","),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const countHidden = (tags: Set<string>): number => [...tags].filter((t) => !t.startsWith("[REDACTED-LITERAL-")).length;
|
|
97
|
+
|
|
98
|
+
/** Text parts of a content field (string or parts array), rewritten by `fn`. */
|
|
99
|
+
function mapContent<T>(content: T, fn: (s: string) => string): T {
|
|
100
|
+
if (typeof content === "string") return fn(content) as T;
|
|
101
|
+
if (!Array.isArray(content)) return content;
|
|
102
|
+
let changed = false;
|
|
103
|
+
const out = content.map((p: Part) => {
|
|
104
|
+
if (p?.type !== "text" || typeof p.text !== "string") return p;
|
|
105
|
+
const text = fn(p.text);
|
|
106
|
+
if (text === p.text) return p;
|
|
107
|
+
changed = true;
|
|
108
|
+
return { ...p, text };
|
|
109
|
+
});
|
|
110
|
+
return (changed ? out : content) as T;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface RedactOptions {
|
|
114
|
+
/** Overrides ~/.pi/agent/pi-redact.json, for tests. */
|
|
115
|
+
options?: Record<string, unknown>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function createRedactor(pi: ExtensionAPI, init: RedactOptions = {}): void {
|
|
119
|
+
const cfg = buildConfig(init.options ?? loadOptions(CONFIG_PATH));
|
|
120
|
+
let enabled = true;
|
|
121
|
+
let hiddenTotal = 0;
|
|
122
|
+
let kept = 0;
|
|
123
|
+
let ui: ExtensionContext | undefined;
|
|
124
|
+
const showStatus = (): void => {
|
|
125
|
+
if (!ui?.hasUI || kept <= 0) return;
|
|
126
|
+
try {
|
|
127
|
+
ui.ui.setStatus(NAME, ui.ui.theme.fg("dim", `redact:${kept}`));
|
|
128
|
+
} catch {
|
|
129
|
+
// The context went stale (session replaced); the next session_start resets it.
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
const handedById = new Map<string, Set<string>>();
|
|
133
|
+
const contextMemo = new WeakMap<object, AnyMessage>();
|
|
134
|
+
const tracked = new Set<string>();
|
|
135
|
+
|
|
136
|
+
// One entry per channel event that brought in values not seen before.
|
|
137
|
+
// `vendor` counts the ones with a known vendor shape (high confidence);
|
|
138
|
+
// the rest came from entropy rules and may be false positives.
|
|
139
|
+
const track = (tags: Set<string>, source: string): void => {
|
|
140
|
+
const fresh = [...tags].filter((t) => !t.startsWith("[REDACTED-LITERAL-") && !tracked.has(t));
|
|
141
|
+
if (fresh.length === 0) return;
|
|
142
|
+
for (const t of fresh) tracked.add(t);
|
|
143
|
+
kept += fresh.length;
|
|
144
|
+
showStatus();
|
|
145
|
+
const vendor = fresh.filter((t) => findVendorKeys(restore(t)).length > 0).length;
|
|
146
|
+
try {
|
|
147
|
+
pi.appendEntry(NAME, { source, values: fresh.length, vendor });
|
|
148
|
+
} catch {
|
|
149
|
+
// Tracking is best-effort; redaction already happened.
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const scrub = (text: string): string => {
|
|
154
|
+
let out = text;
|
|
155
|
+
if (cfg.secrets) out = scrubSecrets(out, cfg);
|
|
156
|
+
if (cfg.pii) out = scrubPii(out, cfg);
|
|
157
|
+
return out;
|
|
158
|
+
};
|
|
159
|
+
const secretsOnly = (text: string): string => (cfg.secrets ? scrubSecrets(text, cfg) : text);
|
|
160
|
+
|
|
161
|
+
const report = (ctx: ExtensionContext | undefined, n: number, where: string): void => {
|
|
162
|
+
if (n <= 0) return;
|
|
163
|
+
hiddenTotal += n;
|
|
164
|
+
if (!ctx?.hasUI) return;
|
|
165
|
+
if (cfg.notify && where) ctx.ui.notify(`${NAME}: hid ${n} value(s) from ${where}`, "info");
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// Every real value one call was handed, so its output hides them again
|
|
169
|
+
// even when no detector would fire on the bare value.
|
|
170
|
+
const rehide = (text: string, handed: Set<string> | undefined): string => {
|
|
171
|
+
if (!handed || handed.size === 0) return text;
|
|
172
|
+
let out = text;
|
|
173
|
+
for (const v of [...handed].sort((a, b) => b.length - a.length)) {
|
|
174
|
+
if (v.length >= 4 && out.includes(v)) out = out.split(v).join(mint("SECRET", v));
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
pi.on("session_start", (_event, ctx) => {
|
|
180
|
+
ui = ctx;
|
|
181
|
+
kept = 0;
|
|
182
|
+
for (const e of ctx.sessionManager.getBranch() as { type: string; customType?: string; data?: { values?: number } }[]) {
|
|
183
|
+
if (e.type === "custom" && e.customType === NAME) kept += e.data?.values ?? 0;
|
|
184
|
+
}
|
|
185
|
+
if (ctx.hasUI) ctx.ui.setStatus(NAME, undefined);
|
|
186
|
+
showStatus();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// 1. What the user types. `!cmd` is the user's own shell and runs as typed.
|
|
190
|
+
pi.on("input", (event, ctx) => {
|
|
191
|
+
if (!enabled || event.text.startsWith("!")) return { action: "continue" as const };
|
|
192
|
+
try {
|
|
193
|
+
const { value, tags } = collect(() => scrub(event.text));
|
|
194
|
+
if (tags.size === 0) return { action: "continue" as const };
|
|
195
|
+
report(ctx, countHidden(tags), "the prompt");
|
|
196
|
+
track(tags, `prompt:${event.source}`);
|
|
197
|
+
return { action: "transform" as const, text: value, images: event.images };
|
|
198
|
+
} catch {
|
|
199
|
+
if (ctx.hasUI) ctx.ui.notify(`${NAME} failed to scan the prompt, so it was not sent.`, "error");
|
|
200
|
+
return { action: "handled" as const };
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// 2. Context files (AGENTS.md and friends). Secrets only: the email in
|
|
205
|
+
// them is the user's own.
|
|
206
|
+
pi.on("before_agent_start", (event) => {
|
|
207
|
+
if (!enabled || !cfg.secrets) return;
|
|
208
|
+
const { tags } = collect(() => {
|
|
209
|
+
for (const file of event.systemPromptOptions.contextFiles) file.content = secretsOnly(file.content);
|
|
210
|
+
});
|
|
211
|
+
track(tags, "context-file");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// 3. Placeholders become real values for tools that run here. Fails
|
|
215
|
+
// closed: pi blocks the tool when a tool_call handler throws.
|
|
216
|
+
pi.on("tool_call", (event) => {
|
|
217
|
+
if (!enabled || !cfg.restore || !cfg.restoreTools.has(event.toolName)) return;
|
|
218
|
+
const input = event.input as Record<string, unknown>;
|
|
219
|
+
if (event.toolName === "bash" && typeof input.command === "string" && PUBLISHES.test(input.command)) return;
|
|
220
|
+
const handed = new Set<string>();
|
|
221
|
+
const restoreTracked = (s: string): string =>
|
|
222
|
+
restore(s) === s
|
|
223
|
+
? s
|
|
224
|
+
: s.replace(LIVE_TAG, (tag) => {
|
|
225
|
+
const v = restore(tag);
|
|
226
|
+
if (v !== tag) handed.add(v);
|
|
227
|
+
return v;
|
|
228
|
+
});
|
|
229
|
+
const restored = walk(input, restoreTracked) as Record<string, unknown>;
|
|
230
|
+
if (handed.size === 0) return;
|
|
231
|
+
for (const key of Object.keys(restored)) input[key] = restored[key];
|
|
232
|
+
handedById.set(event.toolCallId, handed);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// 4. Every tool result, nested codemode calls included. pi passes the raw
|
|
236
|
+
// result on when a handler throws, so failures withhold explicitly.
|
|
237
|
+
pi.on("tool_result", (event, ctx) => {
|
|
238
|
+
const handed = handedById.get(event.toolCallId);
|
|
239
|
+
handedById.delete(event.toolCallId);
|
|
240
|
+
if (!enabled) return;
|
|
241
|
+
try {
|
|
242
|
+
const escape = !LIVE_TAG_TOOLS.has(event.toolName);
|
|
243
|
+
const memo = new Map<string, string>();
|
|
244
|
+
const clean = (s: string): string => {
|
|
245
|
+
const done = memo.get(s);
|
|
246
|
+
if (done !== undefined) return done;
|
|
247
|
+
const out = rehide(scrub(escape ? escapeLiteralTags(s) : s), handed);
|
|
248
|
+
memo.set(s, out);
|
|
249
|
+
return out;
|
|
250
|
+
};
|
|
251
|
+
const detailsState: WalkState = { incomplete: false };
|
|
252
|
+
const structuredState: WalkState = { incomplete: false };
|
|
253
|
+
const { value, tags } = collect(() => ({
|
|
254
|
+
content: mapContent(event.content, clean),
|
|
255
|
+
structuredContent: event.structuredContent === undefined ? undefined : walk(event.structuredContent, clean, structuredState),
|
|
256
|
+
details: event.details === undefined ? undefined : walk(event.details, clean, detailsState),
|
|
257
|
+
}));
|
|
258
|
+
if (tags.size === 0) return;
|
|
259
|
+
report(ctx, countHidden(tags), "");
|
|
260
|
+
track(tags, `tool:${event.toolName}${event.parentToolCallId ? ":nested" : ""}`);
|
|
261
|
+
return {
|
|
262
|
+
content: value.content,
|
|
263
|
+
// Structured data the walker could not fully rewrite may still hold
|
|
264
|
+
// the value; scripts then fall back to the text content.
|
|
265
|
+
...(event.structuredContent === undefined || structuredState.incomplete
|
|
266
|
+
? {}
|
|
267
|
+
: { structuredContent: value.structuredContent as typeof event.structuredContent }),
|
|
268
|
+
details: detailsState.incomplete ? undefined : value.details,
|
|
269
|
+
};
|
|
270
|
+
} catch {
|
|
271
|
+
return { content: [{ type: "text" as const, text: WITHHELD }], details: undefined, isError: true };
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// 5. Backstop before persistence. No literal escaping here: tool_result
|
|
276
|
+
// already did it, and a second pass would turn live placeholders into
|
|
277
|
+
// quoted ones.
|
|
278
|
+
pi.on("message_end", (event) => {
|
|
279
|
+
if (!enabled) return;
|
|
280
|
+
const message = event.message as unknown as AnyMessage;
|
|
281
|
+
if (message.role !== "user" && message.role !== "custom" && message.role !== "toolResult") return;
|
|
282
|
+
try {
|
|
283
|
+
const { value: content, tags } = collect(() => mapContent(message.content, scrub));
|
|
284
|
+
if (content === message.content) return;
|
|
285
|
+
track(tags, `message:${message.role}`);
|
|
286
|
+
return { message: { ...message, content } as unknown as typeof event.message };
|
|
287
|
+
} catch {
|
|
288
|
+
return { message: { ...message, content: [{ type: "text", text: WITHHELD }] } as unknown as typeof event.message };
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// 6. The last net, request-local: bashExecution output, custom messages
|
|
293
|
+
// appended while idle, and history from sessions recorded before this
|
|
294
|
+
// extension. Same value -> same placeholder, so the cached prefix holds.
|
|
295
|
+
const cleanMessage = (message: AnyMessage): AnyMessage => {
|
|
296
|
+
const cached = contextMemo.get(message);
|
|
297
|
+
if (cached) return cached;
|
|
298
|
+
let out = message;
|
|
299
|
+
switch (message.role) {
|
|
300
|
+
case "user":
|
|
301
|
+
case "custom":
|
|
302
|
+
case "toolResult": {
|
|
303
|
+
const content = mapContent(message.content, scrub);
|
|
304
|
+
if (content !== message.content) out = { ...message, content };
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
case "bashExecution": {
|
|
308
|
+
const command = typeof message.command === "string" ? scrub(message.command) : message.command;
|
|
309
|
+
const output = typeof message.output === "string" ? scrub(message.output) : message.output;
|
|
310
|
+
if (command !== message.command || output !== message.output) out = { ...message, command, output };
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
case "compactionSummary":
|
|
314
|
+
case "branchSummary": {
|
|
315
|
+
const summary = typeof message.summary === "string" ? scrub(message.summary) : message.summary;
|
|
316
|
+
if (summary !== message.summary) out = { ...message, summary };
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
contextMemo.set(message, out);
|
|
321
|
+
return out;
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
pi.on("context", (event) => {
|
|
325
|
+
if (!enabled) return;
|
|
326
|
+
let changed = false;
|
|
327
|
+
const { value: messages, tags } = collect(() =>
|
|
328
|
+
event.messages.map((m) => {
|
|
329
|
+
const out = cleanMessage(m as unknown as AnyMessage);
|
|
330
|
+
if (out !== (m as unknown)) changed = true;
|
|
331
|
+
return out as unknown as typeof m;
|
|
332
|
+
}),
|
|
333
|
+
);
|
|
334
|
+
track(tags, "context");
|
|
335
|
+
return changed ? { messages } : undefined;
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
pi.registerCommand("redact", {
|
|
339
|
+
description: "pi-redact status. `/redact off` or `/redact on` toggles it for this session.",
|
|
340
|
+
handler: async (args, ctx) => {
|
|
341
|
+
const arg = args.trim();
|
|
342
|
+
if (arg === "off" || arg === "on") enabled = arg === "on";
|
|
343
|
+
const restoreList = cfg.restore ? [...cfg.restoreTools].join(", ") : "off";
|
|
344
|
+
ctx.ui.notify(
|
|
345
|
+
`${NAME} ${enabled ? "on" : "OFF"}: ${hiddenTotal} value(s) hidden this session. Restore into: ${restoreList}. PII: ${cfg.pii ? "on" : "off"}.`,
|
|
346
|
+
enabled ? "info" : "warning",
|
|
347
|
+
);
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export default function (pi: ExtensionAPI): void {
|
|
353
|
+
createRedactor(pi);
|
|
354
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,57 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vieko/pi-redact",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Keeps secrets out of pi session transcripts: stable placeholders in, real values back for local tools.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi-extension",
|
|
8
|
+
"secrets",
|
|
9
|
+
"redaction",
|
|
10
|
+
"privacy"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"author": "Vieko Franetovic",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/vieko/pi-redact.git"
|
|
17
|
+
},
|
|
18
|
+
"homepage": "https://github.com/vieko/pi-redact#readme",
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/vieko/pi-redact/issues"
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public",
|
|
24
|
+
"registry": "https://registry.npmjs.org"
|
|
25
|
+
},
|
|
26
|
+
"type": "module",
|
|
27
|
+
"files": [
|
|
28
|
+
"extensions",
|
|
29
|
+
"src",
|
|
30
|
+
"scripts",
|
|
31
|
+
"README.md",
|
|
32
|
+
"NOTICE",
|
|
33
|
+
"LICENSE"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
37
|
+
"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",
|
|
38
|
+
"test": "node --test \"test/*.test.ts\" && npm run test:vendor",
|
|
39
|
+
"check": "npm run typecheck && npm run test"
|
|
40
|
+
},
|
|
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
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
54
|
+
"@types/node": "^24.0.0",
|
|
55
|
+
"typescript": "^5.7.0"
|
|
56
|
+
}
|
|
57
|
+
}
|