LAwLi3tCoding/dsh-approval-review

dsh-approval-review

新增「替我审批」访问模式:审批请求由独立的复核模型裁决而非人工,复核者可只读工作区,复核跑不起来时把请求交回人工,每次裁决的理由记录在「审批」页签中。

未分類ai-safetyapprovalauto-review

0.6.4: the install half leaves the transaction too

0.6.2 fixed only the removal direction, and the live host immediately showed the other half: an enable is an HMR transaction as well, so the mount-time write that restores a missing preset was rejected with HMR transactions cannot be nested (log: access-mode preset check failed — could not write preset "approve-for-me"). Both directions now share one transaction-free writer: leave the store through hmr.executing.exit(...), then call configEditor.edit(), which QUEUES behind the running reload instead of failing. The first attempt is no longer delayed (the menu should not wait for it) and the retries cover a row that is replaced mid-reload. Two regression tests join it: a write issued while the mount runs inside the store must succeed, and an unleaveable context must report the failure.

0.6.3: which build is running is now in the log

Diagnosing 0.6.1/0.6.2 surfaced another trap: inside one host process a reinstalled plugin reuses the module it first imported — a different install source or disk path changes nothing, and the mount log looked identical. A "fixed" version could therefore never have been loaded while the retest said otherwise. This release makes the running build visible (the ready line carries v0.6.3) and has the uninstall cleanup state whether the HMR transaction context could be left before it gives up. Restart DSH Desktop after upgrading the plugin; without a restart the new code is not loaded.

0.6.2: uninstall cleanup actually leaves the reload transaction

0.6.1 only deferred the write to a timer, and the live retest still failed: the host marks "inside a transaction" with an AsyncLocalStorage store, and Node propagates that store into timers created inside the transaction — so the write stays nested however long it waits, and all three retries hit the same lock. 0.6.2 leaves the context through hmr.executing.exit(...) before scheduling anything (falling back to the old behaviour when that service is absent), after which configEditor.edit() queues on the host's own serialized path. A regression test reproduces the mechanism with a real AsyncLocalStorage: the unmount happens inside store.run(true, …) and the write still has to succeed.

0.6.1: uninstall cleanup no longer fights the host's reload lock (incomplete, see 0.6.2)

The first real uninstall of 0.6.0 exposed an ordering bug: the uninstall runs inside the host's HMR transaction, and writing the preset back into the profile patch takes that same lock, so the deletion failed with HMR transactions cannot be nested and the menu kept "Approve for me". The removal now runs outside the transaction and retries (0.25s / 1s / 3s); only three blocked attempts leave the entry behind, with an explicit warning in the log and in /approval-review status.

0.6: the access-mode entry can no longer disappear silently

The "Approve for me" entry lives in the permission row's preset table, and an id-targeted override replaces that whole row — so any later layer that restates the table (typically a profile patch pinning its own defaultPreset) drops it without a word: the menu lists three modes, the plugin's gate key is not in the catalog, and it claims nothing. This release adds two guards: a mount-time catalog check that names the cause in the startup log and in /approval-review status, and (by default) a managed entry written into the profile patch through configEditor — present while installed, removed on uninstall, no stale option. For a read-only deployment, manageAccessModePreset: false warns without ever writing. See the access-mode entry's lifecycle.

dsh-approval-review

English | 简体中文

Codex-style agent auto-approval for DeepSeek Harness. When an action crosses a boundary that the sandbox does not cover on its own, a second, independent reviewer model reads the proposed action and returns a verdict — so a human handles fewer routine prompts. Model judgements can still be wrong. Each reviewed decision leaves a full rationale in a dedicated Approvals tab.

This plugin implements the shape of Codex's Auto-review: an interactive approval request is routed to a reviewer agent instead of a person, the reviewer answers with a structured verdict, a denial is handed back to the calling model as reasoning rather than as a bare error, and a per-turn rejection circuit breaker stops the agent from looping on escalation attempts.

