DEV Community

Cover image for I Gave My Agent One Signed Permission It Couldn’t Mint Itself
Self-Correcting Systems
Self-Correcting Systems

Posted on

I Gave My Agent One Signed Permission It Couldn’t Mint Itself

Evidence status. The supervised operator run completed on 2026-08-09. An operator-signed job executed once and a replay of the same still-valid receipt was refused. Every value in the live-result block below was read back from the machine; no expected value was written into that result. The final worker, module and checker bytes received an independent breaker verdict before that run. The bounded claim and its limits are stated at the end. External reproduction remains zero. Every hash, contract, breaker verdict and receipt below is published at github.com/keniel13-ui/signed-resumption-evidence. The operator signature can be verified against the published public key with stock OpenSSH; the repo's VERIFY.md gives the exact commands, including a negative control. "Independent breaker" here means a separate review seat that did not author the candidate under test. It does not mean an outside organisation.

Correction, added after publication. A reader named Peter pointed out that binding the entry script does not bind the code that entry script imports. He assumed my claim that repro was "standard-library-only" made this moot for my route. That claim was false. repro.py imports gates.py, a project-local module, at two places. I ran his attack on an isolated copy: changed only gates.py, left repro.py byte-identical, and the signed route-manifest hash did not move. So the hole is live on the route I shipped, not a warning about future reuse. The P1 result binds the job body, the interpreter and the entry script. It does not bind the full project-code closure that executed. The signing-custody and single-use results below are unaffected. Execution-closure binding is open, and I am not claiming a repair until one has an independent verdict and a new operator receipt.

Since the July 31 repair, the safest part of my agent system was also the least useful:

It could not run anything.

That was not an accident. A previous repair had left the worker with no reachable True branch. Deleting the pause file did not restore authority. Dropping a marker beside the agent did not restore authority. Every route ended at the same answer: refuse.

The gate was safe in the narrowest possible sense. It was also a dead end.

The harder question was not whether I could block an agent. It was whether I could open exactly one position without giving the agent the ability to open a second one:

Can the worker execute one human-approved job without being able to mint or alter the signed receipt — and can that receipt be single-use while the spend ledger is intact?

The answer required more than a signature check. It required moving the authority to sign outside the system being governed, binding the signature to the exact interpreter and entry-script bytes read immediately before execution, and proving that the legitimate path worked without weakening the nineteen refusals that already held.

This is the mechanism we built, where it broke, and the operator-controlled result that finally showed the permission being used rather than merely implemented.

One open position

The worker may execute exactly one route: repro.

repro is not a shell, and it is not a general command runner. It is this project's deterministic reproduction of a sequence attack — 453 lines that run the same takeover against an ungated guard and a purpose gate and check both against predictions frozen before the run. It imports gates.py, a project-local module, which the P1 route manifest did not bind. Calling this route standard-library-only, as an earlier version of this article did, was wrong. It is a real job with a real result, not a ceremonial one chosen because it was safe to allow. The exact script that executed is public at commit d44a72c; its SHA-256 is f0c0ec9eee57c022207491ac6fcd02e64ff908aefa8ed936ced064c860cb7636, which is the script_sha256 inside the signed route manifest.

The approval receipt binds all of this:

  • the queued job ID;
  • a digest of the job’s id, assign and text;
  • the exact working directory;
  • the tool name;
  • the resolved Python interpreter path and its SHA-256;
  • the resolved script path and its SHA-256;
  • the maximum wall time;
  • an expiry;
  • and a one-use nonce.

The route manifest is not a hash of the words python3 repro.py. That version existed briefly and failed review. The filenames could remain identical while the program behind repro.py changed. The signed manifest binds the resolved interpreter and entry-script bytes, not the filenames. The worker checks that manifest during authorization, spends the nonce, then recomputes it a second time inside the owned execution boundary — with nothing between that final check and subprocess.run.

Every other route remains closed. A valid repro receipt cannot authorize adapter, eye, a freeform AUTO: prompt, a headless model, or a fallback path. A refusal on the signed route cannot fall through to the older allowlist executor.

