SLOPSHOPPER

my-privacy

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

newrowsguardcommandstatusprompt
★ 59v0.5.0UNLICENSEDupdated 2026-10-06jinrunsen/claude-my-privacy/plugins/my-privacy
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · my-privacy
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ Denied by my-privacy: Underlying tool reported an error (execution may have occurred): src/auth.test.ts: ✓ ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /my-privacy-status ⎿ my-privacy: MyPrivacy v0.5.0: targeted replacement, best effort. Configuration: unavailable; mappings: 0. Configured terms ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

MyPrivacy

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>

Install and verify

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>

1. Create private configuration

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>

2. Install the plugin

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>

3. Confirm that configuration is active

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>

Everyday use

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>

Update

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>

Documentation and development

Choose documentation by task; you do not need to read the entire implementation:

TaskDocument
Configure terms in names, companies, domains, and email addressesConfiguration guide
Understand the differences between input, tool execution, files, and repliesReplacement behavior and limits
Diagnose paths, old blocking messages, and tool errorsTroubleshooting and audit
Locate implementation, loading, and storage responsibilitiesArchitecture
Change code, check documentation, and prepare a releaseContributing guide
Run isolated verification and consult historical probesVerification guide

The project does not yet have an open-source license; the plugin manifest is marked UNLICENSED.

Source 7 files
hooks/register.js 237 lines
1import { 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}
237
lib/privacy.js 184 lines
1/* 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}
184
lib/content.js 77 lines
1// 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}
77
lib/tool-policy.js 74 lines
1// 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}
74
config.js 4 lines
1// 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';
4
lib/configuration.js 31 lines
1import { 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}
31
lib/audit.js 190 lines
1// 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