At 02:14 the build box failed with a message that had nothing to do with disk.
ninja: error: fork: Cannot allocate memory
That message was a lie. The real problem was inode exhaustion. df -i showed 100% use on .cache. The directory held 412,000 small dependency files. The build had slowed for weeks, and the easiest cleanup was also the riskiest, because the cache directory was the only place that remembered which object files were still valid.
A build engineer sent the raw failure block to a free model endpoint. MonkeyCode's free model access supplied the candidate fixes. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The model answered with one line:
{"candidate":"rm -rf .cache && cmake --build .","reason":"cache exhaustion blocks fork","expected_paths":["build",".cache"],"rollback":"git clean -qfd .cache","confidence":0.92}
That one-liner would likely fix the build. It could also erase a day of reproducibility work. The team did not reject the model. They demoted it to a proposer. Nothing generated by the free model got to touch the real tree until it earned execution through a permit.
The permit was a small C++ object.
#include <string>
#include <vector>
struct CommandPermit {
std::string id;
std::string candidate; // exact command submitted by the model
std::string reason; // model's claimed reason
std::vector<std::string> expected_paths; // paths expected to change
std::string rollback; // safe command to undo a failed run
int timeout_seconds = 20;
int risk = 1; // 1 read-only, 3 mutates generated files, 5 destructive
bool dry_run_required = true;
};
The point of the permit was not to make the model look safer. It turned a suggestion into an auditable state machine. The C++ helper hashed every field and appended the result to an append-only ledger.
// sha256 is a local digest helper. It prevents a changed command from reusing an old record.
std::string toLedgerLine(const CommandPermit& p, const std::string& state) {
std::string all = p.id + "|" + p.candidate + "|" + p.reason + "|" +
p.rollback + "|" + std::to_string(p.risk) + "|" + state;
return "{\"record\":\"" + all + "\",\"sha\":\"" + sha256(all) + "\"}";
}
The dry run did not run on the real tree. It ran in a copied snapshot. Any command that needed write access had to prove its effect there first.
sandbox="$(mktemp -d)"
cp -a "$PROJECT"/. "$sandbox"
before="$(find "$sandbox" -type f -print0 | sort -z | xargs -0 sha256sum | sha256sum)"
( cd "$sandbox" && timeout "$PERMIT_TIMEOUT" bash -lc "$PERMIT_CANDIDATE" ) > dry_run.out 2> dry_run.err
rc=$?
after="$(find "$sandbox" -type f -print0 | sort -z | xargs -0 sha256sum | sha256sum)"
[ "$rc" -ne 0 ] && ledger_state="DRY_RUN_FAILED"
[ "$before" = "$after" ] && ledger_state="NO_EFFECT"
If the exit code was not zero, the record was DRY_RUN_FAILED. If the manifest did not change, the record was NO_EFFECT. If a declared expected path was missing, the record was EXPECTED_PATH_MISSING. Only DRY_RUN_OK could move to the next state.
Risk decided how much proof was enough.
| Risk | Meaning | Required gate |
|---|---|---|
| 1 | read-only inspection | run directly |
| 2 | writes only temporary files | snapshot dry run |
| 3 | mutates generated files | snapshot dry run plus expected path match |
| 4 | mutates tracked files | maintainer approval required |
| 5 | destructive outside the workspace | auto-reject |
The C++ wrapper consulted the risk field before leaving the proposal state.
if (permit.risk >= 4 && !maintainer_approved) {
appendLedger(fd, toLedgerLine(permit, "REJECTED_WITHOUT_APPROVAL"));
return;
}
Only after the dry run matched the expected paths did the real command run inside a timeout.
if [ "$ledger_state" = "DRY_RUN_OK" ] && [ "$risk" -le 3 ]; then
timeout "$PERMIT_TIMEOUT" bash -lc "$PERMIT_CANDIDATE" > execute.out 2> execute.err
if [ $? -ne 0 ]; then
timeout 30 bash -lc "$PERMIT_ROLLBACK"
ledger_state="ROLLED_BACK"
fi
fi
The ledger did not need to live inside the build host. The team pointed it at a small append-only file on MonkeyCode's free server option. A builder that crashed or wiped its local metadata could not rewrite the remote record of why a command was allowed to run.
That design was useful, but it was not magic.
- It recorded what was proposed, not why the model was right.
- It narrowed execution to expected paths, not complete side effects.
- The dry-run snapshot was not a security boundary. A command that escaped the snapshot or used the network needed a stricter sandbox.
- Rollback worked for generated artifacts and Git-cleanable paths. It did not cover databases, credentials, or mounted volumes.
- Free models still hallucinated reasons, confidence values, and even rollback commands. The permit made failures inspectable; it did not make them impossible.
Teams with a public multi-tenant CI runner or a production database should not route model-generated shell commands through this alone. The gate is for controlled, rebuildable workspaces with a rollback story.
If a free model already writes commands in a builder, forward its proposals through a permit object before they touch real state. Try the flow on a scratch VM where rm -rf .cache is cheap to undo and compare the ledger with what actually changed.
Top comments (0)