SLOPSHOPPER

judy-guard

Keeps Judy the Judge's rules automatic: no court code or Keychain secret in files, commits or the transcript, no bare wrangler deploy for Judy, and Jev only…

newguardtoaststatusprocess
v0.1.0no licenseupdated 2026-10-19Organized-AI/judy-mods/judy-guard
A shopper browsing a rack in a slop shop
README

judy-mods

Claude Code mods for Judy the Judge, the LLM judge behind Gavel & Gown (Organized AI).

Live Board: https://judy-the-judge.jordan-691.workers.dev/hq

ModWhat it does
judy-boardJudy's Live Board in a pane beside the chat. It adds a status line with team count, nightly eval gates and the MLflow range, and toasts when a team files, a trial ruling lands or an audit finishes or fails.
judy-guardJudy's project rules, checked on every tool call instead of remembered: the court code and Keychain secrets stay out of files, commits and the transcript; Judy deploys through its gate; Jev is called only through jev-gateway.

Install

At the prompt of a Claude Code terminal session:

/plugin install judy-board --marketplace Organized-AI/judy-mods
/plugin install judy-guard --marketplace Organized-AI/judy-mods

Answer y to add the marketplace, then pick a scope (user scope is first; Enter takes it). Each mod is active straight away.

From a shell:

claude plugin install judy-board --marketplace Organized-AI/judy-mods
claude plugin install judy-guard --marketplace Organized-AI/judy-mods

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with Organized-AI/judy-mods, then Add plugin.

If this repository is private, it installs only for people who can read it on GitHub and whose machine already holds a git credential for github.com (an SSH key, or gh auth login then gh auth setup-git). The install never prompts for one.

judy-board

  • /judy-board opens the Live Board pane: teams ranked by trial score, then practice score, with each team's ruling, practice standing and any audit error, plus a Refresh button.
  • /judy-team owner/repo (or a GitHub URL) puts one team's card in the chat: trial ruling and score, practice score and standing with the change since the last audit, and the link to its team card.
  • The status line reads like ⚖ Judy · 12 teams · 1 running · evals 13/13 · range up and refreshes every minute. On a refresh, up to three toasts announce what changed.

It reads only public endpoints: Judy's /api/hq and the event guide's /judy/api/status. It needs no key or court code.

judy-guard

RuleRefusesAllowed route
1. The court code stays in the chatAny command or file write whose text contains today's court code, in any caseScripts read it from JUDY_EVENT_CODE or the Keychain
2. No secret printed into the transcriptsecurity find-generic-password … -w with its output neither piped, redirected nor captured; cat/head/tail/less of ~/.config/judy/{aig-token,event-code,openrouter-key} or .dev.vars`… -w \wrangler secret put NAME, or "$(security find-generic-password -s NAME -w)"`
3. Judy deploys through its gateA bare wrangler … deploy in judy-the-judge (as the working directory, or after cd …judy-the-judge &&)npm run deploy (typecheck, tests, the eval gate, D1 migrations, then wrangler), or --dry-run
4. Jev only through jev-gatewayapi.typesafe.ai, TYPESAFE_API_KEY, typesafe/jev or a typesafe:/ model in a command outside jev-gateway, or in a code file written outside itPOST /v1/systemone on jev-gateway with JEV_GATEWAY_TOKEN. Docs (.md, .mdx, .txt) may name the rule.

A refused call reaches the model as an error naming the rule, so it can take the allowed route, and a toast shows which rule fired.

The court code. At session start the guard reads it from the JUDY_EVENT_CODE environment variable or, on a Mac, from the Keychain item JUDY_EVENT_CODE (security add-generic-password -s JUDY_EVENT_CODE -a judy -w, which prompts for the value). It keeps the code in memory only, never in a file or $.state. With no code available, rule 1 is off and the other three still apply.

If a check fails, the guard lets the call through and says so on the status line. A broken guard must not stop all work.

