SLOPSHOPPER

browser-guard

Stops Chrome actions when the Chrome profile is not the one paired with the active cswap account

newguardcommandtoastprocess
v0.2.0MITupdated 2026-10-02abhibansal60/claude-mods/browser-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · browser-guard
› 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) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /browser-guard ⎿ browser-guard: browser-guard: no pairs yet. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

Claude Code mods (plugins of function hooks) built from how I actually use Claude Code: two accounts rotated with cswap, many parallel sessions in herdr, and checking results rather than diffs.

on-me and twin-meter in the band above the prompt, ship-state in the side pane, on a real repo

Start here: paste this to your agent

Set up the claude-mods Claude Code plugins from https://github.com/abhibansal60/claude-mods.
1. Read README.md and each mod's hooks/register.tsx (or .ts) on GitHub so you know what each one runs.
2. Check what I have, read-only: `claude --version`, `which cswap herdr gh git`, `gh auth status`, and the plugins in ~/.claude/settings.json.
3. Tell me which mods fit my machine: twin-meter and browser-guard need cswap; herdr-fleet needs herdr; ship-state needs git and gh. Skip the ones whose tools are missing.
4. Ask me before changing anything. Then run `claude plugin marketplace add abhibansal60/claude-mods` and `claude plugin install <mod>@claude-mods --scope user` for each mod I pick.
5. Tell me to restart Claude Code, and how to see each mod: the one-line band above the prompt, `/ship-state`, `/herdr-fleet`, `/browser-guard`.

Install

claude plugin marketplace add abhibansal60/claude-mods
claude plugin install on-me@claude-mods --scope user
claude plugin install twin-meter@claude-mods --scope user
claude plugin install ship-state@claude-mods --scope user
claude plugin install browser-guard@claude-mods --scope user
claude plugin install herdr-fleet@claude-mods --scope user

Then restart Claude Code. Update later with claude plugin marketplace update claude-mods.

Needs: twin-meter and browser-guard use cswap, herdr-fleet uses herdr, ship-state uses git and gh.

The mods

ModWhereWhat it shows or does
on-meBand, leftOne line: a yellow ON YOU tag and the first item from the last answer's Blocked on me part (or "answer my question"), +N for more; a cyan CLAUDE tag and the current tool call while Claude works. Nothing when nothing waits on you.
twin-meterBand, right edgeA magenta OTHER ACCOUNT tag with #2 5h 3% ↺ 45m · 7d 91% for your other cswap accounts (the status line already shows the active one). Turns into a cyan SWITCH tag when the active account passes 60% and another has room.
ship-statePane, /ship-stateEach repo the session touched: branch, uncommitted files, unpushed commits, last CI run, commit statuses on HEAD (Vercel deploys) and PyPI against the local version. A toast when an answer says "done" but a repo is not shipped.
browser-guardHook on Chrome toolsPairs each cswap account with a Chrome browser on first use, then stops Chrome actions from the wrong profile. /browser-guard lists pairs, /browser-guard reset forgets them.
herdr-fleetPane, /herdr-fleetEvery herdr agent with status, folder, title and last words, "◉ you are here" on this pane, a focus button, a toast when another agent is blocked. Stops Bash commands that would close this pane or herdr.

Develop

Each mod is a plugin: .claude-plugin/plugin.json, hooks/hooks.json, hooks/register.tsx, a types/index.d.ts contract for its $.state, and pure parsers in hooks/parse.ts with tests in tests/.

claude plugin validate ./<mod>
claude plugin test ./<mod>

To work on a mod live, run claude --plugin-dir ./<mod>: saving a file reloads it.

