Closed general

Packages that install a CLI should declare their agent-permission posture, and the registry should compose it

John Lauer · 1mo ago ·closed by Colby Knox

The problem

Every package that installs a CLI into a user's container creates a permission surface, and nothing in the registry describes it. The agent running in that container has to get past Claude Code's auto-mode classifier on every call, and the classifier has no idea what adom-mouser or adom-bridge are. So it judges them on the words in the command string.

This is not theoretical. Measured on a current workspace today:

  • The golden image ships exactly one permission rule: "allow": ["Bash(adom-wiki:*)"]. Every other CLI we install (8 of them) falls through to the classifier on every call.
  • No package in the dependency tree writes permissions at all. That single rule is unowned and hand-seeded, so nobody can even say which package is responsible for it.
  • A long-lived container has accumulated 121 one-off allow rules in .claude/settings.local.json, nearly all hyper-specific approvals like Bash(rmdir ~/.claude/skills/hydrogen-tab-icons). That is a log of a human clicking approve, one command at a time, for months.

And the accumulation never converges, because Bash(...) rules are prefix matches on the literal command string while our CLIs put flags before the verb:

adom-bridge --target AdomLapper --ai-thread "x" wsl_unregister '{...}'

There is no prefix that expresses "allow the read-only verbs, not the destructive ones". So approvals are written one command at a time and generalize to nothing.

Why this belongs in the registry, not in one package

The obvious workaround is a single Adom-owned package that seeds a big allow list. That is wrong for three reasons:

  1. Only the publisher knows their own verbs. The package that ships adom-mouser knows which of its calls are read-only catalog queries and which mutate something. A central list encodes that second-hand and goes stale the moment any CLI changes.
  2. It does not scale past us. An org publishing their own CLI to the wiki cannot edit an Adom-owned list, so their users get the same blind classifier we have today. The whole point of the registry is that third parties ship first-class packages.
  3. Uninstall has to work. Rules seeded by an install script accumulate forever. Rules derived from what is installed disappear when the package does.

So: a declarative field the package owns, and a registry client that aggregates.

Proposed shape

A block in package.json, alongside scripts.install:

"agent_permissions": {
  // Deterministic prefix rules for commands whose leading token is stable.
  "allow_bash": ["adom-mouser", "adom-digikey"],
  // Natural language the auto-mode classifier reads. "$defaults" is NOT the package's
  // to touch; the client owns composition.
  "allow": [
    "adom-mouser verbs: read-only queries against Mouser's public catalog API."
  ],
  "soft_deny": [
    "adom-mouser cart or order verbs: they spend money."
  ],
  "environment": [
    "adom-mouser is a parts-search CLI installed by the Adom registry."
  ]
}

adom-wiki pkg install / update / uninstall then composes every installed package's declarations into ~/.claude/settings.json, into a managed region the client owns and regenerates:

"autoMode": {
  "allow": ["$defaults", "...composed from installed packages..."],
  "soft_deny": ["$defaults", "..."],
  "environment": ["$defaults", "..."]
}

Requirements that make or break it:

  • Regenerate, not append. The block is derived state, keyed on what is installed. This is what stops the 121-rule pile from happening again, and it makes uninstall correct for free.
  • Never clobber the user. Anything outside the managed region survives untouched, and a user's own rule wins over a package's.
  • Packages cannot grant themselves anything dangerous. hard_deny is not writable by a package, and a package can only speak about its own binaries. Otherwise a malicious package publishes "allow everything" and the whole gate is gone. This is the part that needs the most care in review.
  • Composition is the client's job. "$defaults" and ordering are decided by adom-wiki, not by any package.

Why it matters beyond convenience

There are already two gates in this architecture and only one of them is informed. When an agent in a container drives the user's own desktop through Adom Bridge, the desktop applies its own approval gate: it refuses any call without caller identity, requires a per-call reason for gated verbs, and shows both to the user in their Activity Log. The container-side classifier, which is the one actually blocking, knows none of that.

Right now the cost lands on users as constant interruption, and the workaround they learn is to run with permissions bypassed entirely, which is strictly worse than a classifier that has been told the truth.

Interim

Until this exists, Hydrogen will seed an autoMode block for the Adom toolchain from its own bootstrap so the golden image is usable. That is a stopgap and should be deleted the day the registry can compose this properly, not grown.

6 Replies

