@cowlprotocol/pulse
v0.1.1
Published
Capability-bounded execution for autonomous agents on Cowl. Let an agent trade from a shielded portfolio without ever holding a private key.
Maintainers
Readme
Pulse
Let an autonomous agent trade on Cowl without ever giving it a private key.
The problem
An agent that can trade has to be able to sign. So you hand it a key, or you hand it something holding a key in the same process it runs in.
That agent also reads the open internet all day. Pages, tool results, documents, other agents. Any of it can carry text written to be obeyed, and the agent has no reliable way to tell an instruction it was given from an instruction it merely read. The failure mode is not a wrong answer. It is a signed transaction.
Spend policies help only if they live somewhere the agent cannot talk to. A limit enforced inside the agent's own context is a limit that can be argued with.
What Pulse does
Pulse puts the key on the other side of a process boundary and gives the agent a menu instead.
agent │ pulse (separate OS user)
decides what and when │ decides what is possible
│
"send treasury 10000 │ ├─ is that account known?
COWL from bot-a" ───▶│ ├─ is 10000 on the allowlist?
│ ├─ is "treasury" in the address book?
◀── {"ok":true, │ ├─ budget left today? spaced enough?
"runId":"…"} │ └─ holds keystore + passphrase, runs the spend
│
│ caps.json ← you write this, the agent cannotThe agent never sees a keystore, a passphrase, or a raw transaction. It names an
account and a recipient; Pulse decides whether that is allowed and does the rest.
Worst case, a fully compromised agent can do exactly what your caps.json
already permits, which is a bounded amount to a destination you chose in advance.
Recipients are the sharp edge, so they are handled by name. An agent passes
treasury, never an address. An address it invented, or read on a web page, or
was told to use by a poisoned document, has nowhere to go.
Install
npm i -g @cowlprotocol/cli @cowlprotocol/pulse
cowl -v # needs >= 0.6.12
cowl-pulse helpPulse drives the Cowl CLI, so the CLI must be installed and on PATH.
Setup
Run the executor as its own unprivileged user. Not your login user, and not the user your agent runs as.
sudo useradd -m -s /bin/bash pulse
sudo -u pulse -H bash
# as pulse:
./setup-account.sh bot-aEach account gets an isolated COWL_HOME, its own keystore, and its own
passphrase file at mode 600. Accounts never share note files or keys. The
script prints a zcowl1… payment address.
Config and state live in ~/.cowl-pulse for whoever runs the executor, so as the
pulse user that is /home/pulse/.cowl-pulse. Put your policy there:
mkdir -p ~/.cowl-pulse
cp "$(npm root -g)/@cowlprotocol/pulse/caps.example.json" ~/.cowl-pulse/caps.json
chmod 600 ~/.cowl-pulse/caps.jsonFill in your accounts, the amounts you are willing to have spent, and the
recipients that may be reached. Then fund an account with a private send from
your own wallet. Set PULSE_BASE if you want config and state somewhere else.
The sudo boundary
Resolve the real path first, because sudoers matches an absolute path exactly and
does no PATH lookup:
command -v cowl-pulse # e.g. /usr/local/bin/cowl-pulse# /etc/sudoers.d/cowl-pulse (edit with visudo -f)
<agent-user> ALL=(pulse) NOPASSWD: /usr/local/bin/cowl-pulseThat single line is the entire surface your agent has. It cannot run cowl,
read /home/pulse/secrets, or edit caps.json. Give your agent this command
shape and nothing else:
sudo -u pulse cowl-pulse <command> …The binary you name here must sit somewhere the agent's user cannot write.
A sudoers rule points at a path, not at contents. If cowl-pulse lives under a
version manager's directory owned by the agent's own user, the agent can replace
that file and have it run as pulse, which hands away everything this design is
protecting. Check it:
ls -l "$(command -v cowl-pulse)" # owner must not be the agent's userInstall Node and this package system wide, or copy the resolved binary to a root owned path and name that path in the rule instead.
Commands
| Command | Effect |
|---|---|
| send <account> <recipient> <amount> <token> | private send to a name in the address book |
| unshield <account> <amount> <token> [spread] | withdraw to the account's own address |
| consolidate <account> <token> | merge fragmented notes |
| status <account> | shielded balance |
| receive <account> | payment address |
| limits | today's remaining budgets and the allowed menu |
| runs [n] | recent outcomes, and anything still running |
Every command answers with one line of JSON, which is what makes it safe to hand
to a model: {"ok":true,…} or {"ok":false,"error":"…","retryable":true|false}.
Transactions return a runId immediately and finish in a detached worker, since
proving takes minutes. Read the outcome later with runs. An attempt is charged
against the daily budget whether or not it lands, so a failing command cannot be
retried for free.
Point an agent at limits first. It returns the menu, so the agent can choose a
legal action instead of guessing and being refused.
What caps.json enforces
accounts— which wallets exist at all.tokens.<SYM>.amounts— the only spendable amounts. Anything else is refused outright rather than rounded or split, so a wrong number cannot become a large transaction.recipients— every reachable destination, by name.limits.dailyTotal/dailyPerAccount— hard transaction ceilings per UTC day.limits.unshieldDaily— withdrawals are the one operation that puts value back on a public address, so they get their own smaller ceiling.limits.minGapMinutes— minimum spacing between submissions. Transactions arriving in a burst are correlated by timing whatever else you do right.
None of these can be widened from the command line. Pulse reads them fresh on every invocation, so tightening a limit takes effect immediately.
Operational notes
One cowl process runs per account at a time, held by a lock, because the pool
and note files are not safe to write concurrently. A second request for a busy
account waits briefly, then refunds its budget and reports rather than queueing
forever.
Nothing exits mid-operation. Failures unwind through the same path as successes, so a crashed or refused run always releases its lock and always records a result.
consolidate fans out into one spend per merge round and charges each confirmed
round, so a badly fragmented account can spend more than dailyTotal in one
call. Treat it as maintenance you trigger deliberately rather than something an
agent calls on a schedule.
What stays off the machine
Your own wallet. Fund accounts with a private send from a wallet Pulse has never seen, and keep its keystore, its passphrase, and your backups somewhere this executor cannot reach. Pulse is only ever able to spend what an account it provisioned already holds.
License
MIT