The flow is deliberately small:

owner-controlled private key, outside the worker
            ↓ signs
canonical one-job receipt
            ↓ verified against
root-owned allowed-signers file
            ↓ binds
job body + route bytes + cwd + timeout + expiry + nonce
            ↓
atomic nonce spend
            ↓
one executor call
Enter fullscreen mode Exit fullscreen mode

The private key is the minting authority; the allowed-signers file is the verification trust root. The worker receives write authority over neither. That second half is not decoration: if the worker could replace the trust root with a key of its own, it could mint receipts its own verifier accepts without ever touching the operator's private key. Read back from the machine as the worker account: the resolved file is root-owned, mode 644, not a symlink, and every component of its path is root-owned and non-writable by the worker. Attempts to write, append, rename and delete it all returned Permission denied. That custody separation is an operational property of how the key was generated and stored, not something the worker's own receipt can prove; what the receipt proves is that a signature validated against the configured trust root and that the authorized path ran once.

Why I did not use HMAC

A shared-secret design looks simpler until you ask who can read the secret.

The worker needs whatever verifies the receipt. If verification happens inside the worker and uses HMAC, the worker must hold the same secret that creates valid receipts. Read access becomes minting authority. You can escape that by moving verification behind a privileged external service — but then the separation lives in that service, not in the agent. Asymmetric signatures give it to you without a second daemon to defend.

So the worker gets only a public OpenSSH allowed-signers file under a root-owned path. Verification uses a pinned /usr/bin/ssh-keygen -Y verify command, a fixed principal, a fixed namespace, no shell, and a signature path generated by the worker. No receipt field controls the verifier, principal, namespace, trust-root path or temporary signature path.

The signing key stays with the operator, ideally on a different device. The agent cannot turn its ability to verify into an ability to sign.

That is the separation the live operator run tested.

Four defects, in the order they were found

Each of the next four sections is a defect that a review round caught before the following stage was allowed to begin. None of them reached a live run. Twelve independent verdicts are published with this article, four of which returned BLOCK.

A mechanism that survives construction unbroken has not been tested. It has only been trusted.

The receipt was signed. The wrong job still ran.

The first implementation checked job_id and never checked job_digest.

An independent harness signed a receipt for one job body, changed the text, kept the same ID, and asked the worker again.

It allowed. The signed job digest and the altered job digest differed, but the implementation never compared them.

This was not an edge case beside the mechanism. It was the exact failure the mechanism existed to prevent: approval for one body had broadened to another body without a new signature.

The repair compares the signed digest with a canonical digest of the live job content before the nonce is spent. The same attack now returns:

RECEIPT_NOT_BOUND_TO_THIS_JOB_CONTENT
executor_calls = 0
Enter fullscreen mode Exit fullscreen mode

The independent re-break confirmed the repaired row returns RECEIPT_NOT_BOUND_TO_THIS_JOB_CONTENT with zero executor calls. A separate independent ablation then removed only the digest comparison and left everything else in place: the altered body flipped from that refusal to ALLOW. The binding was load-bearing, not decorative.

Returning “authorized” was another hole

The next implementation verified the receipt, recomputed the route, spent the nonce, and returned the signed timeout to its caller.

That sounds modular. It also reopened the route after authorization.

The script could change after authorize() returned and before some other function invoked the executor. The verifier had checked one program; the caller could run another.

The repair moved execution inside the same owned boundary:

verify → bind job → recompute route → spend nonce → execute once
Enter fullscreen mode Exit fullscreen mode

The executor now consumes the signed wall-time directly. A receipt with a one-second limit against a five-second fixture terminates at the signed bound and records EXECUTION_TIMED_OUT, never a successful completion.

Sequential replay returns NONCE_ALREADY_SPENT. Two concurrent attempts with the same nonce produce one execution and one refusal. The lock is held across ledger reread, append, flush and fsync, then released before execution. The semantic is intentionally at-most-once: a crash after spend may lose the job, but it must not silently run it twice.

Two breakers passed a route that could never open