It is a reviewer swap, not a permission grant. The plugin never widens a sandbox, never invents a grant, and never removes a human from a decision it was not configured to take over. Requests it does not own are delegated with next(), unchanged.

0.5: Jev as an optional reviewer engine

reviewer.engine now chooses who reviews: the original LLM route, or TypeSafe's Jev (System One). Jev is a decision model — it answers typed questions with probabilities instead of writing prose — so one review is a single HTTP call with no tool loop. Measured on this plugin's own case set: ~0.9 s per review against 4–12 s for the LLM reviewer, and 16/16 agreement with the expected policy outcomes. Everything downstream of the verdict — risk gate, breaker, cache, ledger, Approvals tab — is shared by both engines.

To use Jev:

  1. Store the key where the harness resolves credentials: the harness credential settings, or $DSH_HOME/.env as TYPESAFE_API_KEY=…. The plugin asks the credential store first (which itself layers the launch environment, the managed store and .env files) and falls back to the process environment; it re-resolves per review, so a rotated key applies to the next verdict with no restart.

  2. Turn the engine on in your profile patch. An id-targeted override replaces the whole row config, so restate any key you still want:

    - id: approval-review
      config:
        reviewer:
          engine: jev
          jev:
            allowEgress: true
    
  3. Restart the harness (the engine is read at mount) and run /approval-review status. It prints the engine, the access-mode gate and the model, so a silent ledger always has a visible cause.

Two guards are deliberate: allowEgress: true is required because Jev is a third-party endpoint and the evidence packet leaves the machine, and a selection that does not fit the engine in force is reported rather than forwarded. Pick any Jev model or LLM route from the Approvals tab — the choice carries its engine and applies to that session only. The full contract, including what each engine can and cannot do, is in Reviewer engines.

What it does

Official seamAn approval/request answerer registered with prepend: true, so it claims a request ahead of the human UI answerer, and delegates everything else back to the chain.
Second-model reviewDefault direct receives the policy, an explicit evidence packet and a bounded local read-only inspector. Optional subagent/spawn offers read-only investigation but retains DSH preset inheritance.
No silent failureA crashed, timed-out, truncated, or off-schema reviewer answer never becomes an approval: it yields the configured failure policy, which defaults to delegate — the request goes back to the human chain. Set onReviewerFailure: rejected for the fail-closed stance.
Rationale reaches the modelA denial's reason is appended to the refused tool result, with an explicit instruction not to pursue the same outcome through a workaround. An allow verdict rides the same channel (gated by recordAllowedVerdicts): the approval outcome is a closed vocabulary, so the tool result is the only place the plugin can write durably — without it the card can show that an action ran but never why.
Risk gateAn allow verdict above maxAutoAllowRisk does not auto-allow; it delegates to the human.
Circuit breakerConsecutive and rolling-window denial thresholds, matching Codex's per-turn breaker, which stop the host turn after the triggering denial has been recorded by default.
BudgetsA per-turn cap on reviewer calls, so a loop cannot bill unlimited reviews.
One-shot override/approval-review approve [n] records a human authorization for one retry. Bound to the same session, tool and byte-identical arguments; expires after five minutes by default and is not restored after restart. The reviewer still decides.
Fourth access modeAn 替我审批 ("approve for me") entry beside 仅可查看 / 工作区内修改 / 完全权限. It shares its sandbox and approval knobs with workspace-write on purpose — the difference is WHO answers — so the menu entry itself is the switch. PermissionPresetService.derive() checks the recorded selection first, which is what lets the two coexist and stay selected.
Verdict cacheDisabled by default because authorization and local state can change. Explicit opt-in is limited to direct review without recent transcript.
Failure budgetA per-turn cap on reviewer failures, so a broken reviewer cannot be retried without bound while the request waits.
Approvals tabA conversation tab rendering every request with its verdict, routing policy, risk, rationale, safer-alternative suggestion, reviewer route, timing, and the live budget/breaker state, plus working on/off and one-shot-approve buttons.