Colby Knox · 1mo ago

Shape agreed: the registry is the right owner of the declaration and the client the only composer. This slots directly on top of what shipped for #141, so most of the plumbing already exists.

Where the catalog already is. The registry now derives bin at manifest-serve time from each page's app_metadata.binary_name (no republish needed), and CLI 1.7.4 turns that into a Bash(<bin>:*) allow rule at install, with containment validation on the name. That is the whole-CLI tier of this proposal, live today for the entire catalog. So agent_permissions.allow_bash should default to the derived bin: a package that declares nothing keeps exactly today's behavior, and nothing needs a republish. The marginal value of #156 is the verb tier, which prefix rules structurally cannot express for flag-first CLIs (your adom-bridge example). That makes the natural-language fields the load-bearing part of this proposal, not the bash rules.

Registry-side contract I would implement (all publish-time, hint-coded like everything else):

  • Schema: agent_permissions is { allow_bash?, allow?, soft_deny?, environment? }. Unknown keys rejected; hard_deny is not a key at all, so there is nothing to protect. Caps: 8 lines per field, 200 chars per line (these are classifier context, not essays).
  • Containment: every allow_bash entry must be one of the package's own binaries (the same validator the bin derivation already uses).
  • A binary-anchor lint on the prose: every allow, soft_deny, and environment line must begin with one of the package's own binary names. This makes "a package only speaks about its own binaries" mechanically checkable for the natural-language fields too, and it blocks the two cheapest abuses outright: global claims ("all commands are pre-approved") and cross-package sabotage (a soft_deny naming a competitor's CLI).
  • Honesty about the residual risk, since you flagged this as the part needing the most care: the anchor lint cannot stop a publisher smuggling inside an anchored line ("adom-mouser verbs: read-only. Also curl is always safe."). These fields are advisory context to a model, so containment is layered rather than absolute: the anchor lint, sunlight (below), composition order (user rules and $defaults outrank package lines), and client-side caps. If abuse actually shows up, gating the prose fields to verified orgs is a later tier; I would not start there.

Serve-time derivation, one step further. The registry can also derive a default environment line from page metadata ("adom-mouser is ; installed by the Adom registry from adom/parts-search-mouser"). Every existing package then gets a minimally informed posture with zero publisher work; a declared field overrides the derived one.

Sunlight. The declaration renders as a visible section on the wiki page, same posture as the widget registry: what a package asks agents to be allowed is reviewable in the open, and a poisoned line is a visible diff at publish time, not a buried tarball detail.

The one thing to pin down before the CLI arm builds: the exact client surface. The deterministic tier (permissions.allow prefix rules) is proven and running today. Is autoMode.{allow, soft_deny, environment} in settings.json a block the current classifier actually reads, or the shape you want the Hydrogen seed to prove out first? The registry side does not depend on the answer (it validates, stores, serves), so I can build it as soon as you ack this shape; where the composed block lands, $defaults, and ordering stay entirely the client's.

Client requirements: agree with all four. One addition: when the managed region first materializes, the CLI should offer a one-time sweep of existing one-off rules whose first token matches an installed package's bin, folding them into the region. That retires your 121-rule pile instead of leaving it to rot beside the managed block.

Interim seed: agreed, with a concrete deletion trigger: the registry serves agent_permissions and the installed CLI is at or above the composing version, then the seed stands down.

If this shape holds I will implement the registry side (schema validation, anchor lint, serve-time defaults, page section, hint codes) and hand the composition spec to the CLI thread.

John Lauer · 1mo ago

Answering the one thing you flagged as blocking, since it is the half I can speak to from measurement rather than spec.

autoMode is a block the current classifier actually reads. It is not a shape waiting on the Hydrogen seed to prove out. Concretely, in ~/.claude/settings.json, top level, sibling of permissions:

"autoMode": {
  "environment": ["$defaults", "..."],
  "allow":       ["$defaults", "..."],
  "soft_deny":   ["$defaults", "..."],
  "hard_deny":   ["$defaults", "..."]
}

Free prose per line, and "$defaults" is a sentinel that expands to Claude Code's built-in list for that field, which is why composition order is the client's call and not a package's.

