Follow-up to the scope note on #268. On main (fa0b1468, v0.12.263) spend policy is set only through the library — SpendControl.setLimit/clearLimit/setPolicy/clearPolicy, src/spend-control.ts:337–370. Nothing in src/cli.ts (unchanged since 07a8a384) or the plugin commands registered in src/index.ts:1979–2228 calls them. The operator path is hand-editing spending.json and restarting, because limits load once in the constructor (spend-control.ts:331–335) and are never re-read.
Proposal — one handler behind clawrouter policy … and /policy …, shaped like /exclude add|remove|clear (src/commands/exclude.ts, index.ts:2228):
policy # print limits + lists on disk
policy set|add|remove <list> <v>… # <list> = exact PolicyList name
policy clear <list>
policy limit <window> <usd>|clear # <window> = exact SpendWindow
Fail closed on input; on any refusal exit non-zero and write nothing:
allowedNetworks values must be CAIP-2. base/solana refuse, and the message names CAIP2_BASE/CAIP2_SOLANA_MAINNET. No nickname translation.
0x-prefixed values that are not 40 hex refuse — a typo'd denylist entry is fail-open.
<usd> parsed by /^\d+(\.\d+)?$/, never parseFloat ("5abc" → 5).
remove that empties a list routes to clearPolicy: setPolicy rejects [] (:356) while load treats it as absent (:209).
- Malformed file (
policyFileBroken, :327): save() returns silently (:681), so a command built on setPolicy would report success and write nothing. Expose that state — getStatus() (:641) does not — and exit non-zero naming the field.
Live proxy: re-read limits at check() entry when the file mtime changes, or have the command say a restart is required. "Applied" with neither is the worst outcome.
Tests: extend src/proxy.spend-policy.test.ts — inject spendControl, run the handler against the same storage after startProxy, assert signerCalls === 0 for the newly blocked payee; deleting the reload path must fail it. Unit: nickname refused with the file byte-identical; malformed file → non-zero exit, file untouched.
PR to follow.
Follow-up to the scope note on #268. On
main(fa0b1468, v0.12.263) spend policy is set only through the library —SpendControl.setLimit/clearLimit/setPolicy/clearPolicy,src/spend-control.ts:337–370. Nothing insrc/cli.ts(unchanged since07a8a384) or the plugin commands registered insrc/index.ts:1979–2228calls them. The operator path is hand-editingspending.jsonand restarting, because limits load once in the constructor (spend-control.ts:331–335) and are never re-read.Proposal — one handler behind
clawrouter policy …and/policy …, shaped like/exclude add|remove|clear(src/commands/exclude.ts,index.ts:2228):Fail closed on input; on any refusal exit non-zero and write nothing:
allowedNetworksvalues must be CAIP-2.base/solanarefuse, and the message namesCAIP2_BASE/CAIP2_SOLANA_MAINNET. No nickname translation.0x-prefixed values that are not 40 hex refuse — a typo'd denylist entry is fail-open.<usd>parsed by/^\d+(\.\d+)?$/, neverparseFloat("5abc"→ 5).removethat empties a list routes toclearPolicy:setPolicyrejects[](:356) whileloadtreats it as absent (:209).policyFileBroken,:327):save()returns silently (:681), so a command built onsetPolicywould report success and write nothing. Expose that state —getStatus()(:641) does not — and exit non-zero naming the field.Live proxy: re-read limits at
check()entry when the file mtime changes, or have the command say a restart is required. "Applied" with neither is the worst outcome.Tests: extend
src/proxy.spend-policy.test.ts— injectspendControl, run the handler against the same storage afterstartProxy, assertsignerCalls === 0for the newly blocked payee; deleting the reload path must fail it. Unit: nickname refused with the file byte-identical; malformed file → non-zero exit, file untouched.PR to follow.