SLOPSHOPPER

admin-capability-lockdown

Organization control mod: withholds the http and process nouns from $ so no plugin beneath it can reach the network or spawn processes, refuses user-tier…

newguard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · admin-capability-lockdown
› 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) ⎿ Denied by admin-capability-lockdown: The Bash tool is disabled by your organization's admin-capability-loc ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

admin-capability-lockdown

An organization-level mod that (1) withholds the http and process nouns from $ so no plugin seated beneath it can reach the network or spawn processes, (2) refuses plugins at plugin.register by name allowlist and by the $ calls their source declares, and (3) optionally withholds or guards the Bash tool.

Only (1), (2) and the "deny" shell policy are real boundaries. The "guardrail" shell denylist is bypassable by design and is labelled as such.

SEATING. Withholding and refusal only bind the plugins BENEATH this one. Put it in the prepend tier through managed settings, e.g.

"prependPlugins": ["admin-capability-lockdown@acme-tools", "sec-default@builtin"]

so it is outermost: its engine.create step returns last (its withholding wins) and it judges every plugin.register after it. Loaded with --plugin-dir it sits in the user tier and only binds plugins listed after it.

If a check itself fails (it throws or runs out of its time budget), a user plugin is refused and a guardrail-mode command is denied, never let through unchecked. The engine.create step has no time budget; a failure there fails the load.