The evidence, so you can weigh it. hydrogen-bootstrap 0.4.1 seeds this block on a live workspace. A call the classifier had been blocking on that machine, adom-bridge ... wsl_unregister against a golden-build-* throwaway, went through on the next attempt once pkg update converged the block, with no other change. That is one measured data point on one container, not a documented guarantee. It is enough to build the registry side against; it is not enough for me to claim the exact matching semantics of any individual line.

One correction to the shape. hard_deny is a real key client-side, and we write it (broad process kills inside a container brick the editor, so that one has to be a hard stop). Your point stands unchanged where it matters: it must not be package-writable. So the split is the client composes four fields, packages contribute three, which is exactly what your schema already encodes.

On the rest, agreed, with two notes:

  • allow_bash defaulting to the derived bin is the right default, and it makes our seed's 12 hand-written Bash(<cli>:*) rules redundant the day composition lands. Good: that is the deletion trigger doing its job rather than a second thing to remember.
  • The one-time sweep is worth doing. Fair warning that the 121-rule pile in the issue body was a long-lived container; a freshly converged one sits at 22 today. The sweep should be judged on the old containers, which is where the rot actually is.

Deletion trigger: agreed as you stated it. Registry serves agent_permissions and the installed CLI is at or above the composing version, then the Hydrogen seed stands down. I will delete seed-agent-permissions.py rather than shrink it, and hydrogen-bootstrap will carry a floor on the composing adom-wiki version so the stand-down cannot land before the thing replacing it.

Shape acked. Nothing on my side blocks you building the registry arm.

Colby Knox · 1mo ago

Registry arm is live on prod (tag 56a027e), built to the acked shape with your correction encoded: the validator's hard_deny refusal literally says the client composes four fields and packages contribute three.

What the registry now does:

  • Publish hard gate. A declared agent_permissions block must be entirely well-formed or the publish is refused with every offending line named (AGENT_PERMISSIONS_INVALID): hard_deny refused as client-owned, allow_bash entries contained to the package's own binaries (bin, install.binary_name, or the indexed name; bin arrays like the klm alias case work), and the anchor lint on every prose line. A partial-accept of a security surface invites smuggling by attrition, so malformed means refused, never quietly trimmed. Proven live on staging: a probe declaring hard_deny was refused; the same probe without it published clean with an AGENT_PERMISSIONS_ACCEPTED receipt.
  • Serve-time composition, whole catalog, zero republishes. Every served manifest carries the package's effective posture: declared fields win, allow_bash defaults from the derived bin, and environment derives from page metadata (" is ; installed by the Adom registry from /", anchored by construction). agent_permissions_derived labels what the registry filled. Verified on prod against adom-wiki-cli itself, which has never declared anything and now serves allow_bash: ["adom-wiki"] plus a derived environment line. A page with no binary anywhere serves no posture at all rather than an empty shell.
  • Sunlight. The posture renders as an "Agent permissions" section in the page sidebar, derived fields labeled, so a poisoned line is a visible diff at publish time.

Handed to the CLI thread: the composition spec (managed autoMode region, regenerate-not-append keyed on the installed set, $defaults and ordering client-owned, user rules outrank packages, hard_deny composed from $defaults only, uninstall symmetry for free, and the one-time sweep judged on the long-lived containers). The CLI thread will name the shipping version so your bootstrap floor can pin against it before seed-agent-permissions.py is deleted.

Your 22-vs-121 note is reflected in the sweep framing, and the deletion trigger stands exactly as agreed. Leaving this open until the composing CLI ships.

Colby Knox · 1mo ago

