SLOPSHOPPER

redact-secrets

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

newguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · redact-secrets
› fix the failing auth test and add an audit log call ╭─────────────────────────────────────────╮ │ redact-secrets │ ⏺ Read(src/auth.ts) │ redact: masked 2 secrets in Bash output │ ⎿ Read 6 lines ╰─────────────────────────────────────────╯ ⏺ Update(src/auth.ts) ╭─────────────────────────────────────────╮ ⎿ Added 2 lines, removed 1 line │ redact-secrets │ ⏺ Bash(bun test) │ redact: masked 2 secrets in tool-result │ ⎿ 3 pass, 1 fail ╰─────────────────────────────────────────╯ ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /redact ⎿ redact-secrets: redact: on; 4 secrets masked so far in this session. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

nixodoo-copier-template

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.

Quickstart

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

What it generates

  • Odoo 16.0 to 19.0 on Python 3.10 to 3.14 and PostgreSQL 13 to 18, chosen at generation time and verified end to end for every version.
  • Three toolchains as flake packages: 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.
  • Pinned sources: repos.yaml for Odoo, addons.yaml for OCA and custom addon repos, pinned by branch or commit, with a symlink farm builder.
  • Generated configuration: .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.
  • Python dependencies straight from Odoo's own requirements.txt, locked with uv and built with uv2nix, so the flake and your shell agree.
  • Claude Code integration, optional: 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.
  • Optional extras, each behind a question: a custom addons repo, S3 backup restore with native neutralization, SSH helpers for the prod and test boxes, OCA queue_job.

The full inventory, including every skill and agent, is in docs/features.md.

The choices that matter

Copier asks about twenty questions; most have a sensible default derived from your Odoo version. These are the ones worth a thought:

QuestionDefaultWhy you might change it
odoo_version19.0everything else follows from it
custom_repo_pattern / custom_repo_nameemptyyour own addons repo; unlocks the development pipeline
use_claude_codetruethe whole .claude/ layer
use_kbfalsememory in kb instead of markdown files
service_suffixemptyset it to run two generated projects on one machine
project_dir_varODOO<major>_PROJECT_DIRsame reason, for the project-root variable
status_mcp / tickets_mcpnonewire status posting and ticket tracking to Teams and Odoo
backup_s3_bucketemptyrestore production dumps locally
prod_ssh_host / test_ssh_hostsemptythe deploy and prod-ops runbooks

Every question and its default: docs/questions.md.

Keeping a project current

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.

More

  • docs/features.md — everything a generated project contains
  • docs/questions.md — every question copier asks
  • docs/notes.md — gotchas worth knowing before they bite
  • nix flake check shellchecks every generated script; python3 tests/test_nudge_find_code.py runs the hook regression corpus

License

MIT

Source 3 files
hooks/register.tsx 83 lines
1import { 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}
83
hooks/redact.ts 108 lines
1export 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}
108
types/index.d.ts 9 lines
1// 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