Options

  allowedPlugins: string  plugin names that may register, comma-separated (unset = any name)
  refuseCalls:    string  a user-tier plugin whose source calls any of these is refused, comma-separated
                            (default: the withheld nouns' calls, e.g. "http.fetch", "process.run")
  shellPolicy:    "deny" | "guardrail" | "allow"  (default "deny")

Declared in .claude-plugin/plugin.json (userConfig). Set them in /config, in user settings (~/.claude/settings.json, not project settings), with --settings <file> or in managed settings:

{ "pluginConfigs": { "admin-capability-lockdown@skills-dir": { "options": { } } } }

Install

npx claude-code-templates@latest --mod enterprise/admin-capability-lockdown
claude

It is written to .claude/skills/admin-capability-lockdown/, which Claude Code auto-loads as admin-capability-lockdown@skills-dir. For one session with hot reload: claude --plugin-dir .claude/skills/admin-capability-lockdown. claude plugin validate .claude/skills/admin-capability-lockdown prints every event it hooks and every $ call it makes.

Requirements. Mods are on by default in Claude Code 2.1.287+. Typed against Anthropic's declarations: https://github.com/anthropics/claude-code/tree/main/mods

Source 1 files
hooks/admin-capability-lockdown.ts 130 lines
1/**
2 * admin-capability-lockdown — Claude Mod
3 *
4 * An organization-level mod that (1) withholds the `http` and `process`
5 * nouns from `$` so no plugin seated beneath it can reach the network or
6 * spawn processes, (2) refuses
7 * plugins at `plugin.register` by name allowlist and by the `$` calls their
8 * source declares, and (3) optionally withholds or guards the Bash tool.
9 *
10 * Only (1), (2) and the "deny" shell policy are real boundaries. The
11 * "guardrail" shell denylist is bypassable by design and is labelled as such.
12 *
13 * SEATING. Withholding and refusal only bind the plugins BENEATH this one.
14 * Put it in the prepend tier through managed settings, e.g.
15 *   "prependPlugins": ["admin-capability-lockdown@acme-tools", "sec-default@builtin"]
16 * so it is outermost: its `engine.create` step returns last (its withholding
17 * wins) and it judges every `plugin.register` after it. Loaded with
18 * --plugin-dir it sits in the user tier and only binds plugins listed after it.
19 *
20 * Needs Claude Code >= 2.1.287. Typed
21 * against Anthropic's declarations: https://github.com/anthropics/claude-code/tree/main/mods
22 *
23 * Options:
24 *   allowedPlugins: string  plugin names that may register, comma-separated (unset = any name)
25 *   refuseCalls:    string  a user-tier plugin whose source calls any of these is refused, comma-separated
26 *                             (default: the withheld nouns' calls, e.g. "http.fetch", "process.run")
27 *   shellPolicy:    "deny" | "guardrail" | "allow"  (default "deny")
28 */
29import type { Register } from 'claude-code'
30
31/** A list option: a string[] or a comma-separated string (what a manifest's `userConfig` string field holds); empty means unset. */
32function strings(value: unknown): string[] | undefined {
33  const list = Array.isArray(value)
34    ? value.filter((v): v is string => typeof v === 'string')
35    : typeof value === 'string'
36      ? value.split(',').map((s) => s.trim()).filter(Boolean)
37      : []
38  return list.length > 0 ? list : undefined
39}
40
41export const register: Register = (on, options) => {
42  const withhold = ['http', 'process'] // fixed, see the engine.create step below
43  const allowedPlugins = strings(options.allowedPlugins) // undefined = allow any name
44  const refuseCalls = strings(options.refuseCalls) ?? withhold.map((noun) => `${noun}.`)
45  const shellPolicy =
46    options.shellPolicy === 'guardrail' || options.shellPolicy === 'allow' ? options.shellPolicy : 'deny'
47
48  // 1. Shape $ itself. Every plugin beneath has already added its nouns when
49  //    the built table comes back from next(e); return it without http and
50  //    process. The host's static scan admits only destructuring that value
51  //    and spreading the rest (a destructured noun may not be read again, and
52  //    engine.create may be registered once), so the withheld set is fixed
53  //    here: to withhold a different set, edit this one line.
54  on('engine.create', async ($, e, next) => {
55    const { http: _http, process: _process, ...rest } = await next(e)
56    return rest
57  })
58
59  // 2. Decide which plugins may join the chain. `e.uses.calls` is the host's
60  //    static scan of the module's `$.noun.event(...)` call sites, so a
61  //    user-tier plugin that reaches for a withheld noun is refused up front
62  //    instead of failing at run time. Managed tiers are the org's own.
63  on('plugin.register', ($, e, next) => {
64    if (e.tier !== 'user') return next(e)
65
66    if (allowedPlugins && !allowedPlugins.includes(e.name)) {
67      $.ui.log(`[admin-capability-lockdown] refused plugin "${e.name}" (not in allowlist)`)
68      return { refuse: `Plugin "${e.name}" is not on the organization allowlist.` }
69    }
70
71    const reaching = e.uses.calls.find((call) =>
72      refuseCalls.some((rule) => (rule.endsWith('.') ? call.startsWith(rule) : call === rule)),
73    )
74    if (reaching) {
75      $.ui.log(`[admin-capability-lockdown] refused plugin "${e.name}" (calls $.${reaching})`)
76      return { refuse: `Plugin "${e.name}" calls $.${reaching}, which this organization withholds from user plugins.` }
77    }
78
79    return next(e)
80  }).catch(async ($, e, next) => {
81    if (e.tier !== 'user') return next(e)
82    // The check had passed: hand back what the rest of the chain decided (replayed, nothing runs twice).
83    if (next.called) {
84      try {
85        return await next(e)
86      } catch {
87        return { refuse: `Plugin "${e.name}" could not be registered.` }
88      }
89    }
90    // A user plugin that could not be checked is refused, never admitted unchecked (fail closed).
91    $.ui.log(`[admin-capability-lockdown] check failed (${next.error.kind}); refused plugin "${e.name}"`)
92    return { refuse: `Plugin "${e.name}" could not be checked against the organization's policy (${next.error.kind}), so it was not loaded.` }
93  })
94
95  // 3. Shell policy. The real security boundary is step 1 (no $.http /
96  //    $.process for plugins beneath). A Bash denylist can always be bypassed
97  //    with an unlisted client, quoting, or a Python one-liner, so it is NOT a
98  //    boundary:
99  //      "deny"      -> withhold the Bash tool entirely (the only mode that enforces "no egress")
100  //      "guardrail" -> keep Bash, deny the obvious network clients as a speed bump
101  //      "allow"     -> leave Bash alone
102  if (shellPolicy === 'deny') {
103    on('tool.call', { tool: 'Bash' }, () => ({
104      deny: "The Bash tool is disabled by your organization's admin-capability-lockdown mod.",
105    }))
106  } else if (shellPolicy === 'guardrail') {
107    const NETWORK_CLIENTS = /\b(curl|wget|nc|ncat|netcat|socat|ssh|scp|sftp|rsync|telnet|ftp|openssl\s+s_client)\b/i
108    on('tool.call', { tool: 'Bash' }, ($, e, next) => {
109      if (NETWORK_CLIENTS.test(e.command)) {
110        return {
111          deny: "Outbound network commands are disabled by your organization's admin-capability-lockdown mod (guardrail mode: not a hard boundary).",
112        }
113      }
114      return next(e)
115    }).catch(async ($, e, next) => {
116      // The check had passed and the command ran: hand back its result (replayed, nothing runs twice).
117      if (next.called) {
118        try {
119          return await next(e)
120        } catch {
121          return { deny: 'The Bash call failed.' }
122        }
123      }
124      // A failed check never lets the command through unchecked (fail closed).
125      $.ui.log(`[admin-capability-lockdown] guardrail check failed (${next.error.kind}); denied Bash call`)
126      return { deny: `admin-capability-lockdown could not check this command (${next.error.kind}), so it was not run.` }
127    })
128  }
129}
130