SLOPSHOPPER

sec-default

Security default: from the outermost seat, continues past the user tier on the organization's classic hooks, prompt content, settings and subjects, refuses a…

newpromptagents
A shopper browsing a rack in a slop shop
README

sec-default

The security default for organizations. Function hooks give every plugin a say on every event, in chain order, and the plugins a person installs sit in the user tier, beneath the organization's prepend tier and above its append tier. Some of what an organization sets today (its classic hooks, its managed X_CODER.md and rules, its settings, its MCP allowlist) was never within a person's reach before function hooks; seated outermost, this plugin keeps exactly those out of the user tier's reach and adds no policy of its own. Everything else passes through untouched.

It has three moves and nothing else: continue past the user tier (next.to(e, "append")), refuse a user-tier caller by name ({ deny } when next.origin.tier is user), or pass (next(e)). A subject's provenance is the event's pinned e.provider; policy is read through $.settings.read({ source: "policy" }), one read serving a burst; both fail closed, so an unreadable policy counts as a policy in force.

hooks/register.ts is the module; hooks/policy/ reads the managed settings it decides by.

The rows

eventfrom the outermost seat
classic.*Continue past the user tier: the organization's settings hooks see the engine's input and their answer stands.
prompt.section, prompt.context, skill.prompt, attribution.textContinue past the user tier: managed X_CODER.md, rules and policy skills reach the model as written. A person's plugins keep prompt.submit and its additive context.
settings.readContinue past the user tier: no user hook rewrites what any caller reads as settings, this plugin's own policy reads included.
tool.describe, command.describe, agent.offer, agent.spawnWhen the subject's pinned e.provider.tier is prepend or append (a policy-installed plugin, the managed folder, a policy MCP server), continue past the user tier; a subject provided by user, builtin or core passes.
tool.registerA caller in prepend or append continues past the user tier. A user-tier caller is refused by name while managed settings hold allowedMcpServers (set at all, empty included); otherwise it passes.
tool.listThe tools of the organization's managed MCP servers are listed as the organization's tiers listed them; every other tool as the user tier left it. With no policy to read, or a refusal from either listing, the organization's listing stands whole.
everything elsePasses: prompt.submit, turn.*, tool.call, tool.check, command.run, command.register, session.*, ui.*, fs.*, http.fetch, process.run, store.*, clock.*, model.*, mcp.call, audio.*, agent.list, engine.create.

What it hooks

classic.*, prompt.section, prompt.context, skill.prompt, attribution.text, settings.read, tool.describe, command.describe, agent.offer, agent.spawn, tool.register, tool.list.

What it calls on $

settings.read. It continues to the append tier with next.to, which only a plugin in a managed tier may do.

Where it is seated

The CLI seats it first in the prepend tier wherever hooks modules load on a machine with managed settings or for a Team or Enterprise organization, unless managed settings define prependPlugins: then that list is the whole prepend tier, and the organization names sec-default@builtin in it at the position it wants, e.g. "prependPlugins": ["acme-guard@acme-tools", "sec-default@builtin"], or leaves it out. It is a plugin folder like any other, but its one move that matters, next.to, is refused outside a managed tier, so loading it with --plugin-dir seats a plugin that can only pass.

