A reviewer opened a late pull request tonight. The coding agent had added a GitHub webhook. The callback URL sat on a free host.
Signature checks lived beside the tool loop. The reviewer refused the merge that night.
Free inference is not equal to public ingress. Agents blur that line during planning work. They print a URL and call planning done.
Delivery retries and TLS remain required details. This field guide blocks inbound traffic mistakes. It targets webhooks, redirects, and MCP HTTP.
It does not ban free models for drafts.
The failure that looks like progress
The agent needed a GitHub App callback. It also needed a Stripe event target. It reused the origin used for chat.
That origin was a free experimental server host. Providers retry after timeouts and 5xx replies. A sleeping free host looks like an outage.
The provider then marks the endpoint failing. The agent fixes outages with extra retries. That loop burns tokens and burns trust.
Progress in the chat is not production truth. It does not create a durable contract.
Two retry loops, one outage
Webhook vendors retry on their own schedule. Agents retry when a tool returns 5xx. Those loops do not share a budget.
The host wakes, accepts one POST, then stalls. Duplicate deliveries arrive without idempotency keys. The agent invents a second handler for duplicates.
That handler usually shares the first secret. The blast radius grows with every fix. Stop the plan before the second handler lands.
What counts as ingress here
Treat these surfaces as ingress, not chat.
- GitHub, GitLab, and Stripe webhook endpoints
- OAuth redirect URIs for app installs
- MCP streamable HTTP or SSE endpoints
- Tool callbacks that receive untrusted POSTs
- Browser WebSocket command channels for agents
Outbound model calls are a different risk class. Inbound HTTP is a contract with strangers. Free hosts rarely match that inbound contract.
Red flags in the agent plan
Refuse the plan when items below appear.
- The callback hostname equals the chat hostname.
- The printed URL uses http, not https.
- The host has no pinned certificate owner.
- The process may sleep after becoming idle.
- Signature secrets live inside the prompt context.
- The agent proposes ngrok, raw IP, or tickets.
- MCP HTTP binds
0.0.0.0on a shared host. - Retry policy is looping until a 200.
- The runbook lacks replay and dead-letter paths.
- Health checks are TCP only, never signed.
One flag can remain a review comment. Three flags should stop the merge cold. Ingress plus process sleep is always stop.
Decision table
Use this table before the agent writes YAML.
| Need | Free host default | Better default | Exit if |
|---|---|---|---|
| Draft tool schemas | Allowed | Local mock server | Schema handles live events |
| GitHub webhook | Not allowed | Dedicated ingress and queue | Provider marks endpoint failing |
| OAuth redirect | Not allowed | Stable HTTPS app origin | Redirect URI changes per session |
| MCP stdio for one user | Allowed | Local process only | Remote users need the tool |
| MCP HTTP for a team | Not allowed | Named service plus auth | Bind address is public |
| Draft text on a free model | Allowed | Same, with redaction | Prompts include webhook secrets |
| Signed payload verify | Not on free host | Worker in front of a queue | Verifier shares memory with agent |
The table is a gate, not a slogan. Teams should copy it into the repository.
Reproducible gate: fail CI on denied ingress
The artifact is a small Node checker. It scans env samples and agent plans. It fails webhook keys on denylisted hosts.
Label this example as a starter template. Teams must fill the denylist themselves. Do not treat host strings as vendor facts.
#!/usr/bin/env node
"use strict";
const fs = require("fs");
const path = require("path");
const INGRESS_KEY =
/(WEBHOOK|CALLBACK|REDIRECT_URI|MCP_HTTP|INGRESS|PUBLIC_BASE)/i;
const DENY_HOSTS = new Set([
"localhost",
"127.0.0.1",
"0.0.0.0",
"example-free-host.invalid",
]);
const DENY_HINTS = [
/free[- ]?server/i,
/free[- ]?host/i,
/ngrok\./i,
/trycloudflare\./i,
];
function walk(dir, acc = []) {
if (!fs.existsSync(dir)) return acc;
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
if (ent.name === "node_modules" || ent.name === ".git") continue;
const p = path.join(dir, ent.name);
if (ent.isDirectory()) walk(p, acc);
else if (/\.(env|ya?ml|md|json|toml|sample)$/i.test(ent.name)) acc.push(p);
}
return acc;
}
function hostOf(value) {
try {
return new URL(value).hostname.toLowerCase();
} catch {
return String(value).toLowerCase();
}
}
function hits(text, file) {
const findings = [];
const lines = text.split(/\r?\n/);
lines.forEach((line, i) => {
const urls = line.match(/https?:\/\/[^\s"'`]+/gi) || [];
for (const raw of urls) {
const nearIngress =
INGRESS_KEY.test(line) || /webhook|redirect_uri|mcp/i.test(line);
if (!nearIngress) continue;
const h = hostOf(raw);
const denied =
DENY_HOSTS.has(h) ||
DENY_HINTS.some((re) => re.test(raw) || re.test(line));
if (denied) findings.push({ file, line: i + 1, host: h });
}
});
return findings;
}
const root = process.argv[2] || ".";
const all = walk(root).flatMap((f) => hits(fs.readFileSync(f, "utf8"), f));
if (all.length) {
console.error("ingress-deny: webhook-like URL on a blocked host");
for (const x of all) {
console.error(`- ${x.file}:${x.line} host=${x.host}`);
}
process.exit(1);
}
console.log("ingress-deny: no blocked callback hosts");
Run the checker as a required status check.
node scripts/ingress-deny.js .
Wire the same command into GitHub Actions.
name: ingress-deny
on: [pull_request]
jobs:
deny:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- run: node scripts/ingress-deny.js .
It only blocks known-bad callback defaults here. The script does not prove paid hosts safe.
Test plan for the checker
Keep a tiny fixture tree under testdata. One fixture file must fail on purpose. Another fixture file must pass the checker.
Create testdata/fail/.env.sample with a blocked webhook.
GITHUB_WEBHOOK_URL=https://example-free-host.invalid/hooks/github
Create testdata/pass/.env.sample with dedicated ingress.
GITHUB_WEBHOOK_URL=https://hooks.internal.example/github
Run both cases in the same CI job.
node scripts/ingress-deny.js testdata/pass
! node scripts/ingress-deny.js testdata/fail
The second command must exit nonzero on purpose. A checker that never fails is theater. Keep the fail fixture next to the script.
Better alternatives
Keep free model access for draft text. Move ingress off that experimental machine entirely.
- Terminate webhooks on a dedicated HTTPS worker.
- Verify signatures before any model sees bytes.
- Enqueue the event after the signature check.
- Pin OAuth redirect URIs to one app origin.
- Prefer MCP stdio for single-user local tools.
- Put team MCP HTTP behind an identity proxy.
- Store replay keys outside the prompt buffer.
The agent should consume a queue later. The free model never owns the socket.
Deterministic verify, never a model tool
HMAC checks belong only in ordinary application code. They do not belong in a tool the agent may skip. A model cannot be the source of timing-safe equality.
Put WHSEC in the worker environment only. Never echo it into plan markdown files. Never ask a free model to check signatures.
A verify-then-queue worker stays small enough. Label the next snippet an unexecuted example.
import http from "node:http";
import crypto from "node:crypto";
import fs from "node:fs";
function valid(sigHeader, raw, secret) {
const want = crypto
.createHmac("sha256", secret)
.update(raw)
.digest("hex");
const got = String(sigHeader || "").replace(/^sha256=/, "");
const a = Buffer.from(got, "utf8");
const b = Buffer.from(want, "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
http
.createServer(async (req, res) => {
const chunks = [];
for await (const c of req) chunks.push(c);
const raw = Buffer.concat(chunks);
if (!valid(req.headers["x-hub-signature-256"], raw, process.env.WHSEC)) {
res.writeHead(401);
res.end("bad sig");
return;
}
fs.appendFileSync("queue.jsonl", raw.toString("utf8") + "\n");
res.writeHead(202);
res.end("queued");
})
.listen(8080);
The worker acknowledges after the signature check. The agent reads queue.jsonl on its own schedule. Raw provider POSTs never enter the prompt.
Exit criteria
Leave the free host path when conditions trip.
- A third party must POST to the agent.
- Delivery retries can page a human operator.
- OAuth redirect URIs cannot change per session.
- Payload signatures must hold under replay attacks.
- The listener cannot sleep without losing events.
- More than one team consumes the MCP HTTP port.
- Prompt logs might capture webhook signing secrets.
- The agent proposes polling harder after 5xx.
Exit means move ingress, not abandon drafting. Free models can still summarize queued events. They should not accept the raw POST.
When free model access is still fine
Draft the webhook schema with a free model. Ask it for field names, not runtime hosts. Strip secrets before pasting any provider payloads.
That split keeps the cheap path useful. Schema help is not the same as socket ownership. Reviewers should reward that split in comments.
When MonkeyCode is the wrong default
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
MonkeyCode offers free model access and a free server option. Agents treat that pair as one deployment. That collapse is the defect this guide blocks.
Draft prompts on free model access only. Do not publish a webhook to the free server option. Teams comparing sandboxes can run the ingress gate on a sample repo first.
The CI gate remains useful without product names.
Who should not use this gate
Skip this workflow in a few cases.
- Fully local agents with stdio tools only
- Batch jobs that never accept inbound HTTP
- Platforms that already force dedicated signed ingress
- Throwaway demos bound to a private loopback port
The gate also fails closed on localhost callbacks. Some OAuth apps need that during development. Maintain an allowlist for loopback in local jobs.
Never copy that allowlist into production CI.
Limitations
The scanner reads text, not runtime traffic. Renamed env keys can dodge the checker. Encrypted compose files remain fully invisible here.
A paid hostname can still drop TLS. Teams still need provider delivery logs daily. They still need signature tests in CI.
They still need a dead-letter queue. This guide only stops a common default. Do not claim durability because CI passed.
Pass means the denylist did not match.
Practical sequence
- Copy the decision table into
INGRESS.md. - Add
scripts/ingress-deny.jsas a required check. - Fill
DENY_HOSTSwith this team's free-tier origins. - Put webhook workers in a separate service folder.
- Redact secrets before a free model summarizes events.
- Re-run the checker after the agent edits YAML.
The sequence is boring on purpose here. Boring ingress survives weekend agent edits well. Invented callback URLs do not survive contact.
Teams can keep free model access for plan text. They should run the ingress gate first. No public URL should leave the repository unchecked.
Top comments (0)