Free-tier inference can draft a plan, a schema comment, even a chain of thought that looks like judgment. It cannot be the component that decides whether a tool is allowed to run. The moment a model reply is parsed into an allow-or-deny bit in front of a shell, a filesystem, a payment call, or an MCP-style tool, the hop has left the sandbox and entered a permission layer the free tier does not own.
That distinction is easy to blur because the two hops share a socket. A drafting call returns English. A permission call returns English too. The difference is not the syntax of the prompt. The difference is whether some other process treats those tokens as a key.
Think of a hotel front desk. A clerk who prints a map of the building is useful. A clerk who programs the elevator so the penthouse button lights up is doing a different job, with a different failure mode. Free inference is the map printer. Tool dispatch is the elevator controller. Mixing them is how a timeout, a recycled session, or a politely worded jailbreak becomes an unlock.
The rest of this field guide is about catching that mix before it ships. The artifact is a local checker. It does not call a model. It inspects a hop description and refuses configurations where free inference sits on the grant path.
What a grant path actually is
A grant path is any edge where model output is interpreted as authorization rather than as text. The classic shape is a router: classify intent, then invoke a tool. The router looks harmless in a demo. It is an if-statement wearing a trench coat, except the condition is no longer a Boolean the team wrote. It is a string from a model that may be rate-limited, interrupted, or served from a shared free pool.
When that string is mapped to run_tool(), the model has become IAM. Free-tier IAM is not a module a production process should import. Three properties make the grant path radioactive. Side effects exist outside the transcript. Failure is ambiguous: a 429, an empty choice, or a truncated JSON blob is not the same as deny. Attribution is weak: later, nobody can prove which weights, which tenant neighbor, or which retry produced the allow.
None of those properties depend on a particular vendor name. They show up whenever inference is unpaid, shared, or allowed to vanish mid-sentence. The correct response is not a longer system prompt. The correct response is to take the grant off the model.
Red flags that already mean stop
The first red flag is a parser that looks for assent. Code that scans for yes, allow, or {"authorized": true} before calling subprocess has already appointed the model as a guard. A guard that can be sweet-talked is a doorstop.
The second is retry-as-policy. A free server that times out on the first pass and returns a confident allow on the third is not resilient. It is a coin flip with extra latency. Authorization retries must be deterministic. Free inference retries are not.
The third is secret-bearing tool descriptions. MCP-style catalogs often include endpoint URLs, header names, or example payloads. Sending that catalog to a free model so the model can choose the right tool leaks the map of the building to a clerk who does not work for the hotel.
The fourth is letting the model pick the tool server, not just the argument. Binding files versus shell versus payments is a deployment decision. It belongs in a checked-in allowlist. A free model that returns a server name is performing change management without a review.
The fifth is using the same free hop to write the policy that later constrains it. Self-authored guardrails are circular. The model that wants to call rm should not be the author of the regex that claims to forbid rm. If any one of those shows up in a design review, the free inference hop is already on the wrong side of the door.
A checker that fails closed
The following module is a proposal for a local gate. It never opens a network socket. It treats a hop as data and exits non-zero if free inference would be asked to grant.
# permission_hop_check.py
# Proposal: local static check. Not executed against a live agent in this article.
from __future__ import annotations
from dataclasses import dataclass
from typing import Iterable
SIDE_EFFECT_TOOLS = frozenset({
"shell",
"fs_write",
"http_post",
"payments",
"email_send",
"mcp_bind",
})
GRANT_ROLES = frozenset({
"authorizer",
"router",
"tool_picker",
"policy_writer",
})
@dataclass(frozen=True)
class Hop:
name: str
inference_class: str # "free" | "dedicated" | "none"
role: str
tools_downstream: tuple[str, ...]
retries_on_timeout: int = 0
sends_tool_catalog: bool = False
parses_assent: bool = False
writes_own_policy: bool = False
@dataclass(frozen=True)
class Finding:
hop: str
code: str
detail: str
def scan(hops: Iterable[Hop]) -> list[Finding]:
findings: list[Finding] = []
for hop in hops:
if hop.inference_class != "free":
continue
if hop.role in GRANT_ROLES:
findings.append(Finding(
hop.name,
"FREE_ON_GRANT_PATH",
f"role {hop.role!r} treats model text as authorization",
))
if hop.parses_assent:
findings.append(Finding(
hop.name,
"ASSENT_PARSER",
"yes/allow JSON is being used as a lock",
))
if hop.retries_on_timeout > 0 and hop.role in GRANT_ROLES:
findings.append(Finding(
hop.name,
"RETRY_AS_POLICY",
"timeout retry can flip deny into allow",
))
if hop.sends_tool_catalog:
findings.append(Finding(
hop.name,
"CATALOG_EXFIL_SURFACE",
"tool map is leaving the process toward a free model",
))
if hop.writes_own_policy:
findings.append(Finding(
hop.name,
"SELF_AUTHORED_GUARD",
"the constrained model is authoring its constraint",
))
dangerous = tuple(t for t in hop.tools_downstream if t in SIDE_EFFECT_TOOLS)
if dangerous and hop.role in GRANT_ROLES:
findings.append(Finding(
hop.name,
"SIDE_EFFECT_BEHIND_FREE",
f"downstream tools {dangerous} require a non-model grant",
))
return findings
def assert_no_free_grants(hops: Iterable[Hop]) -> None:
bad = scan(hops)
if not bad:
return
lines = [f"{f.code}: {f.hop}: {f.detail}" for f in bad]
raise SystemExit("free inference on grant path\n" + "\n".join(lines))
if __name__ == "__main__":
proposed = [
Hop(
name="intent_router",
inference_class="free",
role="router",
tools_downstream=("shell", "fs_write"),
retries_on_timeout=3,
sends_tool_catalog=True,
parses_assent=True,
),
Hop(
name="draft_plan",
inference_class="free",
role="drafter",
tools_downstream=(),
),
]
assert_no_free_grants(proposed)
A drafting hop named draft_plan passes. The router does not. That is the whole policy, encoded as data rather than as hope. The sample command is blunt on purpose:
python permission_hop_check.py
# expected: non-zero exit, FREE_ON_GRANT_PATH first
A tiny test makes the refusal mechanical so a later refactor cannot quietly put the free hop back in front of shell.
# test_permission_hop_check.py
import unittest
from permission_hop_check import Hop, scan
class FreeGrantTests(unittest.TestCase):
def test_drafter_without_tools_is_quiet(self):
hops = [Hop("draft", "free", "drafter", ())]
self.assertEqual(scan(hops), [])
def test_free_router_in_front_of_shell_fails(self):
hops = [Hop("r", "free", "router", ("shell",), parses_assent=True)]
codes = {f.code for f in scan(hops)}
self.assertIn("FREE_ON_GRANT_PATH", codes)
self.assertIn("ASSENT_PARSER", codes)
self.assertIn("SIDE_EFFECT_BEHIND_FREE", codes)
def test_dedicated_authorizer_is_out_of_scope(self):
hops = [Hop("authz", "dedicated", "authorizer", ("payments",))]
self.assertEqual(scan(hops), [])
if __name__ == "__main__":
unittest.main()
python -m unittest test_permission_hop_check.py
The dedicated authorizer is not blessed. It is simply not this checker's job. Dedicated inference still needs audit logs, allowlists, and a human-owned policy. The checker only answers whether a free hop has wandered onto the grant path.
Hops can live next to the service config the team already reviews. A YAML list keeps the grant decision out of prompt text.
# hops.yaml — proposal, loaded by CI, never sent to a model
hops:
- name: draft_plan
inference_class: free
role: drafter
tools_downstream: []
- name: intent_router
inference_class: free
role: router
tools_downstream: [shell, fs_write]
retries_on_timeout: 3
sends_tool_catalog: true
parses_assent: true
Better alternatives than a nicer prompt
Replace the model-shaped if-statement with an actual if-statement. Intent that can be named in a ticket can usually be named in code: path prefixes, authenticated user ids, change type, environment. Those predicates belong in the repo, next to the tests that freeze them.
Where language is genuinely required, keep the model on the drafting side of a queue. Let it propose a tool name and arguments as a patch. A separate, non-model component compares that proposal to a static allowlist and either drops it or waits for a reviewer. The allowlist is the lock. The model is the comment on the lock.
Timeouts must deny. Empty choices must deny. Malformed JSON must deny. That is not pessimism. That is how locks work when the other side of the socket can disappear. A small wrapper makes the fail-closed rule visible in the call site rather than buried in retry middleware.
# fail_closed.py — proposal for any grant-adjacent call, free or not
class GrantDenied(Exception):
pass
def deny_on_empty_or_timeout(payload, timed_out: bool) -> dict:
if timed_out:
raise GrantDenied("timeout is deny")
if not payload:
raise GrantDenied("empty choice is deny")
if not isinstance(payload, dict):
raise GrantDenied("non-object payload is deny")
if "tool" in payload or "authorized" in payload:
raise GrantDenied("model text is not a grant")
return payload
For MCP-style catalogs, send the model a redacted index: tool names and one-line purposes, never live URLs, never example auth headers. Bind the real server in config the model cannot see. The catalog that authorizes a call and the catalog that explains a call are different documents. Only the second one belongs near a free model.
Exit criteria
Leave the free hop the moment any of the following becomes true. A tool has a side effect outside the chat transcript. An operator would be uncomfortable pasting the prompt plus tool catalog into a public gist. A retry could change the authorization outcome. The model is asked to choose among servers, not among phrasings. The output is stored as an audit decision.
Those exits are cheap if they happen in design. They are expensive if they happen after a shell tool has already run. A free server is a reasonable place to practice the drafting side. It is an unreasonable place to keep the only copy of a policy, a catalog, or a grant.
Teams that still want a cheap room for that practice can park routing language on MonkeyCode's free model access and free server option, then run permission_hop_check.py on the hop list before anything is wired to tools. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The checker does not depend on that sandbox. If the room is unavailable, the same scan still runs on a laptop.
Who should ignore this guide
Throwaway chat experiments with no tools can ignore it. So can notebooks that never leave stdout. The guide is aimed at the hour when a demo sprouting shell and http_post starts to look like a product.
Do not use the checker as a substitute for real authorization. A green scan means only that free inference is not pretending to be IAM. It does not mean the dedicated path is safe, that MCP servers are scoped, or that secrets are absent from prompts. Do not treat SIDE_EFFECT_TOOLS as complete. Add the names the team actually ships. A payments wrapper called settle will sail through until someone writes it down.
The conservative reading is the point. Free inference is abundant at the map printer. It is the wrong clerk to hand the elevator key. Keep the grant somewhere else.
Top comments (0)