The most instructive failure happened after the worker glue had already passed an independent nineteen-case regression run.

The live worker loaded the reviewed module with importlib, but it did not register the module in sys.modules before executing it. Python’s @dataclass machinery looked up the module while building the frozen Config class and found nothing.

The loader raised on every call.

The signed route therefore refused everything. Again.

Two independent harnesses had passed the build because both loaded the module correctly inside their own test setup. They proved that the module worked while bypassing the loader used by the worker. A “repro plus bad receipt refuses” row appeared green, but it was refusing because the loader had crashed before receipt verification ran.

That is a wrong-reason pass: the verdict matches while the claimed control never executes.

The fix was one line:

sys.modules[spec.name] = module
spec.loader.exec_module(module)
Enter fullscreen mode Exit fullscreen mode

The re-break called the worker’s own loader rather than a parallel import. It proved three things:

  • the worker loader now constructs the frozen production Config;
  • removing the registration restores the AttributeError, so the line is load-bearing;
  • a signed-route refusal now reaches the gate rather than dying in module loading.

The test harness had been more correct than the system it was testing. Until the harness was made to cross the same boundary as production, that correctness hid the defect.

The checker was not sharing the verifier

One integration row originally said disagreement between the worker and its usefulness checker was “impossible by construction.”

That sentence was not a test. It was an unfalsifiable promise.

The live checker still implemented its own state policy and admitted the split in its own source:

authorization artifacts exist, but this checker does not verify signatures
Enter fullscreen mode Exit fullscreen mode

It could report UNKNOWN while the signed-resumption verifier had already frozen a specific state. The checker now imports the same pinned health_state function as the worker. The repaired row loads both call paths with identical inputs and compares the outputs. Both return:

state   PAUSED_BLOCKED
allow   false
Enter fullscreen mode Exit fullscreen mode

“They cannot disagree” became “run them on the same input and fail if they disagree.” That is the difference between a design intention and a control.

The old floor still had to hold

Opening one route was not allowed to weaken the previous default-deny repair.

On the first integration hash, the independent harness replayed the full July 31 family: six mandated attacks and thirteen breaker-authored cases. Every executor was replaced with a counting stub.

cases                  19 / 19
allowlist calls         0
signed-route executions 0
Enter fullscreen mode Exit fullscreen mode

It also checked that other allowlist keys, freeform prompts, headless execution and a shadowed module could not borrow a valid repro receipt. A failed signed-route attempt never fell through to another executor.

The loader and checker repairs then changed the worker hash, which meant the floor had to be re-established rather than assumed. An independent breaker replayed all nineteen against the exact final worker bytes e99f2cf5…60a298: nineteen refusals, zero executor calls, and zero cases refusing on a shallower check than the one they were written to test. The count was not carried over from the earlier hash; it was earned again on the bytes that ran.

That result matters because the easiest way to make the positive row pass would have been to weaken the gate that had already survived nineteen attacks. The integration was accepted only after the old refusals and the new door held together.

The result that decides the title

Those fixtures proved the mechanics. They still did not prove that an operator-controlled trust boundary could open the legitimate route.

That required the final row:

  1. the public key is installed in the root-owned trust file;
  2. the private key remains outside agent custody;
  3. the operator signs one frozen receipt;
  4. the worker runs the approved repro job once;
  5. replay of the same receipt refuses.

Step two is the part that is easy to write in a spec and awkward in a kitchen.

I generated the signing key on a Chromebook. Not as a metaphor — a physically separate machine, outside the access any agent in this system is configured to have. I never copied the private half to the Mac and never disclosed it to an agent. That was the rule I set before any of this was built: if an agent asks for the private key, the request itself is the defect.

This is an operational property, not a cryptographic one. The receipt cannot prove where the key was kept or who else might hold a copy. It proves only that the corresponding private key signed these exact bytes. That the worker never held that key comes from how the run was conducted — from procedure, not from the signature.

The payload did not travel as a file. I rebuilt it on the Chromebook and compared SHA-256 digests across the two machines by eye: 4ed5ecff…6b64f, 433 bytes, identical on both sides. Only then did I sign it. The whole authority separation this article is about came down to me reading a hex string off one screen and checking it against another.

That is the honest shape of the boundary. It is not a hardware security module. It is a cheap laptop on the other side of the room, and one key that never moves.

It ran on 2026-08-09 at 19:43:41. Every line below was read back from the machine afterwards.

operator signature:    Good "km-agent-execution-authorization" signature for keniel-owner
                       ED25519 SHA256:EsftjEQJF0z4+fBDDZNEXmIUUK0ydtyijHqfsmRyfpA
first execution:       19:43:41 EDT  job P1-SUPERVISED-2026-08-09 signed route EXECUTION_COMPLETED
completed executions in the worker log:  1
replay:                19:47:22  signed route refused: NONCE_ALREADY_SPENT
nonce ledger:          one entry, nonce 0b88f91dd7147b2c41285ef889a62fa9,
                       spent 2026-08-09T23:43:40.831980 UTC (= 19:43:40 EDT), unchanged by the replay
Enter fullscreen mode Exit fullscreen mode

The replay is the part I care about. I re-queued the same job while the same signature was still sitting there, valid and unexpired. It refused. One signature, one run — for as long as the spend ledger it checks stays intact, which is a condition I come back to below and do not wave away.

The bytes it ran against: worker e99f2cf5…60a298, module 41164d8e…183a76, checker 7f162a24…186363. The process was restarted first so the running image was those bytes and not the three-day-old ones it had been holding.

What this proves, and what it does not

The distinction that matters most, said plainly: the worker cannot forge an owner authorization, because it does not hold the signing key. That is not the same as saying the host stops a hostile same-user process from replacing the verifier altogether. It does not. A cryptographic authorization boundary is not a host integrity boundary, and this work establishes the first, not the second.

One more piece of precision. "Byte-bound" here means the resolved interpreter binary and the entry script are SHA-256 bound and recomputed immediately before the call. It does not mean the whole transitive import graph, the environment, runtime inputs or operating-system state are attested. Concretely: repro.py imports gates.py and a change to gates.py alone does not move the manifest, so a valid receipt authorizes altered project code. The entry point is bound; the full execution closure is not. This is also a pre-execution hash check rather than kernel-enforced execution identity: moving the executor inside authorization closed the large window, but a small hash-to-exec interval still exists, and a hostile same-user replacement inside that interval is outside this threat model.

The bounded claim is not that this worker is tamper-proof.

Every agent on this machine runs as the same user. A hostile same-user process can overwrite the worker and restart it. The spend ledger is also user-writable; deleting or rolling it back defeats the replay claim. This mechanism defends against accidental and naive authority restoration, not a process deliberately rewriting the enforcement code beneath itself.

The strong result is narrower:

Under the reviewed worker and an intact spend ledger, one operator-signed receipt opened one route bound to its job body, interpreter and entry script. The agent could not mint the signature or alter those signed fields. Imported project code was not bound by the P1 receipt.

Replay resistance is claimed only while the ledger is intact. External reproduction remains zero. No deployed IAM, hardware attestation or hostile same-user isolation is demonstrated here.

Those limits do not make the mechanism trivial. They locate the authority precisely:

  • verification is not signing;
  • integrity is not identity;
  • a valid job ID is not a bound job body;
  • a filename is not the bytes that execute;
  • an entry script is not the closure it imports;
  • a refusal is not evidence that the intended control ran;
  • and a gate that cannot open is a pause, not a permission system.

The goal was never to make the worker powerful. It was to make one permission real without making permission self-service.

One open position. One human signature. One execution. Then closed again.


Top comments (4)

Collapse
 
reidmarlow profile image
Reid Marlow

I like the nonce ledger here. The part people skip is proving the worker cannot move the trust root after it gets the job. One signed permission is only useful if replay and verifier-custody both fail closed.

Collapse
 
kenielzep97 profile image
Self-Correcting Systems

Verifier custody was the part I was most worried about. If the worker can swap the allowed-signers file it just mints receipts its own verifier accepts, and it never has to forge a signature at all.

So I read it back running as the worker account instead of trusting the permission bits. Root owned, 644, not a symlink, and every component of the path is root owned and not writable by the worker. Then I tried all four attacks from that account. Write, append, rename, delete, all permission denied.

Rename is the one I almost missed. You don't need write access to a file if you can move something on top of it through a writable parent, so checking the file alone would have looked fine and proven nothing.

Your "after it gets the job" is the sharper half of that though, because that's a timing question and not a permissions one. The way it lands in the code is that the route manifest gets checked once during authorization, then the nonce gets spent, then the manifest is recomputed a second time inside the execution boundary with nothing between that check and subprocess.run. So swapping the interpreter or the script after authorization fails closed. But I want to be straight that this is a pre execution hash check and not kernel enforced execution identity. Moving the executor inside authorization closed the big window, it did not close the interval entirely.

Same story on replay. The nonce ledger holds while the ledger is intact, and it lives in user writable space because the worker can't write the root owned directory. Delete it and you get a replay. Any process running as me can also just overwrite the worker and restart it. It defends against accidental and naive authority restoration, not against something deliberately rewriting the enforcement code underneath itself.

The custody readback and the P1 run are both in the repo linked at the top if you want to pull the actual output.

Collapse
 
peterbuildssecure profile image
Peter

The line that stood out: "the entry point is bound; the full execution closure is not." That's the gap I'd want closed next, and it's closable the same way you closed the receipt-to-job-content binding: if repro.py imports anything beyond the standard library (you say it's stdlib-only here, which sidesteps this specifically), the manifest needs to bind a hash of the resolved import graph too, not just the entry script — otherwise the attack just moves one file over: leave repro.py's bytes untouched and mutate a module it imports, and the byte-bound check on the entry script passes while the code that actually runs has changed. Stdlib-only is a clean way to make that moot for this repro job specifically, but the moment this pattern gets reused for a route that imports project code, "bind the entry script hash" quietly stops meaning what it sounds like it means unless the import graph is pinned too.

Collapse
 
kenielzep97 profile image
Self-Correcting Systems

You're right, and the out you gave me isn't real.

You treated stdlib-only as the reason this wouldn't apply to my route. I went and checked after reading your comment. repro.py imports gates.py, a project-local module, at line 19 and again inside the ablation path. It is not standard-library-only. That sentence in my article was just wrong.

So I ran your attack on an isolated copy. Appended code to gates.py, left repro.py byte for byte identical, recomputed the signed route manifest. gates.py hash moved. Manifest hash didn't. A still-valid operator receipt would authorize different code. That's live on the route I shipped, not a future-reuse warning.

I've corrected the article rather than fixing it quietly here, because you found it by reading the limits section as a spec, and the limits section only works if the errors above it get marked.

What still holds is narrower than the piece implied but it isn't nothing. The worker never held the signing key and still can't mint or alter a receipt. The job body binding, the trust root custody, and the single-use nonce are all untouched by this. What P1 did not establish is that every byte of project code reached through the entry script was bound.

On the repair, I want to be careful about something. There is a patch sitting on my machine that folds a static import walk into the manifest, and I'm not going to point you at it or quote its hash, because I wrote it and nothing has broken it yet. Everything else in that repo went through freeze, independent break, and a verdict before it shipped, and three of those came back BLOCK. It would be a bad look to abandon that the first time I'm embarrassed and want a fast answer in a comment thread.

The controlling test is obvious enough to state now though. Mutate gates.py only, leave repro.py untouched, and the old receipt has to refuse before execution with zero executor calls. Then remove the new binding and that same attack has to succeed, or the binding isn't load bearing and I've just added ceremony. A static AST walk also won't cover dynamic imports, native extensions or site-packages, so whatever I end up claiming will have to say that out loud.

Until that has an independent verdict and a new signature, the execution closure is open and I'll say so.

Thank you for this. You did more for the piece in one comment than the whole review round did.