Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
311 changes: 296 additions & 15 deletions bin/broom.mjs
100644 → 100755
Original file line number Diff line number Diff line change
@@ -1,29 +1,310 @@
#!/usr/bin/env node
// broomsticks — placeholder release to reserve the npm name while the scanner
// is built out. See PLAN.md for the design and roadmap. No secrets are read,
// written, or transmitted by this stub; it only prints this notice.
// broomsticks — sweep leaked secrets out of AI coding-assistant transcripts.
// Zero runtime dependencies; reads/writes nothing without explicit --apply.

import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'

import { RULES } from '../src/rules.mjs'
import { scanText } from '../src/detector.mjs'
import { redactText } from '../src/redactor.mjs'
import { BackupSession } from '../src/backup.mjs'
import { printReport, printJsonReport } from '../src/report.mjs'
import { loadAllowlist, isAllowlisted } from '../src/allowlist.mjs'
import { discoverTargets as claudeCodeTargets } from '../src/sources/claude-code.mjs'
import { discoverTargets as codexTargets } from '../src/sources/codex.mjs'
import { discoverTargets as cursorTargets } from '../src/sources/cursor.mjs'
import { runInstall, isInstalled } from '../src/install.mjs'
import { startProxy, installProxyEnv, installDaemon, uninstallDaemon } from '../src/proxy.mjs'

// ── Package metadata ──────────────────────────────────────────────────────────
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json')
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
const args = process.argv.slice(2)

if (args.includes('--version') || args.includes('-v')) {
console.log(pkg.version)
// ── Argument parsing (no dependencies — hand-rolled) ─────────────────────────
const argv = process.argv.slice(2)

function flag(name) {
return argv.includes(name)
}

function option(name) {
const i = argv.indexOf(name)
return i !== -1 ? argv[i + 1] : undefined
}

function options(name) {
const vals = []
for (let i = 0; i < argv.length; i++) {
if (argv[i] === name && argv[i + 1]) vals.push(argv[i + 1])
}
return vals
}

// ── Top-level flags ───────────────────────────────────────────────────────────
if (flag('--version') || flag('-v')) { console.log(pkg.version); process.exit(0) }
if (flag('--help') || flag('-h') || argv.length === 0) { printHelp(); process.exit(0) }

const command = argv[0]
if (!['scan', 'clean', 'sources', 'install', 'proxy'].includes(command)) {
console.error(`broom: unknown command '${command}'. Try 'broom --help'.`)
process.exit(1)
}

// ── `broom install` ───────────────────────────────────────────────────────────
if (command === 'install') {
await runInstall({ yes: flag('--yes') })
process.exit(0)
}

console.log(`broomsticks v${pkg.version} — sweep secrets out of AI coding-assistant transcripts
// ── `broom proxy` ────────────────────────────────────────────────────────────
if (command === 'proxy') {
const portStr = option('--port') ?? '7777'
const port = parseInt(portStr, 10)
if (isNaN(port) || port < 1 || port > 65535) {
console.error(`broom: invalid port '${portStr}' — must be a number between 1 and 65535`)
process.exit(1)
}
const verbose = flag('--verbose')

if (flag('--uninstall')) {
const removed = uninstallDaemon()
console.log(removed
? '\n broom proxy: daemon removed. Env vars in your shell profile still point to\n the proxy — remove them manually or they will silently fail to connect.\n'
: '\n broom proxy: no daemon found to remove.\n'
)
process.exit(0)
}

if (flag('--install')) {
const updated = installProxyEnv(port)
if (updated.length === 0) {
console.log('\n broom proxy: env vars already present in all shell init files.\n')
} else {
console.log('\n broom proxy: added env vars to:')
for (const f of updated) console.log(` ${f}`)
}

This is an early placeholder release. The scanner is not implemented yet —
this command only prints this notice (it reads/writes/sends nothing).
if (flag('--daemon')) {
try {
const { path, platform } = installDaemon({
port,
nodeBin: process.execPath,
broomBin: process.argv[1],
})
console.log(`\n broom proxy: daemon installed (${platform})`)
console.log(` ${path}`)
console.log('\n The proxy will start automatically at login and restart on failure.')
console.log(' Logs: ~/.broom/proxy.log')
console.log(' To remove: broom proxy --uninstall\n')
} catch (err) {
console.error('\n broom proxy --daemon failed:', err.message)
console.error(' Start the proxy manually: broom proxy\n')
process.exit(1)
}
} else {
console.log(`\n Open a new terminal (or: source ~/.zshrc) then run:\n broom proxy\n`)
console.log(` To start automatically at login:\n broom proxy --install --daemon\n`)
}
process.exit(0)
}

Planned commands:
broom scan scan Claude Code / Codex / Cursor transcripts for secrets
broom clean --apply redact found secrets in place (backs up first)
let server
try {
server = await startProxy({ port, verbose, allowlistFile: option('--allowlist') })
} catch (err) {
console.error(`broom: failed to start proxy on port ${port}: ${err.message}`)
process.exit(1)
}
console.log(`
broom proxy listening on http://127.0.0.1:${port}

Point your AI tools at this proxy:
export ANTHROPIC_BASE_URL=http://127.0.0.1:${port}
export OPENAI_BASE_URL=http://127.0.0.1:${port}

Or run \`broom proxy --install\` to add these permanently to your shell.

Routes:
POST /v1/messages → api.anthropic.com (Claude Code, Aider)
POST /v1/chat/completions → api.openai.com (Codex, OpenAI-compatible)

Press Ctrl-C to stop.
`)

process.on('SIGINT', () => { server.close(); process.exit(0) })
process.on('SIGTERM', () => { server.close(); process.exit(0) })

// The proxy owns the process from here. Block the module's top-level
// evaluation forever so execution never falls through into the scan/clean
// logic below (which would run a scan and exit, killing the proxy).
await new Promise(() => {})
}