Source 2 files
hooks/register.ts 80 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { ACTS, CHROME, PICKERS, activeEmail, inUseBrowser, mismatch } from './parse'
4import type { Browser, Pairs } from './parse'
5
6// Pairs live in $.store so they last across sessions: account email → Chrome browser.
7async function getPairs($: EngineInterface): Promise<Pairs> {
8  return ((await $.store.get('pairs')) as Pairs | undefined) ?? {}
9}
10
11async function account($: EngineInterface) {
12  const { stdout } = await $.process.run(['cswap', 'status'], { timeoutMs: 10000 })
13  return activeEmail(stdout)
14}
15
16async function browserInUse($: EngineInterface): Promise<Browser | null> {
17  const listed = await $.tool.call({ tool: `${CHROME}list_connected_browsers` } as never)
18  return inUseBrowser((listed as { text?: string }).text ?? '')
19}
20
21// One look at the account and browser serves the calls of the next 30 s.
22let seen: { at: number; email: string | null; browser: Browser | null } | null = null
23
24async function look($: EngineInterface) {
25  const now = await $.clock.now()
26  if (!seen || now - seen.at > 30_000) {
27    seen = { at: now, email: await account($).catch(() => null), browser: await browserInUse($).catch(() => null) }
28  }
29  return seen
30}
31
32export const register: Register = on => {
33  on('session.start', async ($, e, next) => {
34    await $.command.register({ name: 'browser-guard', description: 'Show Chrome profile pairs; "/browser-guard reset" forgets them' })
35    return next(e)
36  })
37
38  on('command.run', { command: 'browser-guard' }, async ($, e) => {
39    if (e.args.trim() === 'reset') {
40      await $.store.delete('pairs')
41      return { text: 'browser-guard: pairs forgotten. The next Chrome action asks again.' }
42    }
43    const pairs = Object.entries(await getPairs($))
44    return {
45      text: pairs.length === 0 ? 'browser-guard: no pairs yet.' : pairs.map(([email, b]) => `${email} → ${b.name} (${b.deviceId})`).join('\n'),
46    }
47  })
48
49  on('tool.call', async ($, e, next) => {
50    const tool = String(e.tool)
51    const input = e as unknown as { url?: string; action?: string; command?: string }
52    // A browser pick or an account switch makes the last look stale.
53    if (PICKERS.test(tool) || (tool === 'Bash' && /\bcswap\b/.test(input.command ?? ''))) seen = null
54    if (!tool.startsWith(CHROME) || PICKERS.test(tool)) return next(e)
55
56    const { email, browser } = await look($)
57    if (!email || !browser) return next(e)
58
59    const pairs = await getPairs($)
60    const why = mismatch(email, browser, pairs)
61    if (why) return { deny: `browser-guard: ${why}` }
62
63    if (!pairs[email]) {
64      const answer = await $.ui.ask(`Chrome "${browser.name}" is about to act. Is it the Chrome profile for ${email}?`, [
65        'Yes, pair them',
66        'No, stop',
67      ])
68      if (!answer.startsWith('Yes')) {
69        return { deny: `browser-guard: the user says Chrome "${browser.name}" is not the profile for ${email}. Ask which profile to use.` }
70      }
71      await $.store.set('pairs', { ...pairs, [email]: { deviceId: browser.deviceId, name: browser.name } })
72    }
73
74    if (ACTS.test(tool)) {
75      $.ui.toast(`Chrome: ${browser.name} · ${email.split('@')[0]} · ${input.action ?? tool.slice(CHROME.length)}${input.url ? ` ${input.url}` : ''}`)
76    }
77    return next(e)
78  })
79}
80
hooks/parse.ts 36 lines
1export type Browser = { deviceId: string; name: string; inUse?: boolean }
2export type Pairs = Record<string, Browser>
3
4export const CHROME = 'mcp__claude-in-chrome__'
5// Calls that only pick or list browsers: never guarded.
6export const PICKERS = /__(list_connected_browsers|select_browser|switch_browser|tabs_context_mcp)$/
7// Calls that act on a page as the signed-in person: worth a toast.
8export const ACTS = /__(navigate|form_input|file_upload|upload_image|javascript_tool|shortcuts_execute)$|__computer$/
9
10// `cswap status`: "Status: Account-1 (a@b.com [a@b.com's Organization])"
11export function activeEmail(status: string): string | null {
12  return status.match(/Status: Account-\d+ \((\S+?)[\s)]/)?.[1] ?? null
13}
14
15export function inUseBrowser(listText: string): Browser | null {
16  try {
17    const list = JSON.parse(listText) as Browser[]
18    return list.find(b => b.inUse) ?? (list.length === 1 ? list[0]! : null)
19  } catch {
20    return null
21  }
22}
23
24// null when the browser fits the account, else why not.
25export function mismatch(email: string, browser: Browser, pairs: Pairs): string | null {
26  const paired = pairs[email]
27  if (paired && paired.deviceId !== browser.deviceId) {
28    return `Chrome "${browser.name}" is not the profile paired with ${email}. Call select_browser for "${paired.name}" (deviceId ${paired.deviceId}), or ask the user to open that Chrome profile.`
29  }
30  const owner = Object.entries(pairs).find(([other, b]) => other !== email && b.deviceId === browser.deviceId)
31  if (owner) {
32    return `Chrome "${browser.name}" is the profile for ${owner[0]}, but the active cswap account is ${email}. Ask the user which profile to use.`
33  }
34  return null
35}
36