0.4 policy and compatibility

Risk and user authorization are assessed separately. Routine low/medium risk actions normally pass. High risk requires medium/high authorization, bounded scope and no prohibition; critical risk is denied. Escalation, outside-workspace paths and normal credential authentication are not intrinsically high risk. Missing evidence, truncated action arguments, timeouts and malformed answers delegate by default.

This is not a full Codex Guardian implementation. Default direct review can inspect local metadata, directory entries and text with at most four read-only calls; it delegates when evidence remains insufficient. The inspector permits workspace paths and exact outside paths named in the action, excludes credential stores, bounds reads to 16 KiB and listings to 100 entries, and never executes shell commands. Optional subagents still inherit DSH presets. The default breaker records the tool refusal, then cancels the host turn while retaining pending user input. Only host approval/request events are covered. Custom policyText replaces the semantic policy but cannot bypass the code-level critical-risk denial or high-risk authorization gate.

Upgrading changes the default reviewer mode, risk ceiling, breaker action and cache setting; explicit profile overrides still win. Recorded rationale retains its original language while UI labels follow the current locale.

Policy comparison and test evidence

Install

No build step on install. The repository carries the built bundles (lib/) and package.json declares no prepare script, because pnpm blocks a git dependency's prepare/install build scripts (ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED) and that fails the entire install. dsh plugin add github:... therefore works with no allowBuilds entry in the profile's pnpm-workspace.yaml.

You only build when working from a source clone: pnpm build (and prepack runs it automatically before a publish).

# npm (published releases)
dsh plugin --profile <profile> add dsh-approval-review

# a local checkout
dsh plugin --profile <profile> add /path/to/dsh-approval-review

# a git pin
dsh plugin --profile <profile> add "github:LAwLi3tCoding/dsh-approval-review#<sha>"

Then restart and confirm the row composed:

dsh --profile <profile> --dump-config | grep -A6 'id: approval-review'

The desktop profile is managed by the Electron app and refuses dsh plugin; add the dependency and bundle entry by hand there, or drive it from the app's plugin manager.

Configuration

All tunables live in the bundle's cordis.patch.yml row, so they are changeable without touching code. An id-targeted override replaces the whole config row — restate every key you still want, or the omitted ones silently return to their schema defaults.