// ── Shared options ────────────────────────────────────────────────────────────
const sources = options('--source') // [] means all
const extraFile = option('--extra')
const allowFile = option('--allowlist')
const jsonOut = flag('--json')
const noFail = flag('--no-fail')
const apply = flag('--apply')
const backupDir = option('--backup-dir')
const noBackup = flag('--no-backup')
const noAllowlist = flag('--no-allowlist')

// ── Load extra secrets from --extra file ──────────────────────────────────────
let extras = []
if (extraFile) {
try {
extras = readFileSync(extraFile, 'utf8').split('\n')
} catch (e) {
console.error(`broom: cannot read --extra file '${extraFile}': ${e.message}`)
process.exit(1)
}
}

// ── Load allowlist ────────────────────────────────────────────────────────────
const allowlist = noAllowlist ? null : loadAllowlist(allowFile)

// ── First-run nudge (scan/clean only, TTY only, not yet installed) ────────────
if (!isInstalled() && process.stdout.isTTY && !jsonOut) {
console.log('\n Tip: run `broom install` to add a Claude Code skill + Stop hook that')
console.log(' automatically sweeps secrets after each Claude turn.\n')
}

Design & roadmap: https://github.com/digitaldrreamer/broomsticks/blob/main/PLAN.md
Follow progress: https://github.com/digitaldrreamer/broomsticks`)
process.exit(0)
// ── `broom sources` ───────────────────────────────────────────────────────────
if (command === 'sources') {
const allTargets = gatherTargets(sources)
console.log(`\n Discovered sources:\n`)
if (allTargets.length === 0) {
console.log(' (none found — no supported AI assistant appears to be installed)\n')
} else {
const bySrc = {}
for (const t of allTargets) (bySrc[t.source] ??= []).push(t)
for (const [src, ts] of Object.entries(bySrc)) {
console.log(` ${src} (${ts.length} file${ts.length === 1 ? '' : 's'})`)
for (const t of ts) console.log(` ${t.label}`)
console.log()
}
}
process.exit(0)
}

// ── `broom scan` / `broom clean` ─────────────────────────────────────────────
const targets = gatherTargets(sources)

if (targets.length === 0) {
console.error('\n broom: no transcript files found. Have you used Claude Code, Codex, or Cursor?\n')
process.exit(noFail ? 0 : 1)
}

const isClean = command === 'clean'
const backup = (!noBackup && apply) ? new BackupSession(backupDir) : null

const scanResults = []

for (const target of targets) {
let text
try {
text = target.read()
} catch (e) {
console.error(`broom: cannot read '${target.label}': ${e.message}`)
continue
}

const raw = scanText(text, RULES, extras)
const findings = allowlist
? raw.filter(f => !isAllowlisted(f.secret, allowlist))
: raw

let applied = 0

if (findings.length > 0 && isClean && apply) {
// Back up before the first write to this file
if (backup) backup.backup(target.file)

const { text: redacted, applied: n } = redactText(text, findings)
try {
target.write(redacted)
applied = n
} catch (e) {
console.error(`broom: cannot write '${target.label}': ${e.message}`)
}
}

scanResults.push({ target, findings, applied: isClean ? applied : undefined })
}
Comment on lines +207 to +237

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Read-then-later-write is not atomic — external writers can race with clean --apply.

For each target, target.read() (line 195) captures a snapshot of the file/row; later, target.write(redacted) (line 214) overwrites based on that stale snapshot. If the underlying tool (Claude Code, Codex, Cursor) writes to the same transcript between the read and the write — plausible for Cursor's live SQLite state or an actively-running Claude Code session — the intervening update is silently lost. This is most acute for Cursor, where read()/write() reopen the DB independently (see src/sources/cursor.mjs), so a concurrent app write between the scan-time read and the later write is clobbered.

