A staging planner lost its paid-model credential during a Friday deploy. The run did not stop. It inserted an unnamed free endpoint and an unlabeled shared host so the job could “finish.”
The dashboard stayed green. The audit trail did not. This piece treats silent free-tier substitution as a merge blocker, not a thrift feature.
The scene is a composite of common planner logs. It is not a production war story from this account. The rule still holds in review.
Substitution is the bug
Missing config is a hard error. A planner that fills the blank with free compute hides that error. Reviewers then cannot tell intent from accident.
Spend is the wrong lens. The real failure is an undeclared change of runtime. Cheap output from an unnamed path is still untrusted output.
Free model access and free servers can be valid. They belong in a named sandbox profile. They do not belong in a default branch that appears when a secret is empty.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. When a team truly needs a labeled sandbox, MonkeyCode’s free model access and free server option can be an explicit target. The checks below still work if that product is removed.
When not to use a free default
Refuse the plan when any flag below is true. These are stop conditions, not style notes.
- A step names no model id, only
defaultorfallback. - A host field is empty and the worker still starts.
- Retry logic swaps providers without a new approval record.
- The same plan file runs in prod and on a laptop demo.
- Tool results from a free path write to production stores.
- Cost and retention fields are omitted because the path is “free.”
- Timeouts grow until a best-effort model finally replies.
- The agent invents a region, queue, or cache “to stay cheap.”
Each flag is a missing human decision. Price is not a decision.
Better alternatives
Keep the planner honest with five mechanical rules.
- Fail closed on missing credentials and empty host fields.
- Keep a named
sandboxprofile with an allowlist. - Require an explicit label on every free-tier step.
- Pin model id and host id inside the committed plan.
- Send sandbox output only to a throwaway bucket.
A named sandbox is reviewable. An implicit fallback is not.
Artifact: a static plan gate
The following Node script is a proposal. It is not a measured production benchmark. Drop it next to committed agent plans and fail CI on implicit free defaults.
plans/checkout.plan.json is the fixture under test.
{
"name": "checkout-summarize",
"env": "prod",
"steps": [
{
"id": "summarize",
"model": "",
"host": "",
"on_error": "fallback_free",
"writes_to": "orders_prod",
"explicit_sandbox": false
}
]
}
That fixture should never merge. Empty model, empty host, and fallback_free are enough to fail.
gate/fail-implicit-free.mjs walks every step. It records each violation as a stable code.
import { readFileSync } from "node:fs";
const FREE_HINTS = ["free", "sandbox", "best_effort", "fallback_free"];
const PROD_SINKS = ["orders_prod", "users_prod", "billing_prod"];
export function lintPlan(plan) {
const errors = [];
if (!plan || !Array.isArray(plan.steps)) {
return [{ code: "PLAN_SHAPE", msg: "steps[] is required" }];
}
for (const step of plan.steps) {
const model = String(step.model || "").trim();
const host = String(step.host || "").trim();
const onError = String(step.on_error || "").toLowerCase();
const sink = String(step.writes_to || "");
const labeled = step.explicit_sandbox === true;
if (!model) errors.push({ code: "EMPTY_MODEL", step: step.id });
if (!host) errors.push({ code: "EMPTY_HOST", step: step.id });
const freeHint = FREE_HINTS.some((h) =>
`${model} ${host} ${onError}`.includes(h)
);
if (freeHint && !labeled) {
errors.push({ code: "UNLABELED_FREE", step: step.id });
}
if (freeHint && plan.env === "prod") {
errors.push({ code: "FREE_IN_PROD", step: step.id });
}
if (freeHint && PROD_SINKS.includes(sink)) {
errors.push({ code: "FREE_WRITES_PROD", step: step.id });
}
if (onError.includes("fallback") && !step.approved_fallback) {
errors.push({ code: "UNAPPROVED_FALLBACK", step: step.id });
}
}
return errors;
}
const path = process.argv[2];
if (path) {
const plan = JSON.parse(readFileSync(path, "utf8"));
const errors = lintPlan(plan);
if (errors.length) {
console.error(JSON.stringify(errors, null, 2));
process.exit(1);
}
console.log("plan_gate_ok");
}
Run the gate on the bad fixture. The process must exit non-zero.
node gate/fail-implicit-free.mjs plans/checkout.plan.json
echo $?
Expected codes include EMPTY_MODEL, EMPTY_HOST, UNLABELED_FREE, FREE_IN_PROD, FREE_WRITES_PROD, and UNAPPROVED_FALLBACK. Any one of them is enough to block.
Tests that lock the rule
gate/fail-implicit-free.test.mjs uses the Node test runner. It encodes the refusal, not a vibe.
import assert from "node:assert/strict";
import test from "node:test";
import { lintPlan } from "./fail-implicit-free.mjs";
test("empty model and host fail closed", () => {
const errors = lintPlan({
env: "staging",
steps: [{ id: "s1", model: "", host: "" }]
});
const codes = errors.map((e) => e.code);
assert.ok(codes.includes("EMPTY_MODEL"));
assert.ok(codes.includes("EMPTY_HOST"));
});
test("labeled sandbox outside prod may pass", () => {
const errors = lintPlan({
env: "sandbox",
steps: [{
id: "s1",
model: "free-sandbox-model",
host: "free-sandbox-host",
explicit_sandbox: true,
writes_to: "scratch_bucket",
approved_fallback: true
}]
});
assert.equal(errors.length, 0);
});
test("free write into prod sink always fails", () => {
const errors = lintPlan({
env: "prod",
steps: [{
id: "s1",
model: "free-sandbox-model",
host: "free-sandbox-host",
explicit_sandbox: true,
writes_to: "orders_prod"
}]
});
const codes = errors.map((e) => e.code);
assert.ok(codes.includes("FREE_IN_PROD"));
assert.ok(codes.includes("FREE_WRITES_PROD"));
});
Run the tests in CI before the planner is allowed to execute.
node --test gate/fail-implicit-free.test.mjs
Wire the same command into the merge pipeline. A green test job is the only path forward.
# proposal: .github/workflows/plan-gate.yml
name: plan-gate
on: [pull_request]
jobs:
lint-plans:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: node --test gate/fail-implicit-free.test.mjs
- run: |
for f in plans/*.plan.json; do
node gate/fail-implicit-free.mjs "$f"
done
The workflow is a proposal. Teams should match it to their real runner images.
Decision table
Use the table during review. Do not invent a fourth column at the keyboard.
| Signal in the plan | Action | Better alternative |
|---|---|---|
Empty model or host
|
Fail the job | Inject secrets or stop |
on_error: fallback_free |
Fail unless approved | Retry same pinned model |
Free hint in prod env |
Fail the job | Named sandbox profile |
| Free path writes prod data | Fail the job | Scratch bucket only |
| Labeled sandbox, scratch sink | Allow | Keep the label forever |
The last row is the only allow. Everything else is an exit.
Exit criteria
Leave the free path the moment one criterion fires.
- A sandbox label is missing from the committed plan.
- A production sink appears on a free step.
- Provider identity changes between retry attempts.
- Logs cannot show model id and host id together.
- Reviewers cannot replay the step on a pinned target.
Exit means halt, not “try one more free call.” Replay on a named, paid, or local target after the halt.
Limitations
The gate reads static JSON only. It cannot see a runtime swap that never hits disk. It cannot prove a labeled sandbox is actually isolated.
Hint lists go stale. A new alias for free compute will slip through until someone adds it. The script does not measure latency, quality, or cost.
Teams without committed plans get no protection. Chat-only agents need a different control, such as a tool-gateway allowlist.
Who should not use this approach
Skip this gate in a few cases. Those cases still need some other brake.
- Local spikes with no shared data and no merge.
- Jobs that must complete even when credentials are missing.
- Pipelines that do not version plan files at all.
- Vendors that hide model and host identity from the client.
If identity is hidden, refuse the vendor for agent control planes. The gate cannot lint what the API will not name.
What a labeled sandbox looks like
A legal sandbox step is boring. Boring is the point.
{
"name": "prompt-draft",
"env": "sandbox",
"steps": [
{
"id": "draft",
"model": "free-sandbox-model",
"host": "free-sandbox-host",
"on_error": "halt",
"writes_to": "scratch_bucket",
"explicit_sandbox": true,
"approved_fallback": false
}
]
}
on_error is halt. Output stays in scratch_bucket. The label is true and committed. That is the whole contract.
MonkeyCode is an open-source project that offers free model access and a free server option. Those options fit this file only when the ids are pinned and the sandbox label is present. They do not fit a blank field in a prod plan.
Readers who need that labeled path can inspect the project’s free model and free server options and keep the gate in front of them. Unlabeled defaults still fail.
Top comments (0)