KeyDefaultMeaning
enabledtrueMaster switch. false mounts the plugin but claims nothing.
enabledByDefaulttrueSession-start default for the runtime switch.
reviewerPresetapprove-for-meThe access-mode key that turns this plugin on. Empty means any access mode claims (for a deployment with no such entry).
manageAccessModePresettrueWrite (or adopt) that access-mode entry in the profile patch while installed, and delete it on uninstall; false warns only and never writes configuration. See the access-mode entry's lifecycle.
reviewTools['*']All tool approval requests by default.
defaultPolicyaiFallback routing policy for unmatched tools.
rules[]Ordered {pattern, policy, field?, note?} regex rules, evaluated before the tool table. field is reason (default), toolName, or arguments.
reviewer.enginellmWhich engine answers: the original LLM reviewer, or TypeSafe's Jev. See Reviewer engines.
reviewer.modedirectIsolated model call by default: no inherited parent prompt, history, skills or memory. Optional subagent can inspect the workspace. Applies to engine: llm only.
reviewer.provider / .model(inherit)Reviewer route; unset inherits the calling agent's own route.
reviewer.subagentProviderspawnOptional subagent backend; spawn omits parent history but still inherits the host preset.
reviewer.inspectLocalStatetrueEnable the bounded local inspector in direct mode.
reviewer.tools[read, glob, grep]The reviewer child's tool allow-list. An empty list falls back to the read-only default rather than the parent's whole face.
reviewer.timeoutMs120000Hard deadline for one reviewer call. A slow route plus a reasoning reviewer can take ~50s; a deadline that expires mid-review becomes a failure-policy outcome (a delegation to the human by default), not a verdict.
reviewer.maxTokens(model route default)Optional output cap; omitted by default so the adapter/model configuration applies.
reviewer.temperature0Sampling temperature.
reviewer.policyText(shipping policy)Replaces the ruling policy text.
reviewer.guidance(none)Extra deployment guidance appended after the policy.
reviewer.argumentMaxChars4000Per-string argument cap.
reviewer.argumentsBudgetChars16000Whole-argument-document cap; 0 disables.
context.turns2Prior turns of transcript evidence; 0 omits recent transcript; selected original/latest user intent is still supplied.
context.maxChars6000Transcript character budget.
context.includeAssistanttrueInclude assistant messages in the transcript.
context.includeToolActivitytrueInclude tool calls and results.
maxAutoAllowRiskhighHigh risk also requires medium/high authorization and bounded scope; critical risk is always denied.
onRiskExceededdelegateallow / delegate / deny above that ceiling.
onUncertaindelegateReviewer reported it could not decide.
onReviewerFailuredelegateReviewer crashed, timed out, or answered off-schema. Defaults to delegating: a reviewer that could not run is an infrastructure problem, not a verdict — set rejected for the fail-closed stance.
budget.maxReviewsPerTurn20Reviewer calls per open turn.
budget.onExhausteddelegatedelegate / deny once spent.
maxFailuresPerTurn10Reviewer failures per open turn before requests delegate.
verdictCache.ttlMs0Disabled by default. Opt-in only for direct mode with context.turns=0 and inspectLocalState=false; key includes session, user evidence and model.
verdictCache.maxEntries256Cached fingerprints before oldest-eviction.
circuitBreaker.consecutiveDenials3Consecutive denials that trip the breaker.
circuitBreaker.windowDenials10Denials within windowSize that trip it; 0 disables.
circuitBreaker.windowSize50Rolling window size.
circuitBreaker.actionstopStop the host turn after recording the refusal; delegate / deny remain available.
override.ttlMs300000How long an /approval-review approve stays usable; 0 never expires.
override.maxPending10How many recent denials the override can address.
reasonMaxChars2000Cap on any reason string the plugin emits.
feedReasonToModeltrueAppend the rationale to the refused tool result.
recordAllowedVerdictstrueAppend the allow verdict to the accepted tool result, so the card can show why an action was allowed. Costs one short marker block in the model context per auto-allowed call.
languageautoProse language this plugin emits: /approval-review command output and the reviewer's reason/suggestion fields. auto follows the harness language setting (Settings → General → Language), en/zh pin it. Resolved per call, so a switch applies to the next command and the next verdict. Boundaries: the decision/risk enums stay English tokens (the parser validates them), and text already recorded in the transcript — an earlier verdict's prose, an earlier command's output — is never rewritten.

The access-mode entry's lifecycle

The "Approve for me" entry lives in the permission row's preset table. An id-targeted override replaces that whole row, so any later layer that restates the table — typically a profile patch pinning its own defaultPreset — silently drops the entry this package ships. The menu then lists three modes, the plugin's gate key is not in the catalog, and it claims nothing while the log says nothing.

Two guards fix that:

  1. Self-check. At mount the plugin reads the live catalog (permissionPresets.catalog()). A missing gate key is named in the startup log, and the Access-mode catalog line of /approval-review status keeps reporting the conclusion — missing, written, or only fixable by hand.
  2. Managed entry (manageAccessModePreset: true, the default). Through the configEditor service the plugin writes the missing entry into the profile patch — the layer no later override can shadow — and adopts an identical entry a human already pasted there instead of writing a duplicate. On uninstall it deletes that entry again: installing adds the menu option, uninstalling removes it, and no stale option is left behind.

