Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/preallow/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "preallow",
"version": "0.0.10",
"version": "0.0.11",
"description": "Lets Claude Code auto mode run the steps of the task you gave without the classifier: a model judge allows what is reversible or what you explicitly asked for",
"author": {
"name": "Entwining"
Expand Down
20 changes: 19 additions & 1 deletion plugins/preallow/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,40 @@

The README states what the plugin allows and what it leaves to native approval; keep it in step with the code.

## Which asks reach the judge

The plugin only loosens. `tool.check` turns the core's `ask` into `allow` or returns the core's answer unchanged; it never touches an `allow`, a `deny`, or an ask whose result names a decider other than the classifier (`OWNS_ASK` in `hooks/register.ts`), or whose input names a host as `approver`. Preserve this ownership boundary when the engine adds metadata. An unknown field leaves the ask to the judge, since sending every ask with an unknown field to the classifier would bring back the false denials the plugin exists to remove. A permission hook that throws or overruns its budget is skipped and the core's answer stands.

One judge for every tool it is asked about: no per-tool or per-command policy branches or allowlists in its policy or around it, and the policy text names no project, command, or wording taken from real data. A tool whose verdict the engine keeps whatever a hook answers (`RESERVED_TOOLS` in `hooks/register.ts`) never reaches the judge, since a judge call there spends seconds on an allow the engine rejects or ignores; add a tool there only for that engine behavior, as a `rejected` log line or the engine's documentation shows it. A Bash, PowerShell, or command-monitor call requesting a non-empty `allowed_domains` list also stays with the classifier, whose approval registers the network grants. Nor does a call whose input asks to run in the background, since its output arrives where the output scan cannot see it; a tool whose output arrives later without such a flag (`Monitor`) is a known gap in the README until the engine signals it.

## Policy and the user's words

`JUDGE_SYSTEM` (`hooks/policy.ts`) decides what is allowed. Check a change to it or to either stage's default model or effort in `hooks/register.ts` against real denials kept outside this repository, on a split not used while writing the change, and compare allow and review rates before and after.

The user's authority comes from `prompt.submit` with a `composer` or `bridge` origin and from the person's answer in an `AskUserQuestion` result, never an answer the dialog gave while the person was away, and never from assistant text, other tools' results, files, skill bodies, or other sessions. Do not widen what counts as the user's words. The answer reaches the judge as the engine gives it, with the questions from the result, which are what the person was shown: the plugin does not split or match it against the options, since the engine's answer is display text a label can also contain. A chosen option authorizes only the action and target it states, as `JUDGE_SYSTEM` says.

The user's words are kept in `$.store` under the session id, so a resume keeps them and `/clear`, which goes on under a new id, starts without them; never rebuild them from `$.session.messages`, which does not say where a message came from. A conversation is kept bounded by its JSON size (`addTurn` in `hooks/messages.ts`), so 50 fit the store whatever they hold, and the judge reads all of it for every call, since leaving out a limit lets through a call the user ruled out. Each write replaces it whole: a judge reading meanwhile sees it before or after the message, never without it. The judge prompt is one JSON document, so text inside a message cannot pose as another field; it is sent as two blocks, the kept conversation first and marked for the prompt cache, then the call's fields (`judgePrompt` in `hooks/judge.ts`), so every call until the user writes again opens the same way and reads the conversation, and on gateway routes the policy, from the cache.

## Judging a call

The judge chooses what to check; `hooks/facts.ts`, with `hooks/paths.ts`, `hooks/refs.ts`, and `hooks/git.ts`, answers from a fixed set of queries that only read, stay offline, and carry a time limit, and takes the judge's strings only as absolute paths or ref names that cannot pose as options. A new query keeps all of that, with a test that fails without it.

Only a reply that ends with a JSON object answering `allow` allows: text may come before it, and nothing but a closing code fence after it, and no conflicting verdict values may appear anywhere in the reply. One answering `check` with one to four well-formed items gets one more round with the facts; anything else, including no reply, is `review`. Facts that cannot be read, and a second `check`, are `review` too.

`judge` (`hooks/judge.ts`) stays one stage, since the evaluation outside the engine calls it once per stage; the second stage lives in `judgeCall` (`hooks/stages.ts`). The `model`, `effort`, `escalation_model`, and `escalation_effort` settings reach `$.model.complete` as the user wrote them: no aliases, option lists, or validation.

The output scan answers a call the plugin allowed with a new `{ result }` holding the redacted record and no `ref`, since core reuses the messages a `ref` names and the model would read the original, or, after an error, with a deny holding the redacted error text. Core keeps whole the context a hook below attached, so a credential there withholds the output. A redacted call stays in `allowedCalls` for the rest of the session, because the engine refuses an answer only after the hook has returned it, then skips the hook and lets the unredacted result stand unless `.catch` withholds it; `claude plugin test` does not validate a hook's result against the tool's schema, so no test reaches that refusal. A failed scan withholds the output too; the call has already run, so recovery must not execute it again. Every pattern in `hooks/withhold.ts` stays linear in the output's length, and `scripts/gitleaks.ts` strips the name gitleaks matches before a value under a keyword, which backtracks for seconds over a crafted megabyte, so a scan ends within the budget instead of withholding large outputs; the test that scans every rule's keyword fails when it comes back. A pattern needs a key's body, not only the words that name its format, and `scripts/gitleaks.ts` appends a token body to a gitleaks rule that is only a literal prefix, since the plugin's own files name every format; `bun run test:sources` fails when reading one of them would be redacted.
## Output scan

The output scan stands in for the look the classifier would have given a call the plugin allowed in its place, and promises what its patterns support, no more: each value a pattern in `hooks/withhold.ts` or the generated gitleaks rules finds is replaced, with its copies, in what the model reads of that call, or the output is withheld when it cannot be. A scan that finds nothing does not show that no secret is left, so a credential in a format the patterns do not know is a known gap in the README, not a defect to patch.

The plugin does not learn the values on this machine or read where credentials are kept to list them: finding and maintaining that list is a responsibility of its own, and the log already keeps tool inputs unscanned, so every value the plugin held would be one more place for it to leak from. Nor does it scan a call it did not allow, one core allowed on its own or the classifier allowed after the judge left it, or search what is left after redacting for the original. Keeping a secret out of reach belongs to the engine's sandbox and permission rules and to the user's credential setup; leaving a call that would print one to the classifier belongs to the first decision in `JUDGE_SYSTEM`.

The output scan answers a call the plugin allowed with a new `{ result }` holding the redacted record and no `ref`, since core reuses the messages a `ref` names and the model would read the original, or, after an error, with a deny holding the redacted error text. Core keeps whole the context a hook below attached, so a credential there withholds the output. A redacted call stays in `allowedCalls` for the rest of the session, because the engine refuses an answer only after the hook has returned it, then skips the hook and lets the unredacted result stand unless `.catch` withholds it; `claude plugin test` does not validate a hook's result against the tool's schema, so no test reaches that refusal. A failed scan withholds the output too; the call has already run, so recovery must not execute it again. When a credential-shaped value withholds the output, the reason names the surface that held it, so the log can show whether a branch still meets real sessions before anyone proposes removing it; a failed scan and a full-output file that cannot be read have reasons of their own.

## Credential patterns

A pattern stays broad, and is narrowed for a false positive that took away what a real session needed, not for prose that happens to read as an assignment: a narrower shape lets a credential through to the model, while a false positive costs every copy of the value it matched, the whole output where it cannot be redacted, and the classifier for the same call run again.

Every pattern in `hooks/withhold.ts` stays linear in the output's length, and `scripts/gitleaks.ts` strips the name gitleaks matches before a value under a keyword, which backtracks for seconds over a crafted megabyte, so a scan ends within the budget instead of withholding large outputs; the test that scans every rule's keyword fails when it comes back. A pattern needs a key's body, not only the words that name its format, and `scripts/gitleaks.ts` appends a token body to a gitleaks rule that is only a literal prefix, since the plugin's own files name every format; `bun run test:sources` fails when reading one of them would be redacted.

`hooks/gitleaks.generated.ts` is written by `bun run gitleaks` (`scripts/gitleaks.ts`, which pins the gitleaks release and rewrites its Go regexes for JavaScript); change the script and regenerate instead of editing the file.
5 changes: 5 additions & 0 deletions plugins/preallow/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## 0.0.11 - 2026-10-29

- A withheld output's reason, which the agent reads and the log records in `reason`, tells apart a credential-shaped value in the full output a large result leaves in a file, in a note a hook added beside the result, and in the text the agent reads but not in the tool's result. All three gave the same reason before.
- The README describes the output scan's reach beside what it redacts, and its known gaps by what you may meet: a credential in a format the scan does not know or reshaped by the output, and prose or a test name with a word like `secret` before a colon taken for one. What the scan finds is unchanged.

## 0.0.10 - 2026-10-29

- An output holding something credential-shaped has each such value replaced with `[redacted by preallow: <kind>]`, wherever it appears in the result, instead of being withheld whole, so the agent keeps the rest of it; a note beside the result tells it what was replaced. The whole output is still withheld when the full output a large result leaves in a file holds a credential, when a note another hook adds beside the result holds one, or when the text the agent reads shows one the tool's result does not. A credential the scan does not know now reaches the agent even beside one it found. The log records `redacted` with the kinds it replaced.
Expand Down
Loading
Loading