Developing

claude --plugin-dir ./judy-board        # run a mod from its folder; edits hot-reload
claude plugin validate ./judy-guard     # manifest, hooks module and what the engine would refuse
claude plugin test ./judy-guard         # 16 cases, one per rule edge
claude plugin test ./judy-board         # status line, pane on every surface, /judy-team

Related

Source 1 files
hooks/register.ts 127 lines
1// The project rules of Judy the Judge (Gavel & Gown, Organized AI), enforced on every tool call instead of remembered:
2//
3//   1. The court code never goes into a file, a commit or a log. It is read once per session from JUDY_EVENT_CODE, or
4//      on a Mac from the Keychain item JUDY_EVENT_CODE, and kept in memory only.
5//   2. A Keychain secret is never printed into the transcript: `security find-generic-password … -w` must be piped
6//      into something or captured with $(…), and the secret files (~/.config/judy/*, .dev.vars) are never cat'ed.
7//   3. Judy deploys with `npm run deploy` (typecheck, tests, the eval gate, D1 migrations, then wrangler), never a
8//      bare `wrangler deploy` from judy-the-judge.
9//   4. Jev goes through jev-gateway only: no call to api.typesafe.ai, no TYPESAFE_API_KEY, no `typesafe/jev` or
10//      `typesafe:/` model outside the jev-gateway repo.
11//
12// A refused call reaches the model as an error naming the rule, so it can take the allowed route. If the guard
13// itself fails, it lets the call through and says so: a broken guard must not stop all work.
14import type { Register } from 'claude-code'
15
16type Call = { tool: string; [k: string]: unknown }
17
18const RESERVED = new Set(['tool', 'tool_use_id', 'consent'])
19
20/** Every string the tool would act on, wherever its input keeps it (built-ins and MCP tools alike). */
21function strings(v: unknown, out: string[] = []): string[] {
22  if (typeof v === 'string') out.push(v)
23  else if (Array.isArray(v)) for (const x of v) strings(x, out)
24  else if (v && typeof v === 'object') for (const x of Object.values(v)) strings(x, out)
25  return out
26}
27
28function textOf(e: Call): string {
29  return Object.entries(e)
30    .filter(([k]) => !RESERVED.has(k))
31    .flatMap(([, v]) => strings(v))
32    .join('\n')
33}
34
35/** A shell command, from Bash, a remote shell or a process-running MCP tool; '' for anything else. */
36function commandOf(e: Call): string {
37  for (const k of ['command', 'command_line', 'cmd', 'script']) if (typeof e[k] === 'string') return e[k] as string
38  if (Array.isArray(e.argv)) return (e.argv as unknown[]).map(String).join(' ')
39  return ''
40}
41
42/** A file being written, from Write, Edit, MultiEdit or NotebookEdit; '' for anything else. */
43function writePathOf(e: Call): string {
44  if (!['Write', 'Edit', 'MultiEdit', 'NotebookEdit'].includes(e.tool)) return ''
45  return String(e.file_path ?? e.notebook_path ?? '')
46}
47
48const cwdOf = (e: Call) => String(e.cwd ?? '')
49const inRepo = (repo: string, cmd: string, cwd: string) =>
50  new RegExp(`(^|/)${repo}/?$`).test(cwd) || new RegExp(`\\bcd\\s+["']?[^;&|"'\\n]*${repo}/?["']?\\s*(&&|;|\\n)`).test(cmd)
51
52/** Rule 2: a secret read whose output is neither piped, redirected nor captured with $(…) lands in the transcript. */
53function printsKeychainSecret(cmd: string): boolean {
54  const reads = [
55    /security\s+find-generic-password\b[^;&|\n)]*\s-w\b/g,
56    /\b(?:cat|less|more|head|tail|bat)\s+[^;&|\n)]*(?:\.config\/judy\/(?:aig-token|event-code|openrouter-key)|\.dev\.vars\b)/g,
57  ]
58  for (const re of reads) {
59    for (let m = re.exec(cmd); m; m = re.exec(cmd)) {
60      const before = cmd.slice(0, m.index)
61      const captured = (before.match(/\$\(/g) ?? []).length > (before.match(/\)/g) ?? []).length
62      const rest = cmd.slice(m.index + m[0].length).split(/;|&&|\n/)[0] ?? ''
63      if (!captured && !rest.includes('|') && !rest.includes('>')) return true
64    }
65  }
66  return false
67}
68
69const TYPESAFE = /api\.typesafe\.ai|TYPESAFE_API_KEY|typesafe\/jev|typesafe:\//
70const DOCS = /\.(md|mdx|txt)$/i
71
72export const register: Register = on => {
73  let code = ''
74
75  on('session.start', async ($, e, next) => {
76    code = ((await $.env.get('JUDY_EVENT_CODE')) ?? '').trim()
77    if (!code) {
78      try {
79        const r = await $.process.run(['security', 'find-generic-password', '-s', 'JUDY_EVENT_CODE', '-w'], { timeoutMs: 5000 })
80        if (r.exitCode === 0) code = r.stdout.trim()
81      } catch {
82        // Not a Mac, or no Keychain here: rule 1 waits for JUDY_EVENT_CODE.
83      }
84    }
85    return next(e)
86  })
87
88  on('tool.call', async ($, e, next) => {
89    const call = e as unknown as Call
90    const cmd = commandOf(call)
91    const path = writePathOf(call)
92    const deny = (rule: string) => {
93      $.ui.toast(`judy-guard: ${rule.split('.')[0]}`)
94      return { deny: `judy-guard: ${rule}` }
95    }
96
97    // 1. The court code stays in the chat.
98    if (code.length >= 6 && (cmd || path) && textOf(call).toUpperCase().includes(code.toUpperCase())) {
99      return deny("That would put the court code into a file, a commit or a log. It goes in the chat only; scripts read it from JUDY_EVENT_CODE or the Keychain (security find-generic-password -s JUDY_EVENT_CODE -w, piped or captured).")
100    }
101
102    if (cmd) {
103      // 2. No Keychain secret printed into the transcript.
104      if (printsKeychainSecret(cmd)) {
105        return deny('That prints a secret into the transcript. Pipe it (… -w | wrangler secret put NAME) or capture it ("$(security find-generic-password -s NAME -w)"), and never cat ~/.config/judy/* or .dev.vars.')
106      }
107      // 3. Judy deploys through its gate.
108      if (/\bwrangler\b[^;&|\n]*\bdeploy\b/.test(cmd) && !/--dry-run/.test(cmd) && !/npm run deploy/.test(cmd) && inRepo('judy-the-judge', cmd, cwdOf(call))) {
109        return deny('Deploy Judy with npm run deploy (typecheck, tests, the eval gate, D1 migrations, then wrangler deploy), never a bare wrangler deploy.')
110      }
111      // 4. Jev only through jev-gateway.
112      if (TYPESAFE.test(cmd) && !inRepo('jev-gateway', cmd, cwdOf(call))) {
113        return deny('Jev goes through jev-gateway only (POST /v1/systemone with JEV_GATEWAY_TOKEN), never TypeSafe directly or with a TypeSafe key.')
114      }
115    }
116
117    if (path && !DOCS.test(path) && !/(^|\/)jev-gateway\//.test(path) && TYPESAFE.test(textOf(call))) {
118      return deny(`${path}: Jev goes through jev-gateway only (POST /v1/systemone with JEV_GATEWAY_TOKEN), never TypeSafe directly, a typesafe:/ model or a TypeSafe key.`)
119    }
120
121    return next(e)
122  }).catch(($, e, next) => {
123    $.ui.status('judy-guard: a check failed; the call went through unchecked')
124    return next(e)
125  })
126}
127