免費編碼智能體的拒絕契約:用 diff 閘門限制改動範圍
A Refusal Contract for Free Coding Agents
文章提出以 diff 閘門約束免費編碼智能體改動範圍的拒絕契約,腳本依政策將變更判為允許、交由人工覆核或拒絕。示例政策只允許 src/ 和 tests/,拒絕 tools/、infra/、.github/ 及 *.lock,並設最多改動 5 個檔案、200 行的門檻;作者説明示例程式僅供説明,未在真實儲存庫執行。
A free coding agent fails in a predictable direction: it says yes. Ask for a one-line fix and the patch also renames a directory. Ask for a test and the diff arrives with a modified lockfile and a new CI workflow attached. The damage is rarely bad syntax. It is correct code in a place nobody approved.
Prompts do not fix this. "Be conservative" is advice, and advice loses to a strong prior. What holds is a contract the agent cannot satisfy by talking: a diff that either passes a rule set or never reaches review. The useful question is not which model wrote the patch, but what the patch is allowed to touch once it exists.
This is a field guide to that boundary, including the cases where the whole arrangement should be abandoned.
Put the refusal outside the model
The instinct is to filter output. A sturdier boundary is the artifact — the pull request diff, checked by a script with no opinion about prose. The script is boring, testable, and identical whether a free model, a paid model, or a tired human produced the change.
Three files carry the contract. tools/policy.yaml states the allowed blast radius. tools/gate.py reads a diff and returns one of three verdicts. The gate's JSON output becomes the receipt attached to the pull request, so the next reader sees why something stopped instead of guessing.
# tools/policy.yaml
allow_paths:
- src/
- tests/
deny_paths:
- tools/ # the gate must not rewrite its own rules
- infra/
- .github/
- '*.lock'
max_files_changed: 5
max_lines_changed: 200
The deny entry for tools/ is not decoration. If the policy file is editable by the same process it constrains, every other rule is advisory. That single line is the difference between a contract and a suggestion.
The gate
Roughly sixty lines, standard library plus PyYAML. It reads the diff against a base ref rather than trusting the agent's own summary of what it did. The code below is illustrative — written for this article and not executed against a real repository — so treat the thresholds as a starting point, not a calibrated policy.
#!/usr/bin/env python3
"""tools/gate.py - a refusal gate for agent-produced diffs."""
import json, os, subprocess, sys
from fnmatch import fnmatch
import yaml
def git(*args):
return subprocess.run(['git', *args], capture_output=True, text=True, check=True).stdout
def diff_stats(base):
files, added, removed = [], 0, 0
for line in git('diff', '--numstat', base + '...').splitlines():
a, d, name = line.split('\t')
files.append(name)
added += int(a) if a.isdigit() else 0
removed += int(d) if d.isdigit() else 0
return {'files': files, 'added': added, 'removed': removed}
def hit(name, patterns):
for pat in patterns:
if fnmatch(name, pat) or fnmatch(os.path.basename(name), pat):
return pat
return None
def verdict(stats, policy):
reasons = []
for name in stats['files']:
if hit(name, policy['deny_paths']):
reasons.append('denied_path:' + name)
outside = [n for n in stats['files'] if not hit(n, policy['allow_paths'])]
if outside:
reasons.append('outside_allowlist:' + ','.join(outside))
if len(stats['files']) > policy['max_files_changed']:
reasons.append('file_budget:' + str(len(stats['files'])))
churn = stats['added'] + stats['removed']
if churn > policy['max_lines_changed']:
reasons.append('churn_budget:' + str(churn))
if not reasons:
decision = 'allow'
elif any(r.startswith('denied_path') for r in reasons):
decision = 'refuse'
else:
decision = 'review'
return dict(decision=decision, reasons=reasons, **stats)
if __name__ == '__main__':
base = sys.argv[1] if len(sys.argv) > 1 else 'origin/main'
with open('tools/policy.yaml', encoding='utf-8') as fh:
policy = yaml.safe_load(fh)
result = verdict(diff_stats(base), policy)
print(json.dumps(result, indent=2))
sys.exit({'allow': 0, 'review': 1, 'refuse': 2}[result['decision']])
Exit codes matter more than the JSON body. CI branches on the number without parsing anything.
set +e
python3 tools/gate.py origin/main > receipt.json
status=$?
case $status in
0) echo 'allow: normal review path' ;;
1) gh pr edit "$PR" --add-label needs-human ;;
2) gh pr close "$PR" --comment 'Refused by policy. See receipt.json.' ;;
esac
The middle verdict does the most work. Most agent patches are not hostile; they are over-broad. review hands a human the cheap decision — this touched three extra files, was that intended? — without pretending the diff is dangerous. A gate that only knows allow and block becomes a nuisance within a week and gets disabled.
Shapes worth denying on sight
Lockfiles and generated code are the clearest case for refusal over review. The diff is unreadable, regeneration is the correct fix, and review time spent on it is time not spent on the actual change. Deny the path and let the lockfile update happen deliberately.
Directory moves and import rewrites deserve the same treatment. They are mechanically easy for a model and expensive for a reviewer, because a single missed reference surfaces later as a runtime error in an unrelated test. If a task needs a rename, it needs a task of its own.
Anything adjacent to secrets belongs behind a hard deny: environment files, deploy keys, credential fixtures, and the deployment directory that reads them. A model does not need to exfiltrate anything to cause harm here; pasting a realistic-looking key into a test fixture is enough to trip scanners for a month.
Finally, long-lived processes and CI configuration. An agent that starts a watcher, a dev server, or a background job leaves state behind that nobody accounts for. And a patch that edits the workflow which runs the gate has effectively voted on its own rules.
Where a free model and a free server fit
The drafting step is the part that benefits from cheap inference: generate a candidate patch, run it through the gate, discard most of it. The free model access in MonkeyCode is a reasonable place to do that drafting, and the free server option is a reasonable place to run the gate, because both jobs are short-lived and neither needs standing database credentials. The agent finishes, the gate renders a verdict, the process exits. Nothing holds a lease on your infrastructure.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
The free allowance and the free server are operator-supplied claims, currently described as a ten-million-token allowance with a hosted option. Verify the current terms yourself before sizing work around them; a number you have not re-read this month is a number you should not plan capacity on. The same caution applies to the project being open source — read the code that would run in your pipeline, not the landing page.
A test you can rerun
Five fixture tasks are enough to learn whether the gate helps or merely annoys. Seed a repository with one task that genuinely belongs inside the allowlist, two that need a single extra file, one that must touch a lockfile, and one that only makes sense as a CI edit. Label the expected verdict for each in a plain TSV.
pass=0; total=0
while IFS=$'\t' read -r task expected; do
total=$((total+1))
./run_agent.sh "fixtures/tasks/$task" > "out/$task.diff"
git apply --index "out/$task.diff"
got=$(python3 tools/gate.py origin/main | jq -r .decision)
git reset --hard -q origin/main
[ "$got" = "$expected" ] && pass=$((pass+1))
done < fixtures/labels.tsv
echo "$pass/$total"
run_agent.sh is your harness around whatever model you are evaluating; swap it and the scoring loop stays. Three numbers fall out of a single run: agreement with your labels, the false-refusal rate on tasks that were fine, and the churn per accepted patch. The last one trends upward quietly, and it is the earliest sign that the allowlist is too loose.
Exit criteria
Three signals say the arrangement has stopped paying for itself. If more than one accepted patch in five needs a follow-up human fix in the same file within a week, the gate is letting through work it should have reviewed — tighten the budget before tightening the prompt. If review verdicts outnumber allow verdicts for two consecutive weeks, the tasks being routed here are the wrong shape, and no amount of model quality fixes routing. If time-to-first-edit on the free path exceeds what a developer would spend writing the patch by hand, the free path is a hobby.
Retire it deliberately rather than by attrition. Move the repeated task shapes to a paid or primary path, narrow the free path to drafting inside a small allowlist, and keep the gate either way — the gate is useful with any model behind it.
Who should not use this
Teams without CI on the agent's output should not start here; a gate that nobody runs is a document. Repositories dominated by generated code will fight the churn budget on every patch, and the honest answer is path-level exclusion, not a cleverer threshold. Work that requires a long-lived process, a persistent session, or access to production credentials should stay on infrastructure you own end to end. And any workflow with an audit requirement that forbids third-party services in the review path is simply out of scope, regardless of how cheap the drafting gets.
If you adopt one idea from this, make it the deny rule protecting the policy file itself. Every other line in the contract assumes the rules are out of reach.
來源:DEV Community · dev.to