Redis command map and grant check
python -m scripts.swarm.redis_commands prints, for each swarm consumer, the Redis commands its code can send and the scheduler keys it writes. antoncore’s Swarm Redis privileges (Redis 7) CI job builds this map from agentihooks dev and fails when a swarm Redis user does not grant a command or key one of its consumers uses. The failure names the consumer, the command, the user and the code that sends it.
The check exists because of an incident on 12 October. An antoncore change narrowed the swarm Redis users, and the controller then crash looped in Anton because its user lacked INCRBY. No check compared the narrowed users with the commands the code sends before the change was deployed.
The command map
The map is built from the source code, so it changes when the code changes. No hand-kept list of commands is involved.
| Consumer | Redis role | Entry points |
|---|---|---|
controller | controller | scripts.swarm.controller:main |
tick | controller | scripts.swarm.scheduled_tick:main, scripts.swarm.cli:cmd_tick |
hub | controller | scripts.hub.runner:main |
ledger server | ledger | scripts.swarm_ledger:run, scripts.swarm_ledger.ledger_server:main |
worker | worker | scripts.swarm_v2.worker.start:main |
hooks | worker | hooks.hook_manager:main |
The role is the key under redis.roles in the swarm chart values. antoncore uses it to find the Redis user that consumer connects as.
How commands are found
- Every module under
scriptsandhooksis parsed. Each function, method and module body becomes one unit. - Starting at the consumer’s entry points, the map follows every unit the entry points can reach. That includes functions named directly, imports (including imports inside a function), the module body of every module reached, methods called on
selforcls, methods called on a name bound to a known class constructor, and methods whose name reached code reads on an object of unknown class. It also includes the dunder methods of every class reached. - In each reached unit, the map records the commands sent by:
- a redis-py client method. The method is matched to the command it sends by calling it on a client that records commands instead of sending them. For example,
incrsendsINCRBYandblpopsendsBLPOP; - a
PubSubmethod on a name bound to<client>.pubsub(), for examplesubscribesendsSUBSCRIBE; execute_commandwith a string literal as its first argument, for exampleexecute_command("XINFO", ...)sendsXINFO;- a Lua script string that calls
redis.call('<COMMAND>', ...); - the helpers
lock(the lock’s own commands, its Lua scripts andSCRIPT LOAD),register_script(EVALSHA,SCRIPT LOAD),transaction(WATCH,MULTI,EXEC) and pipelineexecute(MULTI,EXEC).
- a redis-py client method. The method is matched to the command it sends by calling it on a client that records commands instead of sending them. For example,
- A receiver counts as a Redis client only when its type traces back to a client or pipeline constructor:
Redis(...),StrictRedis(...),Redis.from_url(...)or<client>.pipeline(), a function underscriptsorhooksthat returns one (resolved through imports, its own class, or a name only one function defines), a parameter annotatedRedis,StrictRedisorPipeline, or a chained call on a pipeline. A name a function binds is typed from that binding in that function only, soitems = []keepsitems.appendout of the map even where another function holds a client nameditems. An attribute, a parameter the function does not rebind, a loop target, or a name bound to a call whose callee the map cannot find (such as a function held in a variable) counts when the same name holds a client elsewhere. Comprehension targets,*argsand**kwargsnever count. Typing is per function, not per line: a name once bound to a client stays one for the whole function.
Scheduler keys
Each consumer also carries the keys the machine scheduler writes, computed by Scheduler.key for the given deployment mode with the machine name probe and no hive id:
| Deployment | scheduler entries | scheduler wake |
|---|---|---|
distributed, compose | agentihooks:scheduler:probe:entries | agentihooks:scheduler:probe:wake:probe |
local | agenticore:<instance>:scheduler:probe:entries | agenticore:<instance>:scheduler:probe:wake:probe |
A real machine writes the same keys with its own machine name in place of probe. The grant check matches these keys against each user’s key patterns. For the controller user, ~agentihooks:* covers them and ~agentihooks:swarm:* does not.
Usage
Print the map for the deployment Anton runs:
cd ~/dev/tcc-ecosystem/agentihooks
~/dev/tcc-ecosystem/.venv/bin/python -m scripts.swarm.redis_commands --deployment distributed > redis-commands.json
--deployment takes local, compose or distributed and defaults to distributed. The command reads only source files. It opens no Redis connection and needs no credentials. On a workstation it takes a few seconds.
The output is one JSON document:
{
"consumers": {
"controller": {
"commands": {
"BLPOP": ["scripts.scheduler.process_timer:Timer.wait"],
"INCRBY": ["scripts.inbox.store:InboxStore._move_address", "..."]
},
"keys": {
"scheduler entries": "agentihooks:scheduler:probe:entries",
"scheduler wake": "agentihooks:scheduler:probe:wake:probe"
},
"role": "controller"
}
},
"deployment": "distributed"
}
Each command lists the units that send it as module:function or module:Class.method, sorted. The excerpt shows one consumer. The real map lists all five.
Read the map:
# Commands one consumer sends
jq -r '.consumers.controller.commands | keys[]' redis-commands.json
# Which code sends one command
jq -r '.consumers.controller.commands.INCRBY[]' redis-commands.json
# Command count per consumer
jq -r '.consumers | to_entries[] | "\(.key) \(.value.role) \(.value.commands | length)"' redis-commands.json
On dev at the time of writing, the command count per consumer was controller 58, tick 57, hooks 48, ledger server 43 and worker 0: the worker’s earlier GET, MULTI and EXEC came from dictionaries and a SQLite cursor read as clients, and the worker sends no Redis command. The controller’s INCRBY senders include scripts.swarm.lease:acquire, the lease acquire behind the 12 October crash loop.
The antoncore grant check
The swarm-redis-authz job in antoncore’s CI Validate workflow, named Swarm Redis privileges (Redis 7), runs on every pull request into antoncore dev, every merge group and every push to dev. It has no path filter, and the workflow’s final gate needs it to succeed. The job:
- Checks out agentihooks at
devand installs the redis-py version pinned indocker/swarm-node/requirements.lock. The command map depends on that version, because each method’s command is read from the installed redis-py. - Reads
deploymentfromk8s/values-overlays/agentihooks-swarm/values.yamland runspython -m scripts.swarm.redis_commands --deployment <deployment>. The map is uploaded as theredis-commandsartifact for one day. - Runs
tests/unit/swarm_redis_authzwithSWARM_REDIS_COMMANDSpointing to the map. The step fails when any test is skipped, so a missing map cannot pass as green.
tests/unit/swarm_redis_authz/grants.py reads the grants:
- Users. Each
userline of theusers.acl.templateentry in theagentihooks-swarm-redis-tlsConfigMap gives that user’s root permissions. These areon/off,+command,-command,+command|subcommand,+@all/allcommands,-@all,~pattern,allkeysandresetkeys. Selectors in parentheses are ignored. - Role to user. For each
redis.roles.<role>in the overlay, the check follows the named ExternalSecret and key to the connection URL template and takes the user name fromredis://<user>:orrediss://<user>:.
test_consumer_grants.py then checks:
| Test | Passes when |
|---|---|
test_the_map_was_published_for_the_deployment_anton_runs | The map’s deployment equals the overlay’s, and every consumer role is a chart role |
test_every_consumer_command_and_scheduler_key_is_granted_to_its_redis_user | No consumer command or scheduler key is refused |
test_a_planted_command_removal_from_the_controller_user_is_refused | Removing +incrby, and separately +blpop, from swarm-controller produces a refusal for that command |
test_a_planted_key_narrowing_of_the_controller_user_is_refused | Narrowing swarm-controller from ~agentihooks:* to ~agentihooks:swarm:* refuses the scheduler entries key |
The planted cases show that the check can fail. INCRBY is the grant behind the 12 October crash loop. BLPOP and the scheduler entries key are the two missing grants fixed by antoncore pull request 954. agentihooks pull request 4832 keeps the hosted controller’s scheduler keys under agentihooks:.
Refusal lines
A failing run prints one line per refusal:
controller issues INCRBY but Redis user swarm-controller does not grant it; sent by scripts.inbox.store:InboxStore._move_address
controller writes its scheduler entries key agentihooks:scheduler:probe:entries outside the keys of Redis user swarm-controller
A command line names the first sending unit. Look up the rest in the redis-commands artifact.
Configuration
| Setting | Where | Effect |
|---|---|---|
--deployment | redis_commands flag | Deployment mode used to compute the scheduler keys. Default distributed |
deployment | antoncore k8s/values-overlays/agentihooks-swarm/values.yaml | The mode the CI job passes as --deployment |
redis.roles.<role>.name, .key | Same overlay | The ExternalSecret and key holding each role’s Redis URL, and through it the user name |
users.acl.template | antoncore k8s/manifests/agentihooks-swarm/redis.yaml | The user grants that the check reads |
SWARM_REDIS_COMMANDS | antoncore CI step environment | Path to the map. The grant tests skip without it, and the step fails on any skip |
A new consumer needs an entry in CONSUMERS in scripts.swarm.redis_commands with its role and entry points. A new role also needs a matching redis.roles entry in the overlay.
Limits
execute_commandwith a computed first argument, and a Lua script passed as anything but a string literal containingredis.call('...'), add no command. Write the command name as a literal.- The map follows imports under
scriptsandhooks. Code reached only through another installed package is not traced. - Method resolution follows names, so the map can list a command that a consumer never sends at run time. It may over-report commands, but every traced call path is included.
- A redis-py method that no sample call completes raises
LookupErrornaming the method, so the map fails to build. It never drops the command without warning. - The ACL reader raises
ValueErroron category rules other than+@alland-@all(such as+@read) and on%key permission rules, because it does not model them. - Only root permissions are checked. Grants inside selectors do not count.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
<consumer> issues <COMMAND> but Redis user <user> does not grant it | The user in users.acl.template lacks the command, or denies it with -command | Add +<command> to that user’s line in antoncore, or remove the code path from that consumer in agentihooks |
<consumer> writes its <label> key <key> outside the keys of Redis user <user> | No ~pattern on the user matches the scheduler key | Widen the user’s key pattern, or keep the scheduler key under the user’s prefix |
test_the_map_was_published_for_the_deployment_anton_runs fails | The overlay’s deployment changed, or a consumer role has no redis.roles entry | Pass the overlay’s deployment, or add the role |
Step fails with N skipped | SWARM_REDIS_COMMANDS was unset or the map step did not run | Read the Publish the Redis commands each agentihooks consumer issues step log |
ValueError: ... category rule ... is not modelled | The ACL template uses a category rule | List the commands one by one |
LookupError: no sample call of ... | A redis-py upgrade changed a method signature so that no sample call completes | Extend the samples in scripts.swarm.redis_wire |
| A new command appears and turns antoncore red with no antoncore change | The job checks out agentihooks dev, so a merged agentihooks change that sends a new command fails the next antoncore run | Grant the command in antoncore. The map artifact names the code that sends it |
Delivery evidence
- agentihooks pull request 4917 added the map, and pull request 5039 made each unit’s syntax tree walk once.
- antoncore pull request 964 added the grant check and the planted red cases.
- On 23 October, antoncore
devrun 39912385457 (job 120278104620) built the map from agentihooksc62851062band passedtests/unit/swarm_redis_authzwith53 passed. - On the same day, the map built locally from agentihooks
devlistedINCRBYandBLPOPfor the controller and finished in 6.9 seconds.