{"slug": "i-built-a-guard-that-refused-to-read-the-user-s-tab-then-my-own-cleanup-code-it", "title": "I built a guard that refused to read the user's tab. Then my own cleanup code closed it.", "summary": "A developer's Safari MCP browser automation tool closed a user's tab despite a guard that correctly refused to read it. The guard, designed to prevent acting on a user's tab, lost tracking due to a cross-origin redirect, but the cleanup code's fallback closed the current tab instead of refusing. The same fail-open pattern existed in all three layers of the stack, where missing ownership was treated as permission rather than refusal.", "body_md": "Three days ago my browser automation tool closed one of my own tabs. Not a tab it had opened — a dashboard I had open in another window, with a page I hadn't finished reading.\n\nWhat makes it worth writing up isn't the bug. It's that the guard designed to prevent exactly this had already fired, correctly, ninety seconds earlier.\n\nSafari MCP lets an AI agent drive your real, logged-in Safari. That premise means the single worst thing it can do is act on a tab you're using. So there's an identity system: every tab the tool opens gets a marker stamped into `window.name`\n\n, which survives navigation, redirects, and cross-origin loads. Before running anything in a tab, the tool checks the marker.\n\nI was filling in a form. The URL was a `forms.gle`\n\nshortlink, which 302s to `docs.google.com`\n\n— a cross-origin redirect that, it turns out, drops `window.name`\n\n. My next read came back refused:\n\nTab tracking lost — refusing to target the user's current tab.\n\nCorrect. Exactly the intended behaviour. The tool no longer knew which tab was its own, so it declined to guess.\n\nSo I did the tidy thing and cleaned up my orphaned tab:\n\n```\nsafari_close_tab\n```\n\nIt closed a different tab. One of mine. The tool went from *\"I can't prove which tab is mine, so I won't read\"* to *\"let me close a tab\"* in one step, and nobody stopped it.\n\nHere is the close path as it existed:\n\n```\nif (_st().activeTabIndex) {\n  await osascript(`... close tab ${_st().activeTabIndex} of ${window}`);\n} else {\n  await osascript(`... close current tab of ${window}`);  // ← the user's tab\n}\n```\n\n`current tab of window`\n\nis whatever the user is looking at. So the fallback for *\"I don't know which tab is mine\"* was *\"close theirs.\"*\n\nThat branch is only reachable when the index is unknown — which is precisely the state the guard had just announced. The two pieces of code were describing the same condition and disagreeing about what it meant.\n\nWhen I went looking, the same fail-open was in all three layers of the stack, and it had the same shape every time: **no ownership recorded was read as permission, not as refusal.**\n\nThe first one is the most embarrassing, because it's a list:\n\n``` js\nconst _noOwnershipCheck = new Set([\n  // Tab management\n  \"new_tab\", \"list_tabs\", \"close_tab\", \"switch_tab\",\n  ...\n```\n\nA set of operations exempt from the ownership check, grouped under the comment \"read-only or tab management\". Three of those four are harmless: `new_tab`\n\ncreates a tab, `list_tabs`\n\nreads, and `switch_tab`\n\ncarries its own ownership check at the tool level. `close_tab`\n\ndestroys a user tab and had no check anywhere.\n\nIt wasn't exempted by argument. It was exempted by *category* — it looked like tab management, it sat next to tab management, so it inherited tab management's safety assumptions. Nobody ever wrote down \"closing a tab is safe.\" The list said it, silently, by adjacency.\n\nThe third layer, the browser extension, had a variant worth naming on its own. Its guard refuses an operation when the session owns tabs and this isn't one of them — but when the session owns *nothing*, it allows the operation, with this comment:\n\n```\n// If no tabs owned yet, allow operation (backward compatibility for sessions that don't use new_tab)\n```\n\nThat leniency is sensible for a write: worst case it edits the page you're already on. For a close it's a disaster. And \"owns nothing\" is exactly what a session reports after its transport drops and the client re-initialises — mid-task, tab still open. The lenient branch is reachable *only* in the state where it's most wrong.\n\nMy first instinct for the fix was to reuse the rule the read path already uses: refuse a tab carrying another session's marker, allow an unmarked one. It keeps \"read the page I'm looking at\" working for a session that genuinely never opened a tab.\n\nThat rule would not have prevented this. The tab I destroyed was a genuine user tab. It had no marker at all. Under \"unmarked is fine\", it sails straight through to `tabs.remove()`\n\n.\n\nWhich forced the actual distinction: **a wrong read costs information; a wrong close costs the user their work.** They don't get the same rule. The read paths keep the lenient fallback on purpose. The destructive path gets no fallback whatsoever — it closes a tab it can positively prove it owns, or it throws:\n\n``` js\nconst idx = explicitIndex || (await _provenOwnTabIndex());\nif (!idx) {\n  throw new Error(\n    `Tab tracking lost — refusing to close a tab this session cannot prove it opened ...`\n  );\n}\n```\n\nNo `else`\n\n. \"Owns nothing\" now means \"closes nothing\", which is the sentence that should have been in that first list all along.\n\nA few adjacent things fell out of it. Blanking a window's last tab — the workaround for \"closing this would quit Safari\" — used the same fallback, and throwing away someone's loaded page is destructive too; it's pinned to the proven index now. And in the extension, the guard validated one tab (`tabId`\n\n) while the indexed branch removed a *different* one (`_winTabs[index - 1]`\n\n), so the check and the action were pointed at different tabs. That one had never fired in production; it was just waiting.\n\nThe guard wasn't wrong and the fallback wasn't wrong. What was wrong is that they were two different answers to one question — *do we know which tab is ours?* — living in two files, and only one of them had thought about what the answer implied.\n\nI've since stopped trusting the word \"safe\" in a category name. `close_tab`\n\nwas in a set called tab management, and the set was right: it *is* tab management. Categories describe what an operation is. Guards have to be about what it costs when it's wrong. Sorting by the first and inheriting the second is how a destructive call ends up on the exempt list with no one having decided that.\n\nThe test I wrote isn't behavioural, for the same reason as last time: the defect is a *missing refusal* on a path that only runs after state is already lost. There's nothing to exercise. It reads the source and asserts that no AppleScript verb in the close path targets the front document — and I checked that it fails when I put the old branch back, because a regression test you haven't seen fail is a guess.\n\nFixed in v2.15.8. The repo is [safari-mcp](https://github.com/achiya-automation/safari-mcp) — the full analysis is in [issue #68](https://github.com/achiya-automation/safari-mcp/issues/68).\n\n**A question for you:** how do you catch the operations that got their safety assumptions by adjacency — the ones sitting in a list they only half belong to? Every review I've done reads the code in the branch, not the membership of the set above it.", "url": "https://wpnews.pro/news/i-built-a-guard-that-refused-to-read-the-user-s-tab-then-my-own-cleanup-code-it", "canonical_source": "https://dev.to/achiya-automation/i-built-a-guard-that-refused-to-read-the-users-tab-then-my-own-cleanup-code-closed-it-3fpe", "published_at": "2026-07-27 06:40:34+00:00", "updated_at": "2026-07-27 07:02:03.418410+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents", "ai-safety"], "entities": ["Safari MCP", "Google Forms", "Google Docs"], "alternates": {"html": "https://wpnews.pro/news/i-built-a-guard-that-refused-to-read-the-user-s-tab-then-my-own-cleanup-code-it", "markdown": "https://wpnews.pro/news/i-built-a-guard-that-refused-to-read-the-user-s-tab-then-my-own-cleanup-code-it.md", "text": "https://wpnews.pro/news/i-built-a-guard-that-refused-to-read-the-user-s-tab-then-my-own-cleanup-code-it.txt", "jsonld": "https://wpnews.pro/news/i-built-a-guard-that-refused-to-read-the-user-s-tab-then-my-own-cleanup-code-it.jsonld"}}