Masks passwords, tokens, keys and URL credentials in tool output before the model reads it and before the transcript (and entire) stores it

A Copier template that generates a complete Odoo development environment on Nix flakes: native processes, systemd user services, a project-local PostgreSQL cluster, and a Python environment locked with uv.
No container, no global install, no pip in sight. The same generated project also runs as a test or production server.
uvx copier copy --trust gh:okolovmark/nixodoo-copier-template my-odoo-project
cd my-odoo-project
nix run .#create-env # .env: ports, database credentials
nix run .#update-repos # clone Odoo and the addon repos, build the symlink farm
nix run .#bootstrap-deps # import Odoo's requirements.txt into uv.lock
nix profile add .#dev-server # odoo, psql, ruff and friends into your profile
nix run .#setup-dev # postgres cluster, odoo.conf, nginx, systemd units
systemctl --user enable --now postgres.service odoo.service nginx.service odoo-logrotate.timer
Odoo is then on the port the template derived from your version, with its own PostgreSQL, its own addons farm and a log rotating daily. The generated README.md walks through the test and production flows the same way.
You need Nix with flakes enabled, uv (uvx runs copier without installing it), and a systemd user session. Starting from nothing:
curl -LsSf https://astral.sh/uv/install.sh | sh
sh <(curl --proto '=https' --tlsv1.2 -L https://nixos.org/nix/install) --daemon
echo 'experimental-features = nix-command flakes' >> ~/.config/nix/nix.conf
sudo systemctl restart nix-daemon
dev-server, test-server, prod-server, the first two wrapping odoo and the Postgres client tools against this project's cluster; prod-server carries Odoo alone - no proxy, no database client.repos.yaml for Odoo, addons.yaml for OCA and custom addon repos, pinned by branch or commit, with a symlink farm builder..env, odoo.conf with a random master password, systemd user units, a log-rotation timer, and an nginx reverse proxy for dev and test - production is left to whatever already fronts it.requirements.txt, locked with uv and built with uv2nix, so the flake and your shell agree.CLAUDE.md, guard hooks, Odoo skills including semantic code navigation over the official language server, a requirements interview, a development pipeline, deploy and production-operations runbooks, and a persistent memory.queue_job.The full inventory, including every skill and agent, is in docs/features.md.
Copier asks about twenty questions; most have a sensible default derived from your Odoo version. These are the ones worth a thought:
| Question | Default | Why you might change it |
|---|---|---|
odoo_version | 19.0 | everything else follows from it |
custom_repo_pattern / custom_repo_name | empty | your own addons repo; unlocks the development pipeline |
use_claude_code | true | the whole .claude/ layer |
use_kb | false | memory in kb instead of markdown files |
service_suffix | empty | set it to run two generated projects on one machine |
project_dir_var | ODOO<major>_PROJECT_DIR | same reason, for the project-root variable |
status_mcp / tickets_mcp | none | wire status posting and ticket tracking to Teams and Odoo |
backup_s3_bucket | empty | restore production dumps locally |
prod_ssh_host / test_ssh_hosts | empty | the deploy and prod-ops runbooks |
Every question and its default: docs/questions.md.
uvx copier update --trust
Template improvements land in the generated project. A few files are yours and are never overwritten (odools.toml, the estimate calibration table); for the rest, resolve conflicts in favour of the project where you have edited it. Hook arrays in .claude/settings.json deserve a second look after an update: copier can add a second key with the same name, and JSON keeps only the last one.
nix flake check shellchecks every generated script; python3 tests/test_nudge_find_code.py runs the hook regression corpusMIT
hooks/register.tsx 83 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { redactText, redactValue } from './redact'
5
6// rows that reach the model and the transcript besides a tool's own result
7const DOORS = new Set(['tool-result', 'tool-message', 'hook-context', 'attachment'])
8// the person's own input: the prompt box, the desktop bridge, an SDK host; never a plugin, a schedule,
9// a task notification or another session
10const PERSON_ORIGINS = new Set(['composer', 'bridge', 'sdk'])
11
12const isOff = atom({ plugin: 'redact-secrets', key: 'isOff' } as const, false)
13const masked = atom({ plugin: 'redact-secrets', key: 'masked' } as const, 0)
14
15async function counted($: EngineInterface, count: number, where: string): Promise<void> {
16 if (count === 0) return
17 await update($, masked, total => total + count)
18 $.ui.toast(`redact: masked ${count} secret${count === 1 ? '' : 's'} in ${where}`)
19}
20
21type Block = { type: string; text?: unknown; content?: unknown }
22
23function redactBlocks(blocks: readonly Block[]): { blocks: Block[]; count: number } {
24 let count = 0
25 const out = blocks.map(block => {
26 if (block.type === 'text' && typeof block.text === 'string') {
27 const done = redactText(block.text)
28 count += done.count
29 return done.count === 0 ? block : { ...block, text: done.text }
30 }
31 if (block.type === 'tool_result') {
32 const done = redactValue(block.content)
33 count += done.count
34 return done.count === 0 ? block : { ...block, content: done.value }
35 }
36 return block
37 })
38 return { blocks: out, count }
39}
40
41export const register: Register = on => {
42 on('session.start', async ($, e, next) => {
43 const started = await next(e)
44 await $.command.register({
45 name: 'redact',
46 description: 'Secret masking in tool output: status, or off/on for this session',
47 argumentHint: '[status | off | on]',
48 })
49 return started
50 })
51
52 on('command.run', { command: 'redact' }, async ($, e) => {
53 const verb = e.args.trim()
54 // masking goes off only by the person's hand; anyone may turn it back on
55 if (verb === 'off' && !PERSON_ORIGINS.has(e.origin.kind)) {
56 return { text: 'redact: only the person at the keyboard turns masking off.' }
57 }
58 if (verb === 'off' || verb === 'on') await update($, isOff, () => verb === 'off')
59 const total = await read($, masked)
60 const state = (await read($, isOff)) ? 'OFF for this session' : 'on'
61 return { text: `redact: ${state}; ${total} secret${total === 1 ? '' : 's'} masked so far in this session.` }
62 })
63
64 // The tool's record itself is rewritten, so the transcript stores the masked record (and `entire`
65 // pushes that), not only the text the model reads. An errored call is left to session.append.
66 on('tool.call', async ($, e, next) => {
67 const ran = await next(e)
68 if (ran.deny !== undefined || ran.isError === true || (await read($, isOff))) return ran
69 const done = redactValue(ran.result)
70 if (done.count === 0) return ran
71 await counted($, done.count, `${String(e.tool)} output`)
72 return ran.context === undefined ? { result: done.value } : { result: done.value, context: ran.context }
73 })
74
75 on('session.append', async ($, e, next) => {
76 if (!DOORS.has(e.door) || (await read($, isOff))) return next(e)
77 const done = redactBlocks(e.message.content as readonly Block[])
78 if (done.count === 0) return next(e)
79 await counted($, done.count, e.door)
80 return next({ ...e, message: { ...e.message, content: done.blocks as typeof e.message.content } })
81 })
82}
83hooks/redact.ts 108 lines1export const MASK = '«redacted»'
2
3// Values that are dev placeholders, not secrets: the local env's odoo/odoo and admin/admin, test fixtures.
4// Masking them would only break an Edit whose old_string quotes them.
5const PLACEHOLDER = /^(?:admin|demo|odoo|test|testing|password|passwd|secret|changeme|example|dummy|x+|\*+|•+|«redacted»|false|true|none|null|undefined)$/i
6// `password = self.password`, `token = vals.get` read as code, never as a value
7const DOTTED_NAME = /^[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)+$/
8
9const KEY_WORDS = 'PASSWORD|PASSWD|PASS|SECRET|TOKEN|API_?KEY|ACCESS_?KEY|PRIVATE_?KEY|CREDENTIALS?'
10const LOWER_KEY_WORDS = 'password|passwd|secret|api_?key|apikey|access_?token|refresh_?token|client_secret|token'
11
12type Rule = { pattern: RegExp; mask: (...groups: string[]) => string | null }
13
14// $VAR, ${VAR}, %(var)s, {{ var }}: a reference to a secret, not the secret
15const REFERENCE = /^[$%{]/
16
17function value(prefix: string, quote: string, secret: string): string | null {
18 if (PLACEHOLDER.test(secret) || DOTTED_NAME.test(secret) || REFERENCE.test(secret)) return null
19 return `${prefix}${quote}${MASK}${quote}`
20}
21
22const RULES: Rule[] = [
23 // PEM private keys, whole block
24 {
25 pattern: /-----BEGIN ([A-Z0-9 ]*)PRIVATE KEY-----[\s\S]*?-----END \1PRIVATE KEY-----/g,
26 mask: (_all, kind) => `-----BEGIN ${kind}PRIVATE KEY-----\n${MASK}\n-----END ${kind}PRIVATE KEY-----`,
27 },
28 // tokens with a known shape: the prefix stays so it is clear what was there
29 { pattern: /\b(gh[pousr]_)[A-Za-z0-9]{30,}\b/g, mask: (_all, p) => `${p}${MASK}` },
30 { pattern: /\b(github_pat_)[A-Za-z0-9_]{40,}\b/g, mask: (_all, p) => `${p}${MASK}` },
31 { pattern: /\b(sk-ant-[a-z0-9]+-)[A-Za-z0-9_-]{20,}/g, mask: (_all, p) => `${p}${MASK}` },
32 { pattern: /\b(xox[abposr]-)[A-Za-z0-9-]{10,}/g, mask: (_all, p) => `${p}${MASK}` },
33 { pattern: /\b(AKIA|ASIA)[0-9A-Z]{16}\b/g, mask: (_all, p) => `${p}${MASK}` },
34 { pattern: /\b(AIza)[0-9A-Za-z_-]{35}\b/g, mask: (_all, p) => `${p}${MASK}` },
35 { pattern: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, mask: () => MASK },
36 // scheme://user:password@host
37 {
38 pattern: /\b([a-z][a-z0-9+.-]*:\/\/[^\s:@/]+:)([^\s@/]+)(@)/gi,
39 mask: (_all, head, secret, at) => (PLACEHOLDER.test(secret ?? '') ? null : `${head}${MASK}${at}`),
40 },
41 // Authorization: Bearer <token>
42 {
43 pattern: /\b(Authorization:\s*(?:Bearer|Basic|Token|token)\s+)([A-Za-z0-9._~+/=-]{8,})/g,
44 mask: (_all, head) => `${head}${MASK}`,
45 },
46 // .env and shell: ODOO_PASSWORD_PROD=..., export API_KEY="..."
47 {
48 pattern: new RegExp(`^(\\s*(?:export\\s+)?[A-Z0-9_]*(?:${KEY_WORDS})[A-Z0-9_]*\\s*=\\s*)(["']?)([^\\s"'$][^\\s"']{2,})\\2`, 'gm'),
49 mask: (_all, prefix, quote, secret) => value(prefix ?? '', quote ?? '', secret ?? ''),
50 },
51 // odoo.conf / ini / yaml: admin_passwd = ..., smtp_password: ...; never a call, a container or a comment
52 {
53 pattern: new RegExp(`^(\\s*[a-z_]*(?:${LOWER_KEY_WORDS})[a-z_]*\\s*[=:]\\s*)()([^\\s#;'"(){}\\[\\],]{3,})[ \\t]*$`, 'gmi'),
54 mask: (_all, prefix, quote, secret) => value(prefix ?? '', quote ?? '', secret ?? ''),
55 },
56 // JSON and Python dicts: "password": "...", 'api_key': '...'
57 {
58 pattern: new RegExp(`(["'][a-z_]*(?:${LOWER_KEY_WORDS})["']\\s*[:=]\\s*)(["'])([^"'\\s]{3,})\\2`, 'gi'),
59 mask: (_all, prefix, quote, secret) => value(prefix ?? '', quote ?? '', secret ?? ''),
60 },
61]
62
63export function redactText(text: string): { text: string; count: number } {
64 let count = 0
65 let out = text
66 for (const rule of RULES) {
67 out = out.replace(rule.pattern, (...args: unknown[]) => {
68 // (match, ...groups, offset, input): an unmatched group reads as ''
69 const all = String(args[0])
70 const groups = args.slice(1, -2).map(arg => (typeof arg === 'string' ? arg : ''))
71 const masked = rule.mask(all, ...groups)
72 if (masked === null || masked === all) return all
73 count += 1
74 return masked
75 })
76 }
77 return { text: out, count }
78}
79
80// every string inside a tool's record, keys left alone
81export function redactValue(input: unknown): { value: unknown; count: number } {
82 if (typeof input === 'string') {
83 const done = redactText(input)
84 return { value: done.text, count: done.count }
85 }
86 if (Array.isArray(input)) {
87 let count = 0
88 const value = input.map(item => {
89 const done = redactValue(item)
90 count += done.count
91 return done.value
92 })
93 return { value, count }
94 }
95 if (typeof input === 'object' && input !== null) {
96 let count = 0
97 const value = Object.fromEntries(
98 Object.entries(input).map(([key, item]) => {
99 const done = redactValue(item)
100 count += done.count
101 return [key, done.value]
102 }),
103 )
104 return { value, count }
105 }
106 return { value: input, count: 0 }
107}
108types/index.d.ts 9 lines1// the session's switch (/redact off) and how many secrets were masked so far
2export type RedactCount = number
3
4declare module 'claude-code' {
5 interface PluginState {
6 'redact-secrets': { isOff: boolean; masked: RedactCount }
7 }
8}
9