Module 06
Scope Contracts
On this page
In plain words
You tell a junior to book the sound system for the fest, and he also changes the stage, the caterer and the DJ. Every step had a reason, but the plan is now different. Agents do the same with code. So before the run you write a small contract file: goal, files it may touch, files it must never touch, and how to undo. After the run, a checker compares the diff to that file.
How it flows
- 1Write the contract
- 2Agent does the task
- 3Collect the diff
- 4Check against globs
- 5Pass or block
A tiny example
contract = {
"allowed_files": ["payments/**/*.py"],
"forbidden_files": ["migrations/**"],
}
for path in touched_files(diff):
if matches(path, contract["forbidden_files"]):
block(path, "forbidden")
elif not matches(path, contract["allowed_files"]):
warn(path, "off scope")Notice that a forbidden path blocks straight away, while a merely unlisted path only warns.
What you will learn
- Why an agent quietly changes ten files when you asked for one fix.
- How to write a small scope contract that says what the agent may and may not touch.
- How to check the agent's final diff against that contract, automatically.
- Which extra limits (time, network, approvals) belong in the same file.
The problem, simply
Think about a college fest. You tell your junior, "Please book the sound system." He comes back having booked the sound system, and also changed the stage layout, called a new caterer, and promised the DJ a slot.
Ask him why, and every answer sounds reasonable. Each step had a good reason in the moment. But together, it is a completely different plan from the one you approved.
Agents do exactly this with code. You say "fix the login bug." The final diff touches the login route, the email helper, the database driver, the README, and the deploy script. Nothing crashed. The agent narrated every step politely as it went.
This is called scope creep, and it is the failure people monitor the least, because it never looks like a failure. A stricter prompt does not fix it, since the agent can always talk itself past a sentence. What fixes it is a file on disk saying what was promised, plus a check that compares the result against that promise.
The idea
A scope contract is a small JSON file written before the agent starts. The agent reads it at the beginning. A checker reads it at the end, along with the diff.
- 1Task
- 2Write contract
- 3Agent works
- 4Final diff
- 5Checker compares
- 6Pass or block
What goes inside the contract
task_id— links this work to the ticket on your board.goal— one sentence a reviewer can actually verify.allowed_files— the file patterns the agent may write.forbidden_files— the patterns it must not touch, even by accident.acceptance_criteria— the test command that proves it is done.rollback_plan— how to undo this, in one paragraph.approvals_required— actions that need a human to say yes.
IMPImportant: A contract without
forbidden_filesis only half a contract. Saying what is not allowed is as important as saying what is.
Use patterns, not exact paths
Write app/**/*.py, not app/routes/login.py. Real repos move files around. After a refactor an exact path is wrong, but a pattern still holds.
Rollback is part of scope
Writing "how do I undo this" forces you to think about what could break. Basically, if you cannot describe the rollback, do not approve the task yet.
A worked example
Suppose Priya asks the agent to fix a payment bug in her Django project. Her contract says allowed_files is payments/**/*.py and tests/test_payments*.py. forbidden_files is migrations/** and config/prod/**.
The agent finishes. The diff has four files: two in payments/, one test file, and one called migrations/0042_add_index.py.
The checker matches each file against the patterns. Three are fine. The fourth hits a forbidden pattern, so the checker returns a violation with the exact file and reason, and the merge gate refuses. Priya sees it in ten seconds instead of finding a surprise migration in production next week.
Two levels of scope
One contract bounds one task. It does not bound the whole project.
An agent can stay perfectly inside Priya's payment contract and then, next turn, decide the project also needs a dark mode toggle and a router rewrite. Nobody asked it what was in scope for the project.
So keep a second file, a feature list: your backlog, but machine-readable and ordered. Each feature has an id, a status (todo, in_progress, done, blocked), a goal, and a done_when line. The agent picks one todo feature, writes that id into the contract's task_id, and may not start a second one in the same session.
"At most one feature in progress" then becomes a startup check: if the file shows two, the session refuses to start until a human sorts it out. Keep it as a file, not a chat message, because chat scrolls away while a file survives across sessions and across agents.
- 1Feature list
- 2Pick one todo
- 3Task contract
- 4Agent
- 5Mark done
Softer gates that people actually keep
A gate that blocks on every tiny slip gets switched off within a week. So real setups add a violation budget: slips inside the budget are warnings, and only crossing the budget refuses the merge.
Severity also differs by folder. An off-scope write to docs/** is usually a warning. One to scripts/**, migrations/** or config/prod/** should always block. This asymmetry lives in the contract, because it changes per project and often per task.
Add time_budget_minutes so the run stops at a wall-clock limit without fresh approval, and a network_egress allowlist of hostnames so the agent cannot quietly call an outside API. File patterns alone are necessary but not enough.
When two contracts apply
Sometimes a project-wide contract and a task contract both apply. Merge by least privilege, meaning the stricter side always wins:
allowed_files: intersect — both must permit the path.forbidden_files: union — either can prohibit it.time_budget_minutes: the smaller number.approvals_required: add them all up.network_egress:Nonemeans no rule and defers to the other side; two lists intersect; an empty list is deny-all, and deny-all stays.
Remember: the contract is not a suggestion to the model, it is data that a checker reads afterwards.
One team that started writing scope contracts before every run reported their rabbit-hole rate falling from 52% to 21% in three weeks, with no change to the agent. The contract did the work, not the model.
Build it
"""A tiny scope contract checker. Standard library only."""
import fnmatch
import json
# The contract: written by a human BEFORE the agent starts.
CONTRACT = {
"task_id": "PAY-114",
"goal": "Fix the duplicate UPI charge on retry",
"allowed_files": ["payments/**/*.py", "tests/test_payments*.py"],
"forbidden_files": ["migrations/**", "config/prod/**"],
"acceptance_criteria": ["pytest tests/test_payments.py"],
"rollback_plan": "Revert the branch and redeploy the previous build.",
"hard_block_globs": ["migrations/**", "config/prod/**", "scripts/**"],
"violation_budget": 1, # this many warnings are tolerated
"time_budget_minutes": 30,
}
def matches(path, patterns):
"""True if the path matches any glob pattern in the list.
fnmatch has no idea about '**', so for a pattern like 'app/**/*.py'
we also try the flattened form 'app/*.py' to cover files sitting
directly inside the folder.
"""
for p in patterns:
forms = {p, p.replace("/**/", "/")}
if any(fnmatch.fnmatch(path, f) for f in forms):
return True
return False
def scope_check(contract, touched_files, minutes_used):
"""Compare one agent run against the contract."""
violations = []
for path in touched_files:
if matches(path, contract["forbidden_files"]):
violations.append(("block", path, "listed in forbidden_files"))
elif not matches(path, contract["allowed_files"]):
level = "block" if matches(path, contract["hard_block_globs"]) else "warn"
violations.append((level, path, "outside allowed_files"))
if minutes_used > contract["time_budget_minutes"]:
violations.append(("block", "<clock>", "time budget exceeded"))
blocks = [v for v in violations if v[0] == "block"]
warns = [v for v in violations if v[0] == "warn"]
passed = not blocks and len(warns) <= contract["violation_budget"]
return violations, passed
def report(label, files, minutes):
violations, passed = scope_check(CONTRACT, files, minutes)
print(f"\n=== {label} ({minutes} min) ===")
for level, path, why in violations:
print(f" [{level.upper()}] {path} -> {why}")
if not violations:
print(" no findings")
print(" VERDICT:", "IN SCOPE" if passed else "BLOCKED")
return {"run": label, "passed": passed, "violations": violations}
# Run 1 stays inside. Run 2 creeps.
good = ["payments/charge.py", "tests/test_payments.py"]
creep = ["payments/charge.py", "README.md", "migrations/0042_index.py"]
results = [report("clean run", good, 12), report("creeping run", creep, 41)]
print("\nreport json:", json.dumps({"task": CONTRACT["task_id"], "runs": results}))Look at the second run. README.md is only a warning, but migrations/0042_index.py and the clock come back as blocks, so the verdict flips to BLOCKED. Now add docs/notes.md to the first run: you get one warning, and it still passes because the budget forgives it. Set violation_budget to 0 and that same run gets blocked.
Where you will see this
- Claude Code and Cursor, where a rules file pins the allowed folders before the agent touches anything.
- GitHub pull requests, where CI runs a scope check on the merge diff and comments when off-ticket files changed.
- Merge gates built on top of coding agents, which ship violation budgets so the gate does not get disabled.
- Customer-support bots, where the contract is which actions are allowed: read an order, yes; refund ₹4,000, only with human approval.
- Any repo with a production config folder, where "never touch this path" beats any amount of prompt politeness.
Common mistakes
- Writing only
allowed_filesand skippingforbidden_files. The risky paths are the ones nobody remembered either way, so they slip through as soft warnings. - Pinning exact paths instead of patterns. One refactor later the contract matches nothing and every file looks off-scope.
- Making every violation a hard block. The team disables the gate within two weeks. Grade severity by folder and keep a budget.
- Trusting the prompt instead of the checker. "Stay in scope" is a wish; comparing the diff to a file is a check.
- Skipping the rollback plan. When it breaks at 11 pm you want a written paragraph, not a group call.
If they ask in an interview
Q: How do you stop an AI coding agent from touching files it should not?
A: I write a scope contract before the run: a JSON file with the goal, allowed globs, forbidden globs, acceptance tests and a rollback plan. After the run, a checker compares the diff against those globs. The enforcement sits in the checker, not the prompt.
Q: Why globs instead of listing the exact files?
A: Real repositories move files during refactors. An exact path goes stale between sessions, while a pattern like app/**/*.py keeps describing the same intent. It also lets me write the contract before I know exactly which files the fix will need.
Q: Two contracts apply to one run. How do you combine them?
A: Least privilege. Intersect the allowed lists, union the forbidden lists, keep the smaller time budget, and accumulate every required approval. For network rules, no rule defers to the other side, two lists intersect, and deny-all stays deny-all.
Try these
- Add a
network_egresslist of allowed hostnames, pass in the hosts the run contacted, and block any host not on the list. - Make
docs/**always a warning andscripts/**always a block. Write two sentences on why that asymmetry fits your project. - Generate
allowed_filesfrom thegoalsentence using keyword rules and no model. Note the first case where it goes wrong. - Write
merge_contracts(a, b)using the least-privilege rules here, then check that the stricter side always wins.
Words, simply
| Word | Meaning in simple words |
|---|---|
| Scope contract | A small file saying what this one task may and may not change |
| Scope creep | Files changed that nobody asked to be changed |
| Glob | A pattern like app/**/*.py that matches many file paths |
| Forbidden files | Paths the agent must never write, even for a good reason |
| Rollback plan | One paragraph on how to undo the change if it goes bad |
| Violation budget | How many small slips are forgiven before the gate refuses |
| Least privilege | When rules clash, always keep the stricter one |
| Feature list | An ordered backlog file so only one feature runs at a time |
Quick recap
- Agents creep because every single step sounds reasonable; only the total is wrong.
- Put the promise in a file with globs, forbidden paths, acceptance and rollback, then check the diff against it.
- Grade violations by folder and keep a small budget, so the gate survives daily use.