Best-effort local substitution of specified personal and company terms, with restoration for tool execution and assistant reply display.

English | 中文
MyPrivacy replaces specified names and company information with aliases in model-input text supported by Claude Code, restores the originals before local tools run, and redacts the text returned by tools. It also attempts to restore aliases in supported assistant reply displays. The plugin uses best-effort replacement, has no command or email allowlist, and does not lock the session when replacement fails.
It only processes configured terms; it is not a general-purpose PII detector. Unconfigured information and unsupported content may remain unchanged. The source includes no personal mappings, and the plugin itself does not call a model or a network detection service. Replacement scope and limitations explains which content is protected and which content is restored.
The current plugin version is 0.5.0. Offline tool and terminal display checks have passed on macOS with Claude Code 2.1.290; other versions and platforms require fresh verification. See the verification guide for the checked scope.
The repository is named claude-my-privacy, the installation identifier is my-privacy@my-privacy-marketplace, and all commands use the /my-privacy- prefix.
<a id="安装并验证"></a>
Run the following commands from the repository root. First confirm that the claude command is available; see the contributing guide for additional development and offline-verification dependencies.
<a id="1-创建私有配置"></a>
Copy the fictional example outside the repository, then edit it with your own mappings. The copy command below will not overwrite an existing file.
mkdir -p "$HOME/.config/claude-my-privacy"
chmod 700 "$HOME/.config/claude-my-privacy"
if [ ! -e "$HOME/.config/claude-my-privacy/mappings.json" ]; then
cp -n examples/mappings.example.json "$HOME/.config/claude-my-privacy/mappings.json"
fi
chmod 600 "$HOME/.config/claude-my-privacy/mappings.json"
The example configuration uses 陈星河 → 陈瑞恩, chenxinghe → ryanchen, 云杉智科 → 海岚数科, and cedarbyte → harborwave. Enter real information only outside the repository. See the configuration guide for the format, case handling, and alias selection.
<a id="2-安装插件"></a>
Register this directory as a local marketplace, then install the plugin:
claude plugin marketplace add "$PWD" --scope user
claude plugin install my-privacy@my-privacy-marketplace --scope user
<a id="3-确认配置生效"></a>
Run these commands in a Claude Code session:
/reload-plugins
/my-privacy-status
When a nonempty configuration loads successfully, the status includes Configuration: loaded and a mappings count greater than zero. loaded; mappings: 0 means the term list is empty; unavailable or invalid means replacement is inactive. The troubleshooting guide provides recovery steps for each state. The status command does not display the term list, and the plugin has no persistent status-bar display.
<a id="日常使用"></a>
Enter requests and use tools as usual. For example, the example configuration restores staff42@harborwave.example on the model side to staff42@cedarbyte.example for actual tool execution. Supported assistant reply displays attempt to show originals without changing stored session text or model context. The plugin also attempts to restore execution arguments used by Write, Edit, or Bash to write files. See the full flow and display limits.
After editing private configuration, run /reload-plugins, then check /my-privacy-status. The plugin reads configuration only once per load. Use /my-privacy-audit for diagnostics; it retains only structural metadata, without commands or body text.
<a id="更新"></a>
After updating the source and incrementing the plugin version, run this command in a terminal:
claude plugin update my-privacy@my-privacy-marketplace --scope user
Then run /reload-plugins and /my-privacy-status in the active session. The source directory is the marketplace installation source; the running copy is in the Claude plugin cache. If you move the source directory, register the marketplace path again.
install.py additionally changes plugin ordering, disables a specified compaction plugin, and writes telemetry-related settings. Use the CLI commands above for routine installation; see the development guide for the script's side effects.
<a id="文档与开发"></a>
Choose documentation by task; you do not need to read the entire implementation:
| Task | Document |
|---|---|
| Configure terms in names, companies, domains, and email addresses | Configuration guide |
| Understand the differences between input, tool execution, files, and replies | Replacement behavior and limits |
| Diagnose paths, old blocking messages, and tool errors | Troubleshooting and audit |
| Locate implementation, loading, and storage responsibilities | Architecture |
| Change code, check documentation, and prepare a release | Contributing guide |
| Run isolated verification and consult historical probes | Verification guide |
The project does not yet have an open-source license; the plugin manifest is marked UNLICENSED.
hooks/register.js 237 lines1import { createPrivacy } from '../lib/privacy.js';
2import { redactAppend, rewriteToolResult, compactMessages } from '../lib/content.js';
3import { prepareTool } from '../lib/tool-policy.js';
4import { CONFIG_RELATIVE_PATH } from '../config.js';
5import { parseMappings } from '../lib/configuration.js';
6import { VERSION, AUDIT_PREFIX, MAX_AUDIT_EVENTS, buildAuditEvent, acceptEvents, formatAuditSummary } from '../lib/audit.js';
7
8let privacy = createPrivacy();
9let configurationState = 'not_loaded';
10let mappingCount = 0;
11let configurationLoading;
12let auditWriteFailures = 0;
13let auditSequence = 0;
14const auditInstance = Math.random().toString(36).slice(2, 12) || '0';
15const auditKeyPattern = /^privacy\.audit\.v1\.\d{16}\.[a-z0-9]+\.\d{8}$/;
16
17// Personal mappings live outside the source, installed plugin and Git history.
18// Load once per Mod instance; /reload-plugins picks up configuration changes.
19async function loadConfiguration($) {
20 let text;
21 try {
22 const configuredPath = await $.env.get('CLAUDE_MY_PRIVACY_CONFIG');
23 const home = configuredPath ? undefined : await $.env.get('HOME');
24 const path = configuredPath || (home ? home.replace(/\/+$/, '') + '/' + CONFIG_RELATIVE_PATH : undefined);
25 if (!path || !path.startsWith('/') || /[\u0000\r\n]/.test(path)) throw new Error('Configuration unavailable');
26 text = await $.fs.read(path);
27 } catch {
28 configurationState = 'unavailable';
29 await recordAudit($, { kind: 'filter_fallback', stage: 'configuration', code: 'CONFIG_UNAVAILABLE', executed: false });
30 return;
31 }
32 try {
33 const mappings = parseMappings(text);
34 const configured = createPrivacy({ mappings });
35 privacy = configured;
36 mappingCount = mappings.length;
37 configurationState = 'loaded';
38 } catch {
39 configurationState = 'invalid';
40 await recordAudit($, { kind: 'filter_fallback', stage: 'configuration', code: 'CONFIG_INVALID', executed: false });
41 }
42}
43
44async function ensurePrivacy($) {
45 if (configurationLoading) return configurationLoading;
46 configurationLoading = loadConfiguration($);
47 return configurationLoading;
48}
49
50// Keep only bounded structural metadata, never arguments, content or errors.
51async function recordAudit($, details) {
52 try {
53 const time = await $.clock.now();
54 const row = buildAuditEvent({ ...details, time });
55 const key = AUDIT_PREFIX + String(Math.floor(time)).padStart(16, '0') + '.' + auditInstance + '.' + String(++auditSequence).padStart(8, '0');
56 await $.store.set(key, row);
57 const keys = (await $.store.keys()).filter(key => auditKeyPattern.test(key)).sort();
58 for (const expired of keys.slice(0, Math.max(0, keys.length - MAX_AUDIT_EVENTS))) await $.store.delete(expired);
59 } catch { auditWriteFailures += 1; }
60}
61
62async function auditSummary($) {
63 try {
64 const keys = (await $.store.keys()).filter(key => auditKeyPattern.test(key)).sort().slice(-MAX_AUDIT_EVENTS);
65 const rows = [];
66 for (const key of keys) rows.push(await $.store.get(key));
67 return { text: formatAuditSummary(acceptEvents(rows), { writeFailures: auditWriteFailures }) };
68 } catch {
69 return { text: 'MyPrivacy audit is unavailable: local storage could not be read. Targeted replacement remains best effort.' };
70 }
71}
72
73// Filtering failures are diagnostic events, not permission decisions. Passing
74// the original preserves operation, but can leave a listed term unfiltered.
75async function redact($, value, stage) {
76 await ensurePrivacy($);
77 try { return privacy.redact(value); }
78 catch {
79 await recordAudit($, { kind: 'filter_fallback', stage, code: 'FILTER_FAILED', executed: 'unknown' });
80 return value;
81 }
82}
83
84async function privateContext($, event) {
85 const blocks = [];
86 for (const block of event.blocks) blocks.push({ ...block, text: await redact($, block.text, 'prompt.context') });
87 const copy = { ...event, blocks };
88 if (event.instructionFiles) copy.instructionFiles = await redact($, event.instructionFiles, 'prompt.context');
89 return copy;
90}
91
92async function localTool($, event, next) {
93 await ensurePrivacy($);
94 let ready = event;
95 try {
96 // The host's static analyzer requires host APIs to be called directly.
97 // Existence is an optional preference, never an execution prerequisite.
98 const existing = new Map();
99 if (event.tool !== 'Bash') {
100 for (const key of ['file_path', 'path', 'notebook_path']) {
101 if (typeof event[key] === 'string' && privacy.hasAliases(event[key])) {
102 try { existing.set(event[key], await $.fs.exists(event[key])); }
103 catch {
104 await recordAudit($, { kind: 'filter_fallback', stage: 'path-check', code: 'FILTER_FAILED', tool: event.tool, executed: false });
105 }
106 }
107 }
108 }
109 let translationFailed = false;
110 const toolPrivacy = {
111 restore(value) {
112 try { return privacy.restore(value); }
113 catch { translationFailed = true; return value; }
114 },
115 redact(value) {
116 try { return privacy.redact(value); }
117 catch { translationFailed = true; return value; }
118 },
119 };
120 ready = await prepareTool(event, toolPrivacy, async path => existing.get(path) === true);
121 if (translationFailed) await recordAudit($, { kind: 'filter_fallback', stage: 'prepare', code: 'FILTER_FAILED', tool: event.tool, executed: false, args: event });
122 } catch {
123 await recordAudit($, { kind: 'filter_fallback', stage: 'prepare', code: 'FILTER_FAILED', tool: event.tool, executed: false, args: event });
124 }
125
126 // Exactly one execution attempt. Never rerun a tool after an output/filter
127 // error; its side effects may already have occurred.
128 let answer;
129 try { answer = await next(ready); }
130 catch (error) {
131 await recordAudit($, { kind: 'tool_error', stage: 'execute', code: 'TOOL_ERROR', tool: event.tool, executed: 'unknown', args: event });
132 const message = typeof error?.message === 'string' ? error.message : 'The tool failed without an error message.';
133 return { deny: 'Underlying tool failed (execution may have occurred): ' + await redact($, message, 'result') };
134 }
135 if (answer?.deny !== undefined || answer?.isError === true) {
136 await recordAudit($, { kind: 'tool_error', stage: 'result', code: answer?.deny !== undefined ? 'DOWNSTREAM_DENIED' : 'TOOL_ERROR',
137 tool: event.tool, executed: 'unknown', args: event, answer });
138 }
139 try { return rewriteToolResult(answer, value => privacy.redact(value)); }
140 catch {
141 await recordAudit($, { kind: 'filter_fallback', stage: 'result', code: 'FILTER_FAILED', tool: event.tool, executed: 'unknown', args: event, answer });
142 return answer;
143 }
144}
145
146export function register(on) {
147 on('session.start', async ($, event, next) => {
148 await ensurePrivacy($);
149 await $.command.register({ name: 'my-privacy-status', description: 'Show targeted local replacement policy and its limits.' });
150 await $.command.register({ name: 'my-privacy-audit', description: 'Summarize local replacement fallbacks and native tool errors without private content.' });
151 await $.command.register({ name: 'my-privacy-recover', description: 'Show migration guidance for the older history guard.' });
152 $.ui.status(undefined);
153 return next(event);
154 });
155
156 on('command.run', { command: 'my-privacy-status' }, async ($) => {
157 await ensurePrivacy($);
158 return {
159 text: 'MyPrivacy v' + VERSION + ': targeted replacement, best effort. Configuration: ' + configurationState + '; mappings: ' + mappingCount + '. Configured terms are pseudonymized in supported text; aliases are restored in executable tool arguments and local assistant reply display. Display restoration changes only rendered reply text, not model context or stored session messages. No personal mappings are built into the plugin. Missing or invalid configuration leaves text unchanged; reload plugins after fixing it. Ordinary emails, phones, short identifiers and old PII placeholders pass unchanged. Model-facing Agent/Task/Skill arguments remain pseudonymized. No command whitelist, format-based denial, media denial, resumed-session denial or history lock. Shell syntax is preserved by literal substitution; native permissions still apply. Listed aliases are reserved vocabulary. Images, signed thinking, pinned tool-use history, saved system snapshots, raw queue records and file snapshots may remain unfiltered. Filtering failures pass the original and record metadata. Local audit retains up to ' + MAX_AUDIT_EVENTS + ' events; use /my-privacy-audit. Audit write failures in this instance: ' + auditWriteFailures + '.',
160 };
161 });
162
163 on('command.run', { command: 'my-privacy-audit' }, async ($) => auditSummary($));
164 on('command.run', { command: 'my-privacy-recover' }, async () => ({
165 text: 'Targeted replacement mode has no history lock. Continue normally; no history was rewritten and no tool was retried. Older placeholders may require re-reading their source.',
166 }));
167
168 on('ui.render', { component: 'AssistantMessage' }, async ($, event, next) => {
169 await ensurePrivacy($);
170 let ready = event;
171 try {
172 // Restore each visible block independently. The host keeps its native
173 // renderer and pinned onScreen viewport metadata. Session/model data
174 // remains untouched.
175 if (typeof event.props?.text === 'string') {
176 ready = { ...event, props: { ...event.props, text: privacy.restore(event.props.text) } };
177 }
178 } catch {
179 await recordAudit($, { kind: 'filter_fallback', stage: 'ui.render', code: 'FILTER_FAILED', executed: false });
180 }
181 // Downstream rendering is attempted once, outside the restoration catch.
182 return next(ready);
183 });
184
185 on('prompt.submit', async ($, event, next) => next({
186 ...event, text: await redact($, event.text, 'prompt.submit'),
187 ...(event.context ? { context: await redact($, event.context, 'prompt.submit') } : {}),
188 }));
189
190 on('prompt.context', async ($, event, next) => {
191 const answer = await next(await privateContext($, event));
192 return privateContext($, answer);
193 });
194
195 on('prompt.section', async ($, event, next) => {
196 const answer = await next({ ...event, text: await redact($, event.text, 'prompt.section') });
197 return { ...answer, text: await redact($, answer.text, 'prompt.section') };
198 });
199
200 on('prompt.attachment', async ($, event, next) => {
201 const answer = await next({ ...event, text: await redact($, event.text, 'prompt.attachment') });
202 return { ...answer, text: await redact($, answer.text, 'prompt.attachment') };
203 });
204
205 on('skill.prompt', async ($, event, next) => {
206 const answer = await next({ ...event, text: await redact($, event.text, 'skill.prompt') });
207 return { ...answer, text: await redact($, answer.text, 'skill.prompt') };
208 });
209
210 on('tool.call', async ($, event, next) => localTool($, event, next));
211
212 on('session.append', async ($, event, next) => {
213 await ensurePrivacy($);
214 let ready = event;
215 try { ready = redactAppend(event, value => privacy.redact(value)); }
216 catch {
217 await recordAudit($, { kind: 'filter_fallback', stage: 'session.append', code: 'FILTER_FAILED', executed: 'unknown' });
218 }
219 return next(ready);
220 });
221
222 on('session.compact', async ($, event, next) => {
223 await ensurePrivacy($);
224 let ready = event;
225 try {
226 ready = { ...event, messages: compactMessages(event.messages, value => privacy.redact(value)),
227 ...(event.instructions ? { instructions: privacy.redact(event.instructions) } : {}) };
228 } catch {
229 await recordAudit($, { kind: 'filter_fallback', stage: 'session.compact', code: 'FILTER_FAILED', executed: 'unknown' });
230 }
231 return next(ready);
232 });
233
234 on('session.send', async ($, event, next) => next({ ...event, text: await redact($, event.text, 'session.send') }));
235 // Intentionally no turn.step guard: immutable old content never locks turns.
236}
237lib/privacy.js 184 lines1/* Pure, local-only fixed substitutions. Fixed aliases are reserved protocol words:
2 * an unrelated person with an alias name cannot be distinguished automatically.
3 * Callers may restore executable tool arguments and display-only assistant reply
4 * text, never model-facing messages or persisted session data. No generic PII
5 * detection, short-name replacement, or session-dependent token mapping occurs.
6 */
7
8// Configuration is injected by the caller; a fresh installation is an identity
9// transform and carries no implicit names, organizations, or pseudonyms.
10const DEFAULT_MAPPINGS = [];
11const LIMITS = { maxChars: 8_000_000, maxNodes: 200_000, maxDepth: 80, maxMappings: 20_000 };
12// Legacy placeholders are opaque text. They are neither newly generated nor
13// resolved, and matching fixed terms inside a token must not corrupt it.
14const LEGACY_TOKEN_PATTERN = /(<PII_[A-Z][A-Z0-9_]*_[0-9]+>)/g;
15
16function fail(code) {
17 // Error messages deliberately contain no original input or configuration.
18 const error = new Error(`Privacy protection: ${code}`);
19 error.code = code;
20 throw error;
21}
22function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
23function caseLike(source, target) {
24 if (!/[A-Za-z]/.test(source)) return target;
25 if (source === source.toUpperCase()) return target.toUpperCase();
26 if (source === source.toLowerCase()) return target.toLowerCase();
27 if (source[0] === source[0].toUpperCase() && source.slice(1) === source.slice(1).toLowerCase()) {
28 return target[0].toUpperCase() + target.slice(1).toLowerCase();
29 }
30 return [...target].map((char, index) => /[A-Z]/.test(source[index] || "") ? char.toUpperCase() : char.toLowerCase()).join("");
31}
32
33function dataRecord(value, allowedKeys) {
34 if (!value || typeof value !== "object" || Array.isArray(value)) fail("INVALID_CONFIG");
35 const proto = Object.getPrototypeOf(value);
36 if (proto !== Object.prototype && proto !== null) fail("INVALID_CONFIG");
37 const result = {};
38 for (const key of Object.keys(value)) {
39 if (!allowedKeys.includes(key)) fail("INVALID_CONFIG");
40 const descriptor = Object.getOwnPropertyDescriptor(value, key);
41 if (!descriptor || !Object.prototype.hasOwnProperty.call(descriptor, "value")) fail("INVALID_CONFIG");
42 Object.defineProperty(result, key, { value: descriptor.value, enumerable: true });
43 }
44 return result;
45}
46
47function normalizeMapping(value) {
48 const entry = dataRecord(value, ["from", "to", "casePairs"]);
49 if (typeof entry.from !== "string" || typeof entry.to !== "string" || !entry.from.trim() || !entry.to.trim() ||
50 entry.from.length > 500 || entry.to.length > 500 || entry.from.toLowerCase() === entry.to.toLowerCase() ||
51 /<PII_/.test(entry.from + entry.to)) fail("INVALID_CONFIG");
52 if (entry.casePairs !== undefined && !Array.isArray(entry.casePairs)) fail("INVALID_CONFIG");
53 if ((entry.casePairs || []).length > 32) fail("MAPPING_LIMIT");
54 const fromLower = entry.from.toLowerCase(), toLower = entry.to.toLowerCase();
55 const forwardCases = new Map(), reverseCases = new Map();
56 for (const value of entry.casePairs || []) {
57 const pair = dataRecord(value, ["from", "to"]);
58 if (typeof pair.from !== "string" || typeof pair.to !== "string" || pair.from.length > 500 || pair.to.length > 500 ||
59 pair.from.toLowerCase() !== fromLower || pair.to.toLowerCase() !== toLower) fail("INVALID_CONFIG");
60 if (forwardCases.has(pair.from) || reverseCases.has(pair.to)) fail("AMBIGUOUS_CONFIG");
61 forwardCases.set(pair.from, pair.to);
62 reverseCases.set(pair.to, pair.from);
63 }
64 return { from: entry.from, to: entry.to, fromLower, toLower, forwardCases, reverseCases };
65}
66
67export function createPrivacy(options = {}) {
68 options = dataRecord(options, [...Object.keys(LIMITS), "mappings", "extraMappings", "namespace"]);
69 const limits = { ...LIMITS };
70 for (const key of Object.keys(LIMITS)) {
71 if (options[key] !== undefined) {
72 if (!Number.isSafeInteger(options[key]) || options[key] < 1 || options[key] > LIMITS[key]) fail("INVALID_CONFIG");
73 limits[key] = options[key];
74 }
75 }
76 if (options.mappings !== undefined && !Array.isArray(options.mappings)) fail("INVALID_CONFIG");
77 if (options.extraMappings !== undefined && !Array.isArray(options.extraMappings)) fail("INVALID_CONFIG");
78 if ((options.mappings || []).length + (options.extraMappings || []).length > 256) fail("MAPPING_LIMIT");
79 if (options.namespace !== undefined && (typeof options.namespace !== "string" || !/^[A-Z0-9]{4,32}$/.test(options.namespace))) fail("INVALID_CONFIG");
80 // namespace remains an accepted compatibility option; no tokens are created.
81 const definitions = [...(options.mappings || DEFAULT_MAPPINGS), ...(options.extraMappings || [])].map(normalizeMapping);
82 if (definitions.length > limits.maxMappings) fail("MAPPING_LIMIT");
83 for (let left = 0; left < definitions.length; left++) {
84 for (let right = 0; right < definitions.length; right++) {
85 const a = definitions[left], b = definitions[right];
86 if (left !== right && (a.fromLower.includes(b.fromLower) || a.toLower.includes(b.toLower))) fail("AMBIGUOUS_CONFIG");
87 if (a.toLower.includes(b.fromLower) || b.fromLower.includes(a.toLower)) fail("AMBIGUOUS_CONFIG");
88 }
89 }
90 const originals = new Map(definitions.map(item => [item.fromLower, item]));
91 const aliases = new Map(definitions.map(item => [item.toLower, item]));
92 const forwardPattern = definitions.length ? new RegExp(definitions.map(item => escapeRegExp(item.from)).sort((a, b) => b.length - a.length).join("|"), "gi") : null;
93 const reversePattern = definitions.length ? new RegExp(definitions.map(item => escapeRegExp(item.to)).sort((a, b) => b.length - a.length).join("|"), "gi") : null;
94 function fixed(text, reverse = false, detectOnly = false) {
95 if (!definitions.length) return detectOnly ? false : text;
96 let detected = false;
97 const pattern = reverse ? reversePattern : forwardPattern;
98 const lookup = reverse ? aliases : originals;
99 const result = text.split(LEGACY_TOKEN_PATTERN).map((segment, index) => {
100 if (index % 2 === 1) return segment;
101 return segment.replace(pattern, match => {
102 detected = true;
103 if (detectOnly) return match;
104 const definition = lookup.get(match.toLowerCase());
105 const casePairs = reverse ? definition.reverseCases : definition.forwardCases;
106 return casePairs.get(match) || caseLike(match, reverse ? definition.from : definition.to);
107 });
108 }).join("");
109 return detectOnly ? detected : result;
110 }
111 function walk(value, transform, inspect = false) {
112 let nodes = 0, chars = 0, outputChars = 0, found = false;
113 const ancestors = new Set();
114 const visitString = input => {
115 chars += input.length;
116 if (chars > limits.maxChars) fail("SIZE_LIMIT");
117 const output = transform(input);
118 if (inspect) { found = found || output; return input; }
119 outputChars += output.length;
120 if (outputChars > limits.maxChars) fail("SIZE_LIMIT");
121 return output;
122 };
123 const visit = (input, depth) => {
124 if (++nodes > limits.maxNodes) fail("NODE_LIMIT");
125 if (depth > limits.maxDepth) fail("DEPTH_LIMIT");
126 if (typeof input === "string") return visitString(input);
127 if (input === null || typeof input === "boolean" || (typeof input === "number" && Number.isFinite(input))) return input;
128 if (!input || typeof input !== "object") fail("INVALID_JSON");
129 if (ancestors.has(input)) fail("CYCLIC_JSON");
130 const proto = Object.getPrototypeOf(input);
131 if (!Array.isArray(input) && proto !== Object.prototype && proto !== null) fail("INVALID_JSON");
132 ancestors.add(input);
133 let output;
134 if (Array.isArray(input)) {
135 if (input.length + nodes > limits.maxNodes) fail("NODE_LIMIT");
136 output = [];
137 for (let index = 0; index < input.length; index++) {
138 const descriptor = Object.getOwnPropertyDescriptor(input, String(index));
139 if (!descriptor || !Object.prototype.hasOwnProperty.call(descriptor, "value")) fail("INVALID_JSON");
140 output.push(visit(descriptor.value, depth + 1));
141 }
142 } else {
143 output = {};
144 for (const key of Object.keys(input)) {
145 const descriptor = Object.getOwnPropertyDescriptor(input, key);
146 if (!descriptor || !Object.prototype.hasOwnProperty.call(descriptor, "value")) fail("INVALID_JSON");
147 // Host tool events can carry optional own data fields as undefined.
148 // Match JSON object serialization without evaluating accessors or
149 // relaxing validation for root values and array entries.
150 if (descriptor.value === undefined) continue;
151 const nextKey = visitString(key);
152 if (Object.prototype.hasOwnProperty.call(output, nextKey)) fail("KEY_COLLISION");
153 Object.defineProperty(output, nextKey, { value: visit(descriptor.value, depth + 1), enumerable: true, configurable: true, writable: true });
154 }
155 }
156 ancestors.delete(input);
157 return output;
158 };
159 const output = visit(value, 0);
160 return inspect ? Boolean(found) : output;
161 }
162 function aliasOption(options) {
163 if (!options || typeof options !== "object" || Array.isArray(options) || Object.keys(options).some(key => key !== "shortAliases") || (options.shortAliases !== undefined && typeof options.shortAliases !== "boolean")) fail("INVALID_CONFIG");
164 return options.shortAliases === true;
165 }
166 return Object.freeze({
167 redact(value) { return walk(value, text => fixed(text)); },
168 restore(value, restoreOptions = {}) {
169 aliasOption(restoreOptions);
170 return walk(value, text => fixed(text, true));
171 },
172 containsProtected(value) { return walk(value, text => fixed(text, false, true), true); },
173 hasAliases(value, aliasOptions = {}) {
174 aliasOption(aliasOptions);
175 return walk(value, text => fixed(text, true, true), true);
176 },
177 hasUnknownTokens(value) {
178 // Validate the same supported JSON shapes, but legacy tokens never deny.
179 return walk(value, () => false, true);
180 },
181 stats() { return { tokens: 0, fixedCaseVariants: 0, types: { EMAIL: 0, PHONE: 0, ID: 0, CARD: 0 } }; },
182 });
183}
184lib/content.js 77 lines1// Protocol identity, tool-use arguments, and signed thinking are not rewritable
2// at session.append. Do not corrupt those fields while scrubbing visible text.
3export function sanitizeBlocks(blocks, redact) {
4 if (!Array.isArray(blocks)) throw new Error('Invalid content blocks');
5 return blocks.flatMap(block => {
6 if (block.type === 'text') return [{ ...block, text: redact(block.text) }];
7 if (block.type === 'tool_result') {
8 const content = typeof block.content === 'string'
9 ? redact(block.content)
10 : Array.isArray(block.content) ? sanitizeBlocks(block.content, redact) : block.content;
11 return [{ ...block, content }];
12 }
13 // Unsupported media and signed/protocol blocks are opaque, not denied.
14 return [block];
15 });
16}
17
18export function redactAppend(event, redact) {
19 return { ...event, message: { ...event.message, content: sanitizeBlocks(event.message.content, redact) } };
20}
21
22export function containsMedia(value, depth = 0) {
23 if (depth > 80) throw new Error('Content too deeply nested');
24 if (!value || typeof value !== 'object') return false;
25 if (['image', 'document', 'audio', 'image_url'].includes(value.type)) return true;
26 if (value.isImage === true || typeof value.base64 === 'string') return true;
27 return Object.values(value).some(child => containsMedia(child, depth + 1));
28}
29
30export function rewriteToolResult(answer, redact) {
31 if (!answer || typeof answer !== 'object' || Array.isArray(answer)) throw new Error('Invalid tool result envelope');
32 if (answer.deny !== undefined) return { deny: redact(answer.deny) };
33 // Core errors carry text/undefined, not the tool's typed output record.
34 // Without ref, a replacement result is schema-validated (Bash requires an
35 // object). Use the error channel instead; never replay core's raw messages.
36 if (answer.isError === true) {
37 const value = typeof answer.text === 'string' ? answer.text
38 : typeof answer.result === 'string' ? answer.result : 'The tool failed without an error message.';
39 return { deny: 'Underlying tool reported an error (execution may have occurred): ' + redact(value) };
40 }
41 // Never carry ref: it instructs the host to reuse the unmodified raw result.
42 const result = { result: containsMedia(answer.result) ? redactAroundMedia(answer.result, redact) : redact(answer.result) };
43 if (answer.isError !== undefined) result.isError = answer.isError;
44 if (answer.context) result.context = redact(answer.context);
45 return result;
46}
47
48function redactAroundMedia(value, redact, depth = 0) {
49 if (depth > 80) throw new Error('Content too deeply nested');
50 if (value === undefined) return value;
51 if (!value || typeof value !== 'object') return redact(value);
52 // Never apply literal name substitution inside opaque binary/base64 data.
53 if (['image', 'document', 'audio', 'image_url'].includes(value.type) || value.isImage === true || typeof value.base64 === 'string') return value;
54 if (Array.isArray(value)) return value.map(item => redactAroundMedia(item, redact, depth + 1));
55 return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redactAroundMedia(item, redact, depth + 1)]));
56}
57
58export function compactMessages(messages, redact) {
59 return messages.map(message => {
60 // A handle replays the original host-side message, ignoring rewritten text.
61 const { handle, ...copy } = message;
62 const filtered = { ...copy, text: redact(copy.text) };
63 const scrubTool = tool => {
64 const out = { ...tool };
65 for (const key of ['input', 'result', 'text']) {
66 if (tool[key] !== undefined) out[key] = redactAroundMedia(tool[key], redact);
67 }
68 // Preserve tool/tool_use_id and all other routing and protocol fields.
69 return out;
70 };
71 if (copy.toolUses) filtered.toolUses = copy.toolUses.map(scrubTool);
72 if (copy.toolResults) filtered.toolResults = copy.toolResults.map(scrubTool);
73 // Keep opaque host content when no supported text needed replacement.
74 return JSON.stringify(copy) === JSON.stringify(filtered) ? message : filtered;
75 });
76}
77lib/tool-policy.js 74 lines1// This adapter translates configured names; it does not approve, parse or
2// restrict commands. Native Claude Code permissions remain responsible for
3// execution. Object keys and the host's routing metadata are never translated.
4const PATH_KEYS = ['file_path', 'path', 'notebook_path'];
5const HOST_KEYS = new Set(['tool', 'tool_use_id', 'agentId']);
6const MODEL_TOOLS = new Set(['Agent', 'Task', 'Skill']);
7
8function translateString(value, translate) {
9 try { return translate(value); }
10 catch { return value; }
11}
12
13function translateValues(value, translate, seen = new WeakMap()) {
14 if (typeof value === 'string') return translateString(value, translate);
15 if (!value || typeof value !== 'object') return value;
16 if (seen.has(value)) return seen.get(value);
17 const prototype = Object.getPrototypeOf(value);
18 if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) return value;
19 const copy = Array.isArray(value) ? [] : Object.create(prototype);
20 seen.set(value, copy);
21 for (const key of Object.keys(value)) {
22 const descriptor = Object.getOwnPropertyDescriptor(value, key);
23 if (!descriptor || !Object.hasOwn(descriptor, 'value')) continue;
24 Object.defineProperty(copy, key, { ...descriptor, value: translateValues(descriptor.value, translate, seen) });
25 }
26 return copy;
27}
28
29function translateArguments(event, translate) {
30 const copy = { ...event };
31 for (const key of Object.keys(copy)) {
32 if (!HOST_KEYS.has(key)) copy[key] = translateValues(copy[key], translate);
33 }
34 return copy;
35}
36
37export async function resolvePath(path, privacy, exists, _allowCreate = false, _realPath) {
38 if (typeof path !== 'string') return path;
39 const restored = translateString(path, value => privacy.restore(value));
40 if (restored === path) return path;
41 // A usable literal alias is the user's selected target, even when the
42 // original spelling also exists elsewhere. Missing/unavailable checks never
43 // prevent the native tool from trying the deterministically restored path.
44 if (typeof exists === 'function') {
45 try { if (await exists(path)) return path; }
46 catch { /* Best effort only: native tools report filesystem errors. */ }
47 }
48 return restored;
49}
50
51export async function prepareBash(event, privacy, _exists, _realPath) {
52 // Translate the complete string. Do not parse or requote shell text: its
53 // quoting, separators, heredocs, variables and program syntax stay intact.
54 return translateArguments(event, value => privacy.restore(value));
55}
56
57export async function prepareTool(event, privacy, exists, realPath) {
58 if (MODEL_TOOLS.has(event.tool)) {
59 // These arguments enter another model context, so keep them pseudonymous.
60 return translateArguments(event, value => privacy.redact(value));
61 }
62 if (event.tool === 'Bash') return prepareBash(event, privacy);
63 const copy = translateArguments(event, value => privacy.restore(value));
64 if (event.tool === 'WebFetch' && typeof event.prompt === 'string') {
65 // Fetch destinations are execution arguments; its summary instructions
66 // are another model's input and keep the same pseudonymous vocabulary.
67 copy.prompt = translateString(event.prompt, value => privacy.redact(value));
68 }
69 for (const key of PATH_KEYS) {
70 if (typeof event[key] === 'string') copy[key] = await resolvePath(event[key], privacy, exists, false, realPath);
71 }
72 return copy;
73}
74config.js 4 lines1// Relative to HOME. Override with the absolute CLAUDE_MY_PRIVACY_CONFIG path.
2// Personal mappings belong in this external JSON file, never in source code.
3export const CONFIG_RELATIVE_PATH = '.config/claude-my-privacy/mappings.json';
4lib/configuration.js 31 lines1import { createPrivacy } from './privacy.js';
2
3export const MAX_CONFIG_CHARS = 262144;
4
5function fail(code) {
6 const error = new Error(`Privacy configuration: ${code}`);
7 error.code = code;
8 throw error;
9}
10
11// Pure parser: filesystem location, permissions, and missing-file behavior are
12// owned by the host integration. Validation errors never echo input contents.
13export function parseMappings(text, options = {}) {
14 if (!options || typeof options !== 'object' || Array.isArray(options) ||
15 Object.keys(options).some(key => key !== 'maxChars')) fail('INVALID_CONFIG');
16 const limit = options.maxChars === undefined ? MAX_CONFIG_CHARS : options.maxChars;
17 if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_CONFIG_CHARS || typeof text !== 'string') fail('INVALID_CONFIG');
18 if (text.length > limit) fail('SIZE_LIMIT');
19 let value;
20 try { value = JSON.parse(text); }
21 catch { fail('INVALID_JSON'); }
22 if (!value || typeof value !== 'object' || Array.isArray(value) ||
23 Object.keys(value).some(key => key !== 'mappings') || !Array.isArray(value.mappings)) fail('INVALID_CONFIG');
24 createPrivacy({ mappings: value.mappings });
25 // Reproject to this documented schema instead of exporting arbitrary JSON.
26 return value.mappings.map(entry => ({
27 from: entry.from, to: entry.to,
28 ...(entry.casePairs === undefined ? {} : { casePairs: entry.casePairs.map(pair => ({ from: pair.from, to: pair.to })) }),
29 }));
30}
31lib/audit.js 190 lines1// Audit storage deliberately contains structural metadata only. Redaction is
2// insufficient here: unknown credentials and company names must never reach it.
3export const VERSION = '0.5.0';
4export const MAX_AUDIT_EVENTS = 300;
5export const AUDIT_PREFIX = 'privacy.audit.v1.';
6
7const VERSIONS = new Set(['0.1.1', '0.1.2', '0.1.3', '0.1.4', '0.1.5', '0.1.6', '0.1.7', '0.1.8', '0.2.0', '0.3.0', '0.4.0', VERSION]);
8const KINDS = new Set(['privacy_block', 'tool_error', 'plugin_error', 'recovery', 'filter_fallback']);
9const STAGES = new Set([
10 'arguments', 'prepare', 'path-check', 'preflight', 'execute', 'result', 'hook',
11 'input', 'context', 'skill', 'session', 'request', 'history', 'recovery', 'configuration',
12 'prompt.submit', 'prompt.context', 'prompt.section', 'prompt.attachment',
13 'skill.prompt', 'session.start', 'session.append', 'session.compact',
14 'session.send', 'turn.step', 'ui.render', 'classic.SessionStart',
15]);
16const CODES = new Set([
17 'INVALID_JSON', 'CYCLIC_JSON', 'KEY_COLLISION', 'SIZE_LIMIT', 'DEPTH_LIMIT',
18 'NODE_LIMIT', 'MAPPING_LIMIT', 'AMBIGUOUS_ALIAS_CASE', 'UNKNOWN_PII_TOKEN',
19 'AMBIGUOUS_PATH', 'AMBIGUOUS_NEW_PATH', 'PATH_NOT_FOUND', 'SHELL_PII_TOKEN',
20 'COMPLEX_ALIAS_COMMAND', 'UNSUPPORTED_ALIAS_COMMAND', 'EXTERNAL_COMMAND_OPTION',
21 'RELATIVE_ALIAS_AFTER_CD', 'PATH_LOOKUP_LIMIT', 'SHORT_IDENTIFIER_COLLISION',
22 'EDIT_MATCH_AMBIGUOUS', 'EDIT_MATCH_NOT_FOUND', 'RAW_PRIVATE_ARGUMENTS',
23 'TOOL_ERROR', 'DOWNSTREAM_DENIED', 'NON_TEXT_RESULT', 'RESUMED_SESSION',
24 'NON_TEXT_INPUT', 'REQUEST_UNFILTERED', 'HISTORY_LIMIT', 'REQUEST_CHECK_FAILED',
25 'FILTER_FAILED', 'OUTPUT_SHAPE', 'INTERNAL_ERROR', 'RECOVERY_SUCCEEDED', 'RECOVERY_FAILED', 'CONFIG_UNAVAILABLE', 'CONFIG_INVALID',
26]);
27const TOOLS = new Set([
28 'Bash', 'Read', 'Write', 'Edit', 'MultiEdit', 'Glob', 'Grep', 'WebFetch',
29 'WebSearch', 'NotebookEdit', 'Task', 'Agent', 'Skill', 'ToolSearch', 'TodoWrite',
30 'AskUserQuestion', 'LSP', 'ExitPlanMode', 'EnterPlanMode',
31]);
32const TYPES = new Set(['missing', 'null', 'array', 'object', 'string', 'number', 'boolean', 'other']);
33const LENGTHS = new Set(['none', '1-80', '81-400', '401-2000', '2001+']);
34const CD_STYLES = new Set(['none', '&&', ';']);
35const EXECUTIONS = new Set(['ran', 'not_run', 'unknown']);
36
37// Avoid invoking persisted getters or methods, even when given malformed rows.
38function field(object, name) {
39 if (!object || typeof object !== 'object') return undefined;
40 try {
41 const descriptor = Object.getOwnPropertyDescriptor(object, name);
42 return descriptor && 'value' in descriptor ? descriptor.value : undefined;
43 } catch { return undefined; }
44}
45
46function allowed(value, values, fallback = 'OTHER') {
47 return typeof value === 'string' && values.has(value) ? value : fallback;
48}
49
50function valueType(value) {
51 if (value === undefined) return 'missing';
52 if (value === null) return 'null';
53 try { if (Array.isArray(value)) return 'array'; } catch { return 'other'; }
54 return TYPES.has(typeof value) ? typeof value : 'other';
55}
56
57function auditTime(value) {
58 let milliseconds;
59 if (typeof value === 'number' && Number.isFinite(value)) milliseconds = value;
60 else if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)) {
61 milliseconds = Date.parse(value);
62 }
63 // Do not copy arbitrary strings or numbers into the log under a date label.
64 if (!Number.isFinite(milliseconds) || milliseconds < 946684800000 || milliseconds >= 4102444800000) return null;
65 return new Date(milliseconds).toISOString();
66}
67
68function commandLength(command) {
69 if (!command.length) return 'none';
70 if (command.length <= 80) return '1-80';
71 if (command.length <= 400) return '81-400';
72 return command.length <= 2000 ? '401-2000' : '2001+';
73}
74
75function leadingCd(command) {
76 // This is diagnostic classification only, not command approval or parsing.
77 // This never controls whether a shell command may execute.
78 const match = /^[ \t]*cd[ \t]+(?:--[ \t]+)?(?:'\/[^'\r\n]*'|"\/[^"\r\n]*"|\/[^\s;&|]+)[ \t]*(&&(?![&|])|;(?![;&]))/.exec(command);
79 return match ? match[1] : 'none';
80}
81
82function inputShape(args) {
83 const value = field(args, 'command');
84 const command = typeof value === 'string' ? value : '';
85 return {
86 type: valueType(args), commandLength: commandLength(command), leadingCd: leadingCd(command),
87 multiline: /[\r\n]/.test(command), heredoc: command.includes('<<'),
88 shellVariable: /\$(?:[A-Za-z_]|\{)/.test(command),
89 };
90}
91
92function outputShape(answer) {
93 return {
94 type: valueType(answer), resultType: valueType(field(answer, 'result')),
95 isError: field(answer, 'isError') === true,
96 hasDenial: typeof field(answer, 'deny') === 'string',
97 };
98}
99
100export function buildAuditEvent(details = {}) {
101 const executed = field(details, 'executed');
102 return {
103 schema: 1, version: VERSION, time: auditTime(field(details, 'time')),
104 kind: allowed(field(details, 'kind'), KINDS),
105 stage: allowed(field(details, 'stage'), STAGES),
106 code: allowed(field(details, 'code'), CODES, 'INTERNAL_ERROR'),
107 tool: allowed(field(details, 'tool'), TOOLS),
108 execution: executed === true ? 'ran' : executed === false ? 'not_run' : 'unknown',
109 input: inputShape(field(details, 'args')),
110 output: outputShape(field(details, 'answer')),
111 };
112}
113
114function storedEvent(row) {
115 if (field(row, 'schema') !== 1) return null;
116 const input = field(row, 'input'), output = field(row, 'output');
117 return {
118 schema: 1, version: allowed(field(row, 'version'), VERSIONS), time: auditTime(field(row, 'time')),
119 kind: allowed(field(row, 'kind'), KINDS), stage: allowed(field(row, 'stage'), STAGES),
120 code: allowed(field(row, 'code'), CODES, 'INTERNAL_ERROR'), tool: allowed(field(row, 'tool'), TOOLS),
121 execution: allowed(field(row, 'execution'), EXECUTIONS, 'unknown'),
122 input: {
123 type: allowed(field(input, 'type'), TYPES, 'other'),
124 commandLength: allowed(field(input, 'commandLength'), LENGTHS, 'none'),
125 leadingCd: allowed(field(input, 'leadingCd'), CD_STYLES, 'none'),
126 multiline: field(input, 'multiline') === true, heredoc: field(input, 'heredoc') === true,
127 shellVariable: field(input, 'shellVariable') === true,
128 },
129 output: {
130 type: allowed(field(output, 'type'), TYPES, 'other'),
131 resultType: allowed(field(output, 'resultType'), TYPES, 'other'),
132 isError: field(output, 'isError') === true, hasDenial: field(output, 'hasDenial') === true,
133 },
134 };
135}
136
137export function acceptEvents(rows) {
138 try {
139 if (!Array.isArray(rows)) return [];
140 const accepted = [];
141 // Each row is reprojected; never stringify or spread storage contents.
142 for (let index = Math.max(0, rows.length - MAX_AUDIT_EVENTS); index < rows.length; index += 1) {
143 const event = storedEvent(field(rows, String(index)));
144 if (event) accepted.push(event);
145 }
146 return accepted.sort((a, b) => (a.time || '').localeCompare(b.time || ''));
147 } catch { return []; }
148}
149
150function countLines(events, property) {
151 const counts = new Map();
152 for (const event of events) counts.set(event[property], (counts.get(event[property]) || 0) + 1);
153 return [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([name, count]) => `${name}=${count}`).join(', ') || 'none';
154}
155
156function recommendations(events) {
157 const codes = new Set(events.map(event => event.code));
158 const advice = [];
159 if (events.some(event => event.kind === 'privacy_block' || event.kind === 'recovery')) advice.push('Retained blocks/recovery events belong to the older policy. Version 0.2.0 uses targeted replacement with no command whitelist or history lock; reload plugins in an older running session.');
160 if (events.some(event => event.kind === 'filter_fallback')) advice.push('A replacement could not complete; the original was passed through without retrying a tool. Reproduce with synthetic data to improve replacement coverage.');
161 if (events.some(event => event.kind === 'tool_error')) advice.push('Native tool failures are counted separately from privacy blocks retained from older versions. Inspect the native failure; execution may already have occurred.');
162 if (events.some(event => event.kind === 'plugin_error') || ['INTERNAL_ERROR', 'OUTPUT_SHAPE', 'FILTER_FAILED', 'REQUEST_CHECK_FAILED'].some(code => codes.has(code))) advice.push('Reproduce the plugin failure using a synthetic fixture and verify the host output schema.');
163 if (!advice.length) advice.push('No specific optimization is suggested by the retained metadata.');
164 return advice;
165}
166
167export function formatAuditSummary(rows, options = {}) {
168 const events = acceptEvents(rows);
169 const failures = field(options, 'writeFailures');
170 const writeFailures = Number.isSafeInteger(failures) && failures >= 0 ? Math.min(failures, 1000000) : 0;
171 const recent = events.slice(-10).map(event => {
172 const structure = event.tool === 'Bash'
173 ? `; command=${event.input.commandLength}, cd=${event.input.leadingCd}, multiline=${event.input.multiline}, heredoc=${event.input.heredoc}` : '';
174 return `- ${event.time || 'time unavailable'} ${event.kind} ${event.tool} [${event.stage}:${event.code}] execution=${event.execution}; output=${event.output.type}/${event.output.resultType}${structure}`;
175 });
176 return [
177 `MyPrivacy v${VERSION} audit: ${events.length} retained events (limit ${MAX_AUDIT_EVENTS}).`,
178 'Local structural metadata only; no commands, paths, prompts, tool output, credentials, or PII mappings are recorded.',
179 `Kinds: ${countLines(events, 'kind')}`,
180 `Codes: ${countLines(events, 'code')}`,
181 `Stages: ${countLines(events, 'stage')}`,
182 `Versions: ${countLines(events, 'version')}`,
183 `Execution: ${countLines(events, 'execution')}`,
184 `Audit write failures in this process: ${writeFailures}.`,
185 'Recent events:', ...(recent.length ? recent : ['- none']),
186 'Analysis:', ...recommendations(events).map(advice => `- ${advice}`),
187 'Analysis never changes filtering rules or permissions automatically.',
188 ].join('\n');
189}
190