Ownership stays narrow, so the plugin never touches somebody else's preset:

  • Only a byte-identical definition is owned. Renamed or retuned entries are treated as the operator's, kept on uninstall, and reported in one log line. The preset table is never emptied.
  • The uninstall signal is the profile's own dsh.profile.bundles. A normal app exit unmounts plugins too, and there the bundle is still listed, so the entry survives a restart as it should.
  • A refused write (an id also pinned by a home patch or a command-line overlay is rejected by configEditor) is a warning, not a mount failure, and /approval-review status shows the reason.

For a deployment whose configuration is read-only, set manageAccessModePreset: false: the self-check still warns, and nothing is ever written.

Reviewer engines: llm and jev

reviewer.engine chooses who answers a review. llm (the default) is the original path: one model call — or a read-only subagent — reads the evidence packet and returns the verdict JSON. jev posts the same packet to TypeSafe's System One endpoint and reads typed answers back, with the decisions (prohibition hit, uncertainty, bounded scope) applied in code. Everything downstream of the verdict — the risk gate, the breaker, the cache, the ledger and the card — is shared by both engines.

Jev never touches DSH's model routes: it is a direct HTTP call with a bearer key, so it does not appear in the model picker and needs no provider registration. The typesafe/<model> value shown in the ledger is a label the plugin synthesizes for its own audit record, not a DSH route; /approval-review model <id> still works and overrides the model name or version sent to TypeSafe.

A selection from the picker carries its engine: typesafe/<model> reviews with Jev, <provider>/<model> reviews with that LLM route, a bare <model> keeps the engine already in force and replaces only the model, and default returns to the deployment's own reviewer. That is what makes every listed row mean something — including switching a Jev deployment back to an LLM for one session, which the header pill and the ledger both follow. Two limits hold: a session may switch to Jev only when the deployment acknowledged egress (jev.allowEgress), and the Jev endpoint receives a bare model name, so a value that still contains / after the typesafe/ marker is stripped is reported and ignored rather than forwarded.

KeyDefaultMeaning
reviewer.jev.endpointhttps://api.typesafe.ai/v1/systemoneWhere the evidence is POSTed. Point it at your own gateway if the packet must not go upstream directly.
reviewer.jev.modeljev-latestModel or alias. The response reports the version that answered (jev-1.13.0), and that is what the ledger records.
reviewer.jev.apiKeyEnvTYPESAFE_API_KEYEnvironment variable holding the key. The key is never read from config, never logged, never written to the audit record.
reviewer.jev.timeoutMs8000Deadline for the single HTTP call; independent of reviewer.timeoutMs.
reviewer.jev.permitProbMin0.6Top probability below which the permit answer counts as uncertain.
reviewer.jev.prohibitedAt0.5Probability at which any of the four prohibitions refuses outright, regardless of authorization. Deliberately asymmetric: a false refusal is cheaper than a false allow.
reviewer.jev.scopeBoundedAt0.5Probability at which the scope answer counts as bounded.
reviewer.jev.allowEgressfalseMust be true for the plugin to mount. Otherwise loading fails and names the endpoint the evidence would reach.
reviewer.jev.rubric{}Per-question instructions overrides keyed by question id (see src/jev-questions.ts).

What differs in practice:

  • One HTTP call per review, no tool loop. Jev cannot inspect local state, so a request whose decisive fact is not in the evidence becomes uncertain and follows onUncertain (a human prompt by default) instead of being investigated. reviewer.mode and reviewer.inspectLocalState do not apply.
  • reason is composed in code from the answers — for example Denied: prohibition "disclosure of secrets or private data" (0.97); risk critical; authorization unknown; permit 1.00 — in the configured output language. reviewer.policyText does not apply: the ruling policy lives in the per-question rubric.
  • The evidence packet leaves the machine, which is what allowEgress acknowledges. Redaction is unchanged (key-name based, plus a best-effort pass over unparsable payloads); it does not scrub secrets out of free text.
  • **The key is resolved through the harness