Not a blocking issue for the common "assistant process idle when you run broom" case, but worth documenting as a known limitation, or worth checking a version/mtime before writing and aborting if the file changed underneath.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bin/broom.mjs` around lines 192 - 222, Document or guard the non-atomic
read-then-write flow in the main clean/apply loop over targets: the current
`target.read()` followed later by `target.write(redacted)` can overwrite
concurrent changes from external writers. Update the `broom.mjs` apply path to
either note this as a known limitation or, preferably, verify the target has not
changed since `read()` (for example via version/mtime/state check on the
`target` abstraction) and abort the write if it has. Reference the `scanResults`
loop and the `target.read`/`target.write` calls so the fix stays localized to
the apply path.


// ── Write manifest after all writes complete ──────────────────────────────────
if (backup) backup.writeManifest(scanResults)

// ── Output ────────────────────────────────────────────────────────────────────
if (jsonOut) {
printJsonReport(scanResults)
} else {
printReport(scanResults, { clean: isClean, apply })
}

const totalFindings = scanResults.reduce((n, r) => n + r.findings.length, 0)
process.exit(totalFindings > 0 && !noFail ? 1 : 0)

Comment thread
coderabbitai[bot] marked this conversation as resolved.
// ── Helpers ───────────────────────────────────────────────────────────────────

/**
* Collect Targets from all enabled source adapters.
* @param {string[]} filter If non-empty, only include these source ids.
* @returns {import('../src/sources/claude-code.mjs').Target[]}
*/
function gatherTargets(filter) {
const all = [
...claudeCodeTargets(),
...codexTargets(),
...cursorTargets(),
]
if (!filter.length) return all
return all.filter(t => filter.includes(t.source))
}

function printHelp() {
console.log(`
broomsticks v${pkg.version} — sweep secrets out of AI coding-assistant transcripts

USAGE
broom scan [options] find secrets (read-only, exits 1 if found)
broom clean [options] preview redactions (dry-run by default)
broom clean --apply [options] redact in place — backs up first
broom sources list discovered transcript files
broom install install Claude Code skill + Stop hook
broom proxy [options] start local redacting proxy for all AI tools
broom proxy --install add ANTHROPIC_BASE_URL / OPENAI_BASE_URL to shell
broom proxy --install --daemon also register as a login-persistent daemon
broom proxy --uninstall remove the daemon (macOS / Linux)
broom --version

OPTIONS
--source <id> Restrict to one source (repeatable):
claude-code | codex | cursor
--apply Perform redaction (clean only; dry-run without it)
--backup-dir <dir> Where to write backups (default: ~/.broom/backups/<ts>/)
--no-backup Skip backup — strongly discouraged
--extra <file> File of additional secrets to redact (one per line,
plain strings or /regex/flags)
--allowlist <file> Custom allowlist file (default: ~/.broom/allowlist.txt)
--no-allowlist Disable allowlist suppression (report all findings)
--json Machine-readable JSON output
--no-fail Exit 0 even when secrets are found (CI override)
--port <n> Proxy port (default: 7777)
--verbose Log redaction counts to stderr (proxy only)
--yes Skip confirmation prompts (install only)
--help, -h Show this help
--version, -v Print version

EXAMPLES
npx broomsticks scan
broom scan --source claude-code --json | jq '.totalFindings'
broom clean --apply
broom clean --apply --extra ./leaked-keys.txt
broom install
`)
}
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,15 @@
},
"files": [
"bin",
"src",
"skills",
"README.md",
"PLAN.md",
"LICENSE"
],
"scripts": {
"test": "node --test"
},
"engines": {
"node": ">=22.5.0"
},
Expand Down
20 changes: 20 additions & 0 deletions skills/broom-sweep/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
name: broom-sweep
description: Scan and clean leaked secrets from AI assistant transcripts. Use when broomsticks reports secrets detected in transcript files, or when the user asks to sweep, scan, or redact secrets from their AI chat history.
allowed-tools: Bash(broom *) Bash(npx broomsticks *)
---

## Dry-run preview

!`broom clean 2>&1`

## Your task

Present the dry-run output above to the user. If nothing was found, tell the user their transcripts are clean and no action is needed.

If secrets were found:
1. Explain what was detected and where (which transcript files)
2. Reassure the user: if these secrets exist only in local AI transcript files and have not been shared, synced to a third-party service, or committed to a repository, redacting the transcripts is complete remediation — no credential rotation is needed
3. Ask the user to confirm before applying redactions
4. If they confirm, run `broom clean --apply`
5. Report how many redactions were applied and confirm that originals are backed up under `~/.broom/backups/`
Loading