●OPUS — Claude Opus 4.7 is generally available, improving software engineering, long-running coding, and higher-resolution vision●APIKEY — You can now set an expiration on API keys in the Console, with email reminders before keys valid for 7+ days expire●REFLECT — A monthly recap at Settings > Reflect shows your top topics, most active day, and peak hour (beta)●M365 — The Microsoft 365 connector now supports write tools for email, calendar, and OneDrive/SharePoint files●COWORK — Cowork expands to web and mobile, bringing Chat and Cowork into one shared home across devices●DESIGN — Claude Design, a new Anthropic Labs product, lets you co-create designs, prototypes, slides, and one-pagers●OPUS — Claude Opus 4.7 is generally available, improving software engineering, long-running coding, and higher-resolution vision●APIKEY — You can now set an expiration on API keys in the Console, with email reminders before keys valid for 7+ days expire●REFLECT — A monthly recap at Settings > Reflect shows your top topics, most active day, and peak hour (beta)●M365 — The Microsoft 365 connector now supports write tools for email, calendar, and OneDrive/SharePoint files●COWORK — Cowork expands to web and mobile, bringing Chat and Cowork into one shared home across devices●DESIGN — Claude Design, a new Anthropic Labs product, lets you co-create designs, prototypes, slides, and one-pagers
The Night a Connector Grew Write Tools, Your Unattended Job Could Quietly Send Something
The moment a connector update quietly adds write tools, a job you run unattended can suddenly send email or delete files. Here is how to build a gate that snapshots a connector's tool surface, diffs it, and stops unapproved writes before they run — drawn from indie development.
I was reviewing the logs of a nightly job the next morning, as usual. The run itself had succeeded. But the tool list of the connector I use held a few names that were not there the week before: create_message, send_message, delete_file. The upstream service had updated, and a connector that used to only read could now compose and send email, and manipulate files.
That progress is welcome in itself. And yet a small chill ran down my back. My nightly job picks the tools a connector offers on its own, according to the situation. Until yesterday, the worst outcome of a bad decision was a misread. From tonight, the same misjudgment could turn into a send, or a delete. The danger was not born the moment my code changed. It was born the moment the connector's tool surface quietly widened.
In July 2026 the Microsoft 365 connector gained write tools — composing, sending, and organizing email, calendar operations, and creating or updating files on OneDrive and SharePoint. A human sitting beside the machine can pause a breath before the Send button. In unattended operation, unless you build that breath into the design yourself, no one stops you. Drawing on running four sites unattended as an indie developer, we build, as working code, a gate that detects when tools have grown and dams up unapproved writes before they execute.
The danger is born when a new tool appears
When we think about the security of an unattended job, we tend to look at our own prompt and permission settings. But the range of operations an agent can actually call is, in the end, decided by the tool list the connector exposes. That was my blind spot. Without changing a single character of my code or my permission scope, the upstream service bumping a version can widen the set of possible operations.
With a read-only connector, the worst failure stops at "read the wrong thing." Add one write tool and the nature of failure changes. A misread can be rolled back; a sent email or a deleted file does not come back easily. So what deserves watching is not "what tools exist now" but the diff: "what grew since last week."
I settled on a policy. Detect the change in tools every time — above all, the appearance of a new write or destructive tool. Do not let an unapproved one proceed to execution. Approval exists only when I explicitly write it into a policy file. This direction — a newly grown write tool is denied by default — sits at the very outer edge of the unattended job.
Sort tools into read / write / destructive automatically
The first part is a classifier that sorts the tool list by risk. MCP tools carry a name and a description. Perfect semantic analysis is not needed. Sorting into three tiers from the verb in the name is enough in practice. Anything ambiguous is pushed to the safe side and collected as "needs review."
# classify_tools.pyimport refrom enum import IntEnumclass Risk(IntEnum): READ = 0 # reference only; nothing to roll back WRITE = 1 # changes state; usually reversible DESTRUCTIVE = 2 # send / delete / payment; hard to reverse# verbs treated as destructive (a send, a delete)DESTRUCTIVE = r"(send|delete|remove|purge|archive|pay|charge|transfer|deploy|publish|revoke)"# common write verbsWRITE = r"(create|update|write|edit|insert|upsert|move|rename|set|add|append|patch|put|post)"# read verbsREAD = r"(get|list|search|read|fetch|find|query|describe|lookup|view)"def classify(tool_name: str) -> Risk: name = tool_name.lower() verb = re.split(r"[_\-.]", name)[0] # treat the leading token as the verb if re.fullmatch(DESTRUCTIVE, verb): return Risk.DESTRUCTIVE if re.fullmatch(WRITE, verb): return Risk.WRITE if re.fullmatch(READ, verb): return Risk.READ # if the verb is unknown, scan the whole name for a destructive word if re.search(DESTRUCTIVE, name): return Risk.DESTRUCTIVE if re.search(WRITE, name): return Risk.WRITE # still unknown: do not assume READ; pick it up as WRITE return Risk.WRITE
I include pay and charge among the destructive verbs because I have payment connectors in mind. If a billing connector like Stripe starts offering create_charge or refund, an unattended job could actually move money — exactly the kind of irreversible operation I want stopped by default to avoid an accident.
The line I cared about is the last one. Treating an unknown verb optimistically as READ would turn the classifier itself into a hole. Leaning "unknown means write or worse" pushes the errors onto the side of over-detection. Over-detection I can clear with a single approval; a destructive tool I miss goes unnoticed until morning. You decide, in advance, which error you are willing to carry.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A way to snapshot the tools a connector exposes and, by diffing against last week, surface only the write tools that newly appeared
✦A classifier that sorts tool names into read / write / destructive by verb, plus a preflight gate that halts unapproved destructive tools before they run
✦Measured results from a two-stage guard that logs every intent to send or delete into a dry-run ledger for a next-morning review
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Once classification works, the next part mechanically produces "the diff since last week." Fetch the tool list the connector exposes now and save it as a table of name and risk. That is the tool-surface snapshot. Next time, compare against the saved snapshot and surface the tools that grew, the tools that vanished, and the tools whose risk changed.
# surface_diff.pyimport json, hashlibfrom pathlib import Pathfrom classify_tools import classify, RiskSNAP = Path.home() / ".connector_surface" / "m365.json"def current_surface(list_tools) -> dict: """Inject list_tools(): a function returning the connector's tool definitions""" surface = {} for t in list_tools(): name = t["name"] surface[name] = { "risk": int(classify(name)), # we want to catch description changes too, so keep only a hash "desc_hash": hashlib.sha256( t.get("description", "").encode("utf-8") ).hexdigest()[:12], } return surfacedef load_snapshot() -> dict: if SNAP.exists(): return json.loads(SNAP.read_text()) return {}def diff(prev: dict, now: dict) -> dict: added = {k: now[k] for k in now.keys() - prev.keys()} removed = {k: prev[k] for k in prev.keys() - now.keys()} changed = { k: {"from": prev[k], "to": now[k]} for k in prev.keys() & now.keys() if prev[k] != now[k] # risk or description hash moved } return {"added": added, "removed": removed, "changed": changed}def save_snapshot(now: dict) -> None: SNAP.parent.mkdir(parents=True, exist_ok=True) SNAP.write_text(json.dumps(now, ensure_ascii=False, indent=2))
Keeping even a hash of the description has a reason. The tool name can stay the same while the upstream swaps out the behavior underneath. Watching only names, you miss a quiet spec change — an update_file that shifts from "append" to "overwrite." Only when name, risk, and description hash line up together can a change be caught as a change.
Stop unapproved write tools at the gate
With a diff in hand, what remains is judgment. Check whether a newly appeared write or destructive tool is on my approved list. Approval lives only in a ledger a human wrote by hand — here, approved_tools.json. If a tool that is not on it is among the execution targets, stop before the job body begins.
# preflight_gate.pyimport json, sysfrom pathlib import Pathfrom surface_diff import current_surface, load_snapshot, diff, save_snapshotfrom classify_tools import RiskAPPROVED = Path.home() / ".connector_surface" / "approved_tools.json"def approved() -> set: if APPROVED.exists(): return set(json.loads(APPROVED.read_text())) return set()def preflight(list_tools) -> None: now = current_surface(list_tools) d = diff(load_snapshot(), now) ok = approved() # surface write-or-worse tools that are new, or whose risk rose suspects = [] for name, meta in d["added"].items(): if meta["risk"] >= Risk.WRITE: suspects.append((name, meta["risk"])) for name, chg in d["changed"].items(): if chg["to"]["risk"] >= Risk.WRITE and chg["to"]["risk"] > chg["from"]["risk"]: suspects.append((name, chg["to"]["risk"])) unapproved = [(n, r) for (n, r) in suspects if n not in ok] if unapproved: lines = [f" - {n} (risk={Risk(r).name})" for n, r in unapproved] sys.stderr.write( "Unapproved write tools detected. Aborting the run:\n" + "\n".join(lines) + f"\nTo approve, append the name to {APPROVED}.\n" ) sys.exit(3) # fail before entering the job body # only approved tools present: update the snapshot and pass save_snapshot(now)
Before and after this gate, the opening move of the unattended job changed as follows.
Situation
Before the gate
After the gate
Connector newly exposes send_message
the agent may pick it depending on context
stopped before running as unapproved (exit 3)
update_file behavior shifts to overwrite
same name, so no one notices
caught as changed via the description hash
Normal run with approved tools only
—
passes through, snapshot updated
When you notice
the log after an accident
before the job even starts
I set the exit code to 3 to distinguish it from a normal error (1) or a bad argument (2), so that the scheduler above can treat it as "this is a safe-side stop, not a failure." Paint a stop and a failure the same color and your morning judgment wobbles.
Log the intent to write before executing
The gate stops unapproved tools. But even for approved tools, there are cases where, for the first few runs, you truly do not want them to execute. So as a second stage, we slip in a dry-run layer that does not actually perform a write call but writes out "what it intended to send or delete" into a ledger. The next morning you read it, and if it matches your intent, you promote the approval.
# dry_run_ledger.pyimport json, timefrom pathlib import Pathfrom classify_tools import classify, RiskLEDGER = Path.home() / ".connector_surface" / "write_intents.jsonl"DRY_RUN = True # True during the break-in period; flip to False once trusteddef guarded_call(tool_name: str, args: dict, real_call): risk = classify(tool_name) if risk >= Risk.WRITE and DRY_RUN: record = { "ts": time.strftime("%Y-%m-%dT%H:%M:%S"), "tool": tool_name, "risk": Risk(risk).name, "args": args, # keep exactly what it meant to send "executed": False, } with LEDGER.open("a", encoding="utf-8") as f: f.write(json.dumps(record, ensure_ascii=False) + "\n") return {"dry_run": True, "logged": tool_name} # only READ, or a write with dry-run lifted, truly executes return real_call(tool_name, args)
The ledger is JSONL. One intent per line, so the next morning I can lightly grep for just send_message or count entries with jq. The essential point here is to keep args in full even in a dry run. "I stopped a send" alone leaves me unable to judge the next morning. Only when "to whom, with what subject it meant to send" remains can I calmly decide whether it is safe to approve.
Measured: from a quiet accident to a scheduled review
Here are results from running this two-stage guard (gate plus dry-run ledger) across the nightly jobs of four sites for about two weeks. The numbers are from my environment and depend on how often the connectors update, but the shape should come through.
Metric
Before (2 weeks)
After (2 weeks)
Newly detected write tools
0 (not watched at all)
6
of which destructive (send / delete)
—
2
Times stopped before running as unapproved
0
4
Write intents logged to the dry-run ledger
—
31
Unintended sends or deletes executed
couldn't tell
0
Average morning review time (per day)
—
~3 min
More than the numbers, what mattered was that the moment of noticing moved from "after the accident" to "before execution." Before, I wasn't even observing whether dangerous tools had grown. After, I knew in advance that two of the six new write tools (about 33%) were send/delete kinds, and four of those were dammed up before approval. I glance over the 31 dry-run records in about three minutes each morning and promote only the ones matching my intent to approval. It is not a flashy mechanism, but I have come to feel that the calm of unattended operation grows from exactly these plain steps.
Distilling all of this into one principle: reads may pass automatically; writes are watched by diff; destructive operations are, by default, stopped to make room for a human breath. I strongly recommend making this the default direction. The aim of automation is not to shut people out but to gather effort onto the one point where a person should judge. Handling all 31 intents myself every time would be a lot; fixing my eyes on just the two send/delete ones among them, I can keep going.
The snapshot and the policy file become an operational record in their own right. When, which tool, and why I approved it. Keeping the history of approved_tools.json trackable in git lets the me of six months from now trace "why did I permit this destructive tool." Precisely because it runs unattended, leave a trace of judgment. The habit of always reconciling the evidence of execution at the end runs through a design that verifies a scheduled job's quiet success with an end-of-run assertion as well.
The smarter a connector gets, the more quietly its tool surface widens. The growth itself is welcome progress. Still, I think the speed at which you invite that progress into an unattended job is yours to set by hand. I am still adjusting as I operate, but if this becomes a quiet support for someone who likewise entrusts several processes to the night, I would be glad. Thank you for reading.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.