Source 23 files
hooks/register.ts 62 lines
1import type { On } from 'x-coder'
2
3import { pastUsers } from './past-users'
4import Policy from './policy'
5import { TOOL_REGISTER_REFUSAL } from './tool-register-refusal'
6
7/**
8 * The built-in's hooks, seated outermost: each keeps one control an
9 * organization has today out of reach of the plugins a person installs.
10 *
11 * Three moves: continue past the user tier (`next.to(e, "append")`), refuse
12 * a user-tier caller by name, or pass. Provenance is the event's pinned
13 * `provider`; policy is `$.settings.read`, memoized per burst; fail closed.
14 *
15 * @param on the engine's registrar
16 */
17export function register(on: On) {
18  const readPolicy = Policy.createPolicyMemo(Policy.POLICY_MEMO_MS)
19
20  on('classic.*', ($, e, next) => next.to(e, 'append'))
21
22  on('prompt.section', ($, e, next) => next.to(e, 'append'))
23  on('prompt.context', ($, e, next) => next.to(e, 'append'))
24  on('skill.prompt', ($, e, next) => next.to(e, 'append'))
25  on('attribution.text', ($, e, next) => next.to(e, 'append'))
26
27  on('settings.read', ($, e, next) => next.to(e, 'append'))
28
29  on('tool.describe', ($, e, next) => pastUsers(e, next))
30  on('command.describe', ($, e, next) => pastUsers(e, next))
31  on('agent.offer', ($, e, next) => pastUsers(e, next))
32  on('agent.spawn', ($, e, next) => pastUsers(e, next))
33
34  on('tool.register', async ($, e, next) => {
35    const isOrgs =
36      next.origin.tier === 'prepend' || next.origin.tier === 'append'
37
38    if (isOrgs) {
39      return next.to(e, 'append')
40    }
41
42    const isRefused =
43      next.origin.tier === 'user' &&
44      (await Policy.decidedByPolicy(
45        readPolicy(() => $.settings.read(Policy.SOURCE)),
46        Policy.hasMcpAllowlist,
47      ))
48
49    return isRefused ? { deny: TOOL_REGISTER_REFUSAL } : next(e)
50  })
51
52  on('tool.list', async ($, e, next) =>
53    Policy.managedToolsRestored(
54      await readPolicy(() => $.settings.read(Policy.SOURCE)).catch(
55        () => undefined,
56      ),
57      await next.to(e, 'append'),
58      await next(e),
59    ),
60  )
61}
62
hooks/past-users/index.ts 6 lines
1export * from './past-users.js'
2export * from './provided'
3export * from './user-reachable-tiers'
4
5export * as default from '.'
6
hooks/policy/index.ts 9 lines
1export * from './create-policy-memo'
2export * from './decided-by-policy.js'
3export * from './has-mcp-allowlist.js'
4export * from './managed-tools-restored'
5export * from './policy-memo-ms.js'
6export * from './source.js'
7
8export * as default from '.'
9
hooks/tool-register-refusal/index.ts 4 lines
1export * from './tool-register-refusal.js'
2
3export * as default from '.'
4
hooks/past-users/past-users.ts 24 lines
1import type Provided from './provided'
2import { USER_REACHABLE_TIERS } from './user-reachable-tiers'
3
4/**
5 * What the `tool.describe`, `command.describe`, `agent.offer` and
6 * `agent.spawn` hooks do: an organization's subject continues past users.
7 *
8 * The subject is the organization's unless its pinned `e.provider.tier` is
9 * `user`, `builtin` or `core`: a policy-installed plugin (`prepend`,
10 * `append`), the managed folder, a policy MCP server, or no provider at all.
11 *
12 * @param e the event's input with its pinned `provider`
13 * @param next the hook's own continuation, handed whole
14 * @returns the result from append inward, or from every tier
15 */
16export function pastUsers<E extends Provided.Provided, R>(
17  e: E,
18  next: Provided.ProvidedNext<E, R>,
19): Promise<R> {
20  const isUsers = USER_REACHABLE_TIERS.includes(e.provider?.tier)
21
22  return isUsers ? next(e) : next.to(e, 'append')
23}
24
hooks/past-users/provided/index.ts 5 lines
1export type * from './provided.js'
2export type * from './provided-next.js'
3
4export * as default from '.'
5
hooks/past-users/user-reachable-tiers/index.ts 4 lines
1export * from './user-reachable-tiers.js'
2
3export * as default from '.'
4
hooks/policy/create-policy-memo/index.ts 4 lines
1export * from './create-policy-memo.js'
2
3export * as default from '.'
4
hooks/policy/decided-by-policy.ts 16 lines
1import type { Settings } from 'x-coder'
2
3/**
4 * A yes/no read off managed policy that fails closed: true (protect) when
5 * the read rejects or deciding throws.
6 *
7 * @param policy the memoized policy read (createPolicyMemo over the hook's
8 * `$.settings.read({ source: "policy" })`)
9 * @param decide the answer once the policy is read
10 * @returns what decide answers, else true
11 */
12export const decidedByPolicy = (
13  policy: Promise<Settings>,
14  decide: (policy: Settings) => boolean,
15): Promise<boolean> => policy.then(decide).catch(() => true)
16
hooks/policy/has-mcp-allowlist.ts 12 lines
1import type { Settings } from 'x-coder'
2
3/**
4 * Whether managed policy holds an MCP allowlist (allowedMcpServers set at
5 * all, empty included): the organization decides which servers add tools.
6 *
7 * @param policy the managed settings, as `$.settings.read` answers them
8 * @returns true when allowedMcpServers is in force
9 */
10export const hasMcpAllowlist = (policy: Settings) =>
11  Array.isArray(policy.allowedMcpServers)
12
hooks/policy/managed-tools-restored/index.ts 5 lines
1export * from './is-org-tool'
2export * from './managed-tools-restored.js'
3
4export * as default from '.'
5
hooks/policy/policy-memo-ms.ts 8 lines
1/**
2 * How long one policy read serves the decisions after it: a burst of
3 * `$.tool.list` and `$.tool.register` calls reads once.
4 *
5 * A settings change waits this long to be seen.
6 */
7export const POLICY_MEMO_MS = 500
8