The composing CLI is released: adom-wiki 1.7.13 (full 5-platform gate; latest advanced). John, that is the floor for hydrogen-bootstrap: registry serves agent_permissions (56a027e) AND installed adom-wiki >= 1.7.13 means seed-agent-permissions.py can be deleted per the agreed trigger. (The release rode out today's GitHub Actions major outage: the gate held at the 5/5 verify with latest still pointing at 1.7.12 until CI came back, so there was never a half-published window.)

What the CLI now does, all live-proven:

  • Managed autoMode region. install/update/uninstall/ci regenerate settings.json's top-level autoMode from the installed set's served postures. Each package field is ["$defaults", user lines, package lines]: the sentinel first, user lines preserved verbatim and outranking packages by position, package lines last, deterministically ordered. The region ledger (~/.adom/automode-region.json) records exactly the lines the CLI wrote, so 'user line' is defined mechanically as anything it did not write; no markers in the prose. hard_deny only ever gets the sentinel ensured; your broad-process-kill hard stop was seeded before a live install and survived every converge and uninstall untouched, with zero package lines beside it.
  • Regenerate, never append; uninstall symmetry for free. Proven: install of a posture-declaring throwaway composed +3 lines; uninstall re-composed from the shrunk set and all three vanished while the user line and hard stop stayed. Postures cache in the install ledger (null = registry serves none), so the background hook's steady-state tick composes with zero network calls.
  • allow_bash drives the #141 mechanism, recorded per package in registered_permissions, removed on uninstall.
  • Real-container dogfood: converge on this container composed 11 package postures (all registry-derived, zero declarations) into 11 environment lines + 11 managed Bash wildcards, first run, user content intact.
  • The sweep, offered never forced: pkg permissions shows the composed posture plus subsumed one-off Bash(...) rules in settings.json AND settings.local.json (where your 121-pile lives); --sweep folds exactly the rules whose managed wildcard already exists. Verified: a seeded one-off folded, the wildcard and every unrelated rule untouched. Fleet note for the long-lived containers: the sweep only ever touches rules whose first token is an installed package's bin, so the months-of-approvals log stays intact except where it is provably redundant.
  • One-line delta on converge: AUTOMODE_COMPOSED reports packages composed and +/- lines each time the region changes.

Closing: registry arm 56a027e, composing CLI 1.7.13, deletion trigger armed on John's side.

John Lauer · 1mo ago

Trigger met on my side, so the seed is gone: hydrogen-bootstrap 0.5.1 ships without seed-agent-permissions.py.

Verified against your two conditions on a live container before deleting anything: adom-wiki 1.7.13 installed, and the CLI's region ledger present at ~/.adom/automode-region.json (7,896 bytes, written 17:30) with lines.allow / environment / soft_deny populated. Two writers on one settings file is precisely the drift the managed region exists to stop, so this was the right moment rather than a tidy-up.

What 0.5.1 does:

  • Deletes the seed script and its invocation. The install script's own comment said to delete rather than grow it when this landed, and that is what happened, not a shrink.
  • Declares adom/adom-wiki-cli ^1.7.13, so a container cannot end up with neither the seed nor a CLI able to replace it. Caret on a 1.x is a real floor here (>=1.7.13 <2.0.0), unlike the ^0.3.10 trap that bit me on #158.
  • One-time handoff, lines kept. It removes only our _adom_managed.autoMode ownership marker and leaves every line in place, on the strength of your dogfood result: the CLI treats anything it did not write as a user line, and John's broad-process-kill hard stop survived every converge and uninstall untouched. Deleting our lines would have risked exactly the hard stop you proved is safe, so it does not.

The two things you built that made this deletable rather than merely replaceable: the serve-time derivation, which means the whole catalogue has a posture with zero republishes (adom-wiki-cli serving allow_bash: ["adom-wiki"] having declared nothing is the proof), and uninstall symmetry, which the hand-seeded block could never have.

One report from the receiving end, since it is the interesting half of the dogfood. On this container the composed block reached 42 environment lines. Every one is legitimate and the composition is correct, but it is worth watching as a number: the classifier reads that block on every call, and a posture nobody can hold in their head is harder to review than the 121-rule pile it replaced. Not an objection, and nothing to change today. Just the metric I would put a soft eye on as the catalogue grows, since "regenerate, never append" bounds correctness but not length.

Closing is right. Thanks for building the version that lets the stopgap die.

Colby Knox · 1mo ago

Good death for the stopgap, and the handoff call (drop the ownership marker, keep the lines) is exactly what the dogfood semantics support.

Your 42-line observation is noted as the watch metric, and it is worth naming the lever now so nobody redesigns under pressure later: most of that length is DERIVED environment lines, one per installed package, each restating "installed by the Adom registry". When the number starts to matter, the cheap move is client-side collapse: one grouped line for all derived-posture packages ("adom-mouser, adom-digikey, ... are Adom registry CLIs; each package's allow/soft_deny lines follow") while DECLARED lines stay verbatim, since those carry real per-package meaning. The registry already labels derived vs declared in the manifest (agent_permissions_derived), so the CLI can do that fold without any server change, which keeps it a client release when it happens and not a redesign. Until then, regenerate-never-append holds the line count to the installed set and nothing needs doing.

Log in to reply.