{"slug": "your-ai-coding-agent-can-leak-one-customer-s-data-to-another-and-every-test-will", "title": "Your AI coding agent can leak one customer's data to another — and every test will still pass", "summary": "A developer with 22 years of software experience built TenantLint, an open-source Roslyn analyzer that detects cross-tenant data leaks in .NET EF Core applications at build time. The tool flags patterns such as IgnoreQueryFilters() and raw SQL that silently strip tenant isolation, emitting 27 diagnostics with inline fixes designed to be read both by developers in the IDE and by AI coding agents scanning dotnet build output.", "body_md": "*I built TenantLint, an open-source Roslyn analyzer that catches cross-tenant data leaks in .NET at build time: in your IDE, in CI, and inside your AI agent's build loop.*\n\nThe most dangerous bug in a multi-tenant SaaS doesn't crash anything. There's no exception, no failing test, no red dashboard. A customer just opens a page and sees another customer's invoices.\n\nI've spent 22 years writing software and the last decade as CTO of a B2B SaaS product. We are moving parts of our platform to a shared PostgreSQL database, where every tenant's rows live in the same tables. That move made me look hard at one question: in a codebase where AI agents now write a large share of the code, what stops a single missing filter from reaching production?\n\nThe honest answer was: code review, and luck. So I built a tool for it.\n\nMost EF Core multi-tenant apps isolate tenants with a global query filter. You write it once, and every query on that entity gets `WHERE TenantId = @current` automatically:\n\n``` js\nmodelBuilder.Entity<Customer>()\n    .HasQueryFilter(c => c.TenantId == _tenantId);\n```\n\nIt's a great pattern, until someone needs to see soft-deleted records:\n\n``` js\nvar customers = await db.Customers\n    .IgnoreQueryFilters()   // removes soft-delete... and the tenant filter\n    .ToListAsync();\n```\n\n`IgnoreQueryFilters()` removes *every* filter on the entity, not just the one you were thinking about. This line compiles, passes code review, and passes your tests, because your tests almost never run with two tenants in the same database. In production, it returns every customer of every tenant.\n\nThere are a dozen variations of this bug. Raw SQL skips global filters entirely. A filter written slightly wrong gets baked into EF Core's cached model. A tenant id read from a request header lets the client choose whose data to see. Each one looks like perfectly normal code.\n\nAI agents like Claude Code, Codex, Cursor and Copilot are genuinely good at writing EF Core code. That's exactly the problem: the code they write *looks* right.\n\nAn agent asked to \"also show archived customers\" will very reasonably reach for `IgnoreQueryFilters()`. An agent asked to speed up a report will write raw SQL. An agent that keeps hitting a warning it doesn't understand may simply suppress it. None of this is malicious; it's the shortest path to a green build.\n\nBut agents have one habit that works in our favor: **they run `dotnet build` constantly, and they read the output.** A warning that says what leaks, why, and exactly how to fix it is something an agent can act on in its next step, without any extra setup, plugin or MCP server.\n\nThat became the design principle for TenantLint: put the check where the agent already looks.\n\nTenantLint is a set of Roslyn analyzers. You add one NuGet package, with no framework migration, no base classes and no configuration:\n\n```\ndotnet add package TenantLint.Analyzers\n```\n\nIt reads your `DbContext`, works out which entities are tenant-scoped and how they are filtered (plain EF Core `HasQueryFilter`, Finbuckle.MultiTenant or ABP), and then checks every build. For the snippet above, the build output says:\n\n```\nwarning TL001: 'Customer' query removes the tenant filter\nvia IgnoreQueryFilters(). Use IgnoreQueryFilters([\"SoftDelete\"])\nto lift only the filter you need, or mark the method\n[CrossTenant(\"reason\")].\n```\n\nNotice the `\"SoftDelete\"`. TenantLint found the name of your other filter and suggests EF Core 10's selective overload, which keeps tenant isolation in place. In Rider or Visual Studio it's a one-click code fix:\n\n``` js\nvar customers = await db.Customers\n    .IgnoreQueryFilters([\"SoftDelete\"])   // tenant filter stays on\n    .ToListAsync();\n```\n\nThe message is written for two readers: a developer skimming the IDE, and an AI agent reading build output. Both get the what, the why and the fix in one line.\n\nTenantLint ships with 27 diagnostics. These four surprised me the most while building it, and each was confirmed with a two-tenant runtime test before I wrote the rule.\n\n```\npublic static class TenantContext\n{\n    public static Guid Current { get; set; }\n}\n\nmodelBuilder.Entity<Order>()\n    .HasQueryFilter(o => o.TenantId == TenantContext.Current);\n```\n\nThis looks fine and even works in a quick test. But EF Core builds the model once and caches it. Values that don't come from the `DbContext` instance are evaluated when the model is built and then reused. In my test, the first tenant's id was baked into the cached model, and every later request saw that tenant's orders. The safe version reads the value through the context instance (a field, a property, or a primary-constructor parameter).\n\n`AddDbContextPool` reuses context instances across requests, and the constructor only runs once per pooled instance. If the tenant id is captured in the constructor, the next request can inherit the previous tenant. TenantLint flags this at the registration site and points to the safe pattern: set the tenant after the context is taken from the pool.\n\n``` js\napp.MapPost(\"/customers\", (Customer c, AppDb db) => { db.Add(c); /* ... */ });\n```\n\nQuery filters protect reads, not writes. If the client sends `\"tenantId\": \"someone-else\"` in the JSON, this writes into another tenant. TenantLint asks for a DTO, with the tenant set from the authenticated user.\n\n``` js\nvar tenantId = Request.Headers[\"X-Tenant-Id\"];\n```\n\nHeaders, query strings and form fields are user input. Tenant identity should come from the authenticated principal. Route parameters like `/orgs/{orgId}` are a legitimate pattern when they're authorized against the user's claims, so TenantLint reports those as Info (TL104) rather than Warning.\n\nThe full list covers raw SQL in EF Core, Dapper and ADO.NET, cache keys without a tenant, static tenant state, background jobs, PostgreSQL row-level security session variables, and ABP and Finbuckle specific bypasses.\n\nThere's an obvious way around any analyzer: suppress it. For an agent trying to reach a green build, this is often the shortest path:\n\n``` js\n#pragma warning disable TL001\nvar all = db.Customers.IgnoreQueryFilters().ToList();\n```\n\nSo TenantLint watches for that too. TL201 flags `#pragma warning disable` for a TenantLint rule without a justification comment. TL202 does the same for `[SuppressMessage]`, and TL203 reports when TenantLint rules are switched off project-wide through `NoWarn` or `.editorconfig`. Placeholder justifications like \"TODO\" or \"n/a\" count as missing.\n\nSome code really does need to cross tenants: admin reports, maintenance jobs, lookups before authentication. For those, TenantLint recognizes a `[CrossTenant(\"reason\")]` attribute, which you define in your own codebase in three lines (TenantLint adds no runtime dependency). The reason is required, so every exception leaves a reviewable trail in the code.\n\nI also recommend one line in your agent instructions file (`CLAUDE.md`, `AGENTS.md` or `copilot-instructions.md`):\n\nTreat TLxxx diagnostics as errors; never suppress them without a `[CrossTenant(reason)]` attribute.\n\nA security analyzer lives or dies by its false positives. If the first build shows twenty warnings and half are wrong, people uninstall it the same day. So before the stable release, I ran TenantLint against real open-source multi-tenant .NET projects.\n\nThe first round was humbling: **56% of the findings were false positives.** Every one of them came from frameworks that apply tenant filters where static analysis can't see them. Finbuckle adds a shadow `TenantId` and its filter at runtime, and ABP sets up filters inside its own base `DbContext`. Teaching TenantLint to recognize both brought Warning-level false positives on those projects down to zero, and it also made it *catch* bugs it had been blind to.\n\nThen I tested on projects it had never seen. That surfaced one new pattern, filters registered through reflection, which TenantLint now reports as \"can't verify\" (Info) instead of a Warning. A real-world test of my own also caught a false positive on C# primary-constructor parameters, fixed before 0.1.0.\n\nAlong the way it found genuine gaps in real code: unfiltered `IgnoreQueryFilters()` calls, and tenant-owned entities with no model-level filter that relied on every query remembering a manual `WHERE`.\n\nTo be clear about the limits: these were six projects, and some of them are small. That's a sanity check, not a benchmark. The full write-up, including what was missed, is in the repository's `docs/validation` folder. If you see a false positive, there's an issue template for it, and I'll treat it as a bug.\n\nTenantLint does not guarantee tenant isolation. It's static analysis: it reads code, not runtime behavior, and it reports suspicious patterns, not proven vulnerabilities. It's built for shared-database (row-level) multi-tenancy on EF Core 8+ and .NET 8+. Database-per-tenant and schema-per-tenant setups, EF6 and .NET Framework are out of scope.\n\nThink of it as one layer. Pair it with database-level protection such as PostgreSQL row-level security, and with tests that run real requests as two different tenants.\n\n```\ndotnet add package TenantLint.Analyzers\n```\n\nIt's MIT-licensed, has zero runtime dependencies, and works in Rider, Visual Studio, VS Code, CI, and any AI agent that runs `dotnet build`.\n\nIf you run a multi-tenant .NET app, I'd love to hear what it finds, and especially what it gets wrong. False-positive reports, new leak patterns and contributions are all welcome; several planned ideas are open as issues for first-time contributors.\n\nAI agents are going to write more and more of our code. Making the build itself tell them when they've crossed a tenant boundary seems like one of the cheapest safety nets we can add.", "url": "https://wpnews.pro/news/your-ai-coding-agent-can-leak-one-customer-s-data-to-another-and-every-test-will", "canonical_source": "https://dev.to/ikilic/your-ai-coding-agent-can-leak-one-customers-data-to-another-and-every-test-will-still-pass-2eag", "published_at": "2026-10-24 10:35:11+00:00", "updated_at": "2026-10-24 10:43:24.026650+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "ai-tools", "ai-safety"], "entities": ["TenantLint", "Roslyn", "EF Core", ".NET", "Finbuckle.MultiTenant", "ABP", "Claude Code", "Cursor"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/your-ai-coding-agent-can-leak-one-customer-s-data-to-another-and-every-test-will", "markdown": "https://wpnews.pro/news/your-ai-coding-agent-can-leak-one-customer-s-data-to-another-and-every-test-will.md", "text": "https://wpnews.pro/news/your-ai-coding-agent-can-leak-one-customer-s-data-to-another-and-every-test-will.txt", "jsonld": "https://wpnews.pro/news/your-ai-coding-agent-can-leak-one-customer-s-data-to-another-and-every-test-will.jsonld"}}