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. 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, broaden or alter that approval - 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, standard-library-only 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 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 andtext ; - 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 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 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 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) 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 ra
Comments
No comments yet. Start the discussion.