Outcomes, Not Headcount: The Context Layer Behind Controlled Security Automation

Attackers have already figured out that capability no longer scales with headcount. The tooling to run the kind of simultaneous, multi-target campaign that used to take a whole crew is now essentially free: GLM-5.2 — a freely downloadable, MIT-licensed open-weight model anyone can run, released in mid-2026 with a million-token context window — scored 39% F1 on finding IDOR vulnerabilities, beating flagship closed coding-agent scores, at roughly seventeen cents per vulnerability found. Offense has been decoupled from the size of the team.

Defenders have to make the same move, and most of the pieces are finally here: an AppSec program can now run find-triage-manage-fix end to end with AI instead of hiring against the backlog. But there's a catch that offense doesn't have to worry about and defense does. Turning an autonomous agent loose on a production estate is only safe if what it does is controlled and measurable — not a black box that quietly decides a critical bug is a non-issue, or patches the wrong threat model, because nobody ever told it what the environment actually is. An AI security agent that sees only the offending line of code is guessing. It can tell you a query concatenates untrusted input, but it can't tell you whether the service running that query is internet-facing, whether it sits behind an authenticating gateway, whether it touches regulated data, or whether the artifact it builds is even deployed. Those facts decide whether a finding is a drop-everything incident or a test-only curiosity, and none of them live in the code.

So we built a layer whose entire job is to know them. Internally we call it the context system: a context service (codename tactical-holomap) that runs LLM extraction agents over customer repos, builds deterministic infrastructure graphs from cloud accounts, and infers what each piece of the estate is for — then fuses all three into one queryable structure every triage and autofix agent reads from. It is what lets us hand decisions to agents without handing them a blindfold. Decoupling security outcomes from headcount only works if the AI making those decisions is standing on ground truth you can inspect.

Three sources, one graph

The context system fuses three fundamentally different kinds of knowledge into a single typed structure we call the asset graph:

  • Code context. A repo_context Python agent walks source repositories and extracts entrypoints, call graphs, business functions, and API schemas. This is the shape of the software: what calls what, which routes exist, where the boundaries are.
  • Cloud context. A Go GraphBuilder transforms AWS, Azure, and GCP resource data into deterministic infra graph nodes and edges — VPCs, hosts, Kubernetes services, IAM, load balancers, storage, databases. This is where the code actually runs and who can reach it.
  • Business context. A separate app_context LLM agent groups entrypoints into logical applications and infers infrastructure roles — AUTH, GATEWAY, DATABASE, CACHE, QUEUE, and so on. This is what the code is for.

Code and cloud alone already unlock a lot. The graph is built to support attack-path chains that connect an internet-facing entrypoint all the way back to the repository that built the running artifact:

`` Internet -CAN_REACH→ Load Balancer -ROUTES_TO→ Target Group -FORWARDS_TO→ ECS Service -USES_TASK_DEFINITION→ Task Definition -RUNS_IMAGE→ buildArtifact -STORED_IN→ buildArtifactRepository ``

Read that chain backwards and a SAST finding in a repo suddenly knows something it could never know from source alone: whether the image it compiles into is deployed, and whether anything on the public internet can reach it. Without the chain, a finding is an island — what we internally call the "edgeless graph," where every vulnerability is a disconnected node and an agent has to guess severity purely from the shape of the code.

Stitching code to cloud without polluting the graph

The interesting engineering is in the stitching. Code nodes and cloud nodes come from different worlds with different identifiers, so we discover the relationships between them with a set of typed matchers, each emitting edges with an explicit numeric confidence. Five matchers compose into the stitching pass — DEPLOYED_FROM, RUNS_IMAGE, BUILT_BY_PIPELINE, STORES_IN, OWNED_BY_TEAM — and each one returns every candidate match with a raw confidence score. A central syncer then filters against a default threshold before anything is persisted. Confidence calibration lives in the matcher; threshold tuning lives in one place.

The confidence itself is graded, not binary. Take RUNS_IMAGE, which links a running container back to the build artifact it came from. Its strongest tier is a reference match — the running image and the built image share the same fully-qualified image reference and tag, which is nearly always the same workload. Below that sits a registry match, where the registry and repository line up but the tags differ. Below that, a name-only match against a repository name with no registry to anchor it — the weakest rung, and one the threshold is deliberately set to treat with suspicion. DEPLOYED_FROM grades the same way, from an exact URI-and-tag match at the top down to a bare image-name match against a different registry at the bottom. This is not a black box that declares two things related; it's a graded, inspectable judgment with a documented reason string on every edge.

That grading matters because getting it wrong doesn't just produce a bad edge — it poisons every downstream agent that reads the graph. Which brings us to the example.

Before and after: the STORES_IN false positive

The STORES_IN matcher connects a compute node to the storage or database it writes to. One of its rungs, env_var_ref, fires when a container's environment variable points at a managed resource — the reasoning being that a service with DATABASE_URL=db in its environment almost certainly stores data in the resource named db.

An earlier version of this rung matched environment variables against resource names with a bare substring check. That was wrong, and wrong in a way that quietly corrupted the graph:

  • Before. A compute node named logs-processor with an environment variable LOGS_ENDPOINT would match any managed resource whose name contained the substring logs — and get a high-confidence STORES_IN edge to it. A triage agent reading that edge would conclude, with high confidence, that the log processor persists data into that resource. Every service whose config happened to mention a common substring sprayed false edges across the graph.
  • After. We replaced the substring check with a token-boundary match: the resource name has to appear as a delimiter-bounded token — split on _, -, ., /, :. Now DATABASE_URL=db and MY_DB_HOST=db match a resource named db, but BIGDB_URL and logs_endpoint do not. The edge — and everything an agent infers from it — is trustworthy.

We hit the same class of bug in the BUILT_BY_PIPELINE matcher, where an early strings.Contains check against short pipeline names like ci, build, and deploy produced a firehose of false edges — nearly every artifact whose metadata mentioned those substrings. The fix was strict equality plus a minimum name length. The lesson landed twice: a lazy match doesn't fail loudly, it fails as a plausible-looking wrong answer that a downstream agent will act on. Context that's wrong is worse than context that's absent, which is why the confidence ladders and token-boundary checks exist in the first place.

The roles are inferred, and we treat them that way

The business-context layer — the part that decides an application is an AUTH service or a GATEWAY or a DATABASE — is where we have to be most honest. This is not a deterministic classification. It is an LLM judgment call.

The app_context agent forces the model to return structured output through a single submit_grouping tool call; if it declines to call the tool, the step fails outright rather than falling back to a guess. The judgment itself is steered by a genuinely detailed system prompt with rules like:

  • Group by business domain, not technical function. "A recommendation engine for videos is part of the streaming platform, NOT a separate app."
  • Identify shared infrastructure. Services described as "centralized," "used by ALL applications," or "organization-wide" are infrastructure; business applications list their infrastructure dependencies rather than absorbing them.
  • Split monorepos by purpose. Entrypoints in the same repository serving different business purposes are separate applications.
  • Don't overreach on compliance. "Do NOT infer or assume compliance status of applications — only use compliance info to understand the organization's domain."

That last rule is a prompt-level guardrail against the model inventing facts it doesn't have — the same discipline we apply to the matchers, applied to the LLM. The prompt is also fed real organizational grounding: industries, products and their types, and an onboarding questionnaire covering data types like PII, PHI, and payments. These are structured facts feeding LLM synthesis, not a model left to improvise.

But structured inputs don't make the output deterministic. Role labels are high-confidence inference, not ground truth, and we know the failure modes firsthand — the same application can come back labeled "Auth Service" on one repo-context run and "Authentication" on the next, so we design on the assumption that a label isn't stable across LLM calls and never treat it as a durable key. So we don't pretend otherwise. Two things keep the inference grounded:

  • Deterministic heuristics do real work alongside the LLM. There's a non-LLM grouping path that assigns entrypoints to applications using token-overlap scoring and explicit affinity hints, so the system isn't purely at the mercy of a model's judgment.
  • Humans can pin the answer. Org admins can pre-declare application "seeds" that the grouping is required to honor. The system defaults to inferring the whole map autonomously, but a security engineer who knows their estate can constrain it — a lever to pull, not a queue to babysit.

This is the honest version of the pitch. We are not claiming the graph knows, with certainty, that a given service authenticates users. We are claiming it makes a well-grounded, inspectable inference — and that a well-grounded inference beats the alternative, which is a triage agent assuming no auth exists at all. That assumption is exactly the trap we built this to escape: agents that guess wrong about authentication because nobody told them what the production environment actually does.

Confidence you can see, and limits the system admits

A context layer that oversells its own certainty is just a more expensive way to be wrong, so measurability is built in at every step.

The reachability engine is a good example. It answers "can the internet reach this?" with a bounded breadth-first search from a synthetic internet node across an allowlist of traversable edge types, capped at a depth of 12 and 5,000 nodes. When it hits those caps it doesn't confidently report "not reachable" — it sets a Truncated flag so the caller can surface a "graph too large to determine" signal instead. At scale this matters: a Fortune-500 graph at depth six can reach roughly 50,000 nodes. The system tells you when it couldn't finish looking rather than pretending the absence of a path is proof of safety.

The OWNED_BY_TEAM matcher is honest in the same spirit. Its design notes describe several ownership-inference paths that early drafts promised — a code-ownership glob match, a team-lead match — and then flatly state that those code paths don't exist yet and are documented as TODOs "to avoid shipping aspirational docstrings." Only the one implemented rung, a repo-team association, actually runs. A context system that documents its own coverage gaps is one you can audit; every edge carries a method and a confidence, and every inference traces back to why it was made.

That auditability extends to the human surface. We fold every node into a normalized, trigram-indexed search string so an operator can search and browse the fused graph directly, not just have agents traverse it. The knowledge the agents read from is the same knowledge a security engineer can inspect by hand.

Doing this without burning tokens

Feeding rich context to agents is only worth it if the context itself is cheap to maintain and cheap to query. The whole game is the best outcome for the least human work and the least token spend, and the context system is engineered around that constraint.

  • A skip gate stops needless re-extraction. Repo-context extraction is LLM-driven and therefore expensive, so a conservative gate decides whether a new commit even needs a re-run. Identical commit SHA: skip. No changed files: skip. Only documentation, license, or editor-config files touched: skip. Anything genuinely ambiguous, or any error comparing commits: run anyway. The gate only skips when it's certain, so a docs-only commit never triggers a full re-analysis — pure savings with a safe default.
  • The triage knowledge layer nudges agents toward cheap tools. During triage, agents query a customer-knowledge RAG store through five tools. The broad cross-category search explicitly tells the model it's the expensive option and to reach for the narrow, cheaper policy/runbook/compliance tools by default. Results are cached with a key of tenant, normalized query, and result count, because a triage batch tends to ask the same handful of questions — "what's our data classification policy?", "what's the on-call escalation matrix?" — across dozens of findings. One cached answer serves fifty-plus findings instead of fifty-plus LLM round-trips.
  • Shared setup, bounded context. The RAG tools share a single connection pool and helper model per request rather than reconstructing them per tool, and answers are synthesized with mandatory inline citations back to the source document — a token-bounded, cost-controlled path that also happens to be fully traceable to where each claim came from.

The layer everything else stands on

Nullify's product is a loop: find vulnerabilities, triage them correctly, manage them to closure, and fix them. Every stage of that loop makes decisions, and every one of those decisions is only as good as what the deciding agent knows. Triage that doesn't know a service is internet-facing over-escalates or under-escalates. Autofix that doesn't know the code runs behind an authenticating gateway writes a patch for the wrong threat model. The context system is the cross-cutting enabler under all of it, which is what makes the automation safe to run at the scale the threat now demands.

We're deliberately not claiming omniscience. Role inference is inference. Confidence scores are estimates. Reachability searches can time out, and the system says so when they do. What we are claiming is that an agent reasoning over a fused, graded, auditable graph of code, cloud, and business context makes far better decisions than one staring at a snippet — and that the difference between those two agents is not a smarter model but the ground they stand on. That is what lets a security program grow its output without growing its headcount, while attackers are busy doing exactly the same thing on the other side.

Book a demo