Mustafa.
Back to all projects

Agent Blast Radius — the data an Agentforce agent can really reach

A static, zero-credit analyzer that computes an agent's true data-access surface at the execution-semantics layer — apiVersion defaults, USER_MODE clauses, sharing declarations, Flow run modes — diffs it against the running user's own permissions, and intersects the gap with the org's GDPR labels. On an Agent Script agent it goes one step further, tracing a sensitive field from the database column into the model's prompt, at a line number. The headline number: the Escalation Gap.

Year
2026
Role
Salesforce Developer — Agent Security Research
Tech stack
Agentforce (authority analysis)Agent Script (.agent) — Salesforce's own parserReal Apex AST (ANTLR apex-parser)Source→sink taint (Authority Path)USER_MODE / apiVersion precedenceFlow runInMode analysisPermission & FLS resolutionGDPR / ComplianceGroup intersectionRuntime oracle — a live org is the judgeSFGE differential (org-refereed)CI gate (fail-on ERROR)307 unit tests

Highlights

  • The question nobody's tooling answers: not what an agent is allowed to do (config-layer AISPM — AppOmni, Zenity, Security Center all audit that), but what its code can actually reach — because on Salesforce, execution mode, not the running user, decides
  • The headline number is the Escalation Gap: the fields the agent's code can reach beyond its own running user — intersected with the org's GDPR/PII ComplianceGroup labels
  • Beyond reachability, it proves the path: on an Agent Script agent, PS522 traces a GDPR field from the SOQL column, through the Apex @InvocableVariable output, into the prompt template — reported with a line number (not 'can reach the model', but 'enters the prompt, at line 125'). That chain is provably absent from the compiled metadata, so a metadata-based scanner cannot produce it at all
  • The precedence law (explicit clause → apiVersion default → sharing declaration) was proven by hand in a live org before it was coded: six experiments, three widely-assumed shortcuts shown wrong — a version-blind scanner would cry wolf on modern code
  • Apex reach is read on a real parse tree (ANTLR apex-parser), with the regex extractor kept as an honest fallback; the report header says which backend ran
  • Runs against a live agent authored in Agent Script — compiled by Salesforce's own validator (sf agent validate) and published to the org (sf agent publish) — catching PS501 (system-mode read on a Private object) and PS506 (a GDPR-labelled Diagnosis field reaching the model past the user's FLS), at zero Flex Credits
  • Honest unknowns are findings, not silence: dynamic SOQL and opaque managed actions are flagged (PS504/PS507), never guessed clean; record counts are 'n/a', never estimated, when record visibility is sharing-dependent
  • Wiring in Salesforce's own Agent Script SDK surfaced a packaging bug that breaks three published npm packages — reported with a reproduction and root cause (issue #71) and fixed with a post-publish smoke test (PR #72, CLA signed, checks green, awaiting review)
  • The platform, not the author, is the ground truth: a runtime oracle deploys each benchmark case into a real org as a throwaway modelled user and asserts the analyzer's own prediction — a red test means the org says the analyzer is wrong — and a differential against Salesforce's own Graph Engine is refereed by that same org. A deliberate negative control (grant FLS on one field, read it under the same USER_MODE that blocks another) keeps every green from being vacuous
  • 307 unit tests, all green; a CI gate fails the build on ERROR and comments the Escalation Gap on the pull request

Feature walkthrough

It runs against a real org — zero credits, real parse tree

The console, not a mock-up: a real TechnoStore Developer-Edition org queried with the sf CLI, then blast_radius.cli against the live TechnoStore Revenue Assistant. The Apex is read on a real parse tree (AST, with a regex fallback ready); nothing is executed and no data leaves the org. Escalation Gap: 6 fields, 1 GDPR-labelled — ERROR 8 · WARN 4 · INFO 2 — deterministic Markdown + HTML written. Zero Flex Credits.

PowerShell: blast_radius.cli run against a live TechnoStore Developer-Edition org — AST backend, Escalation Gap 6 fields (1 GDPR), ERROR 8 / WARN 4 / INFO 2, deterministic md + html written, zero Flex Credits

The headline — the two-circle Escalation Gap

The image that makes an architect stop scrolling. The agent's code reaches 8 fields; its running user can see 2; the red ring is the gap — 6 fields the code reaches beyond the user, 1 of them GDPR-labelled. Classification coverage is 100%, so that personal-data result is a real measurement, not a blind spot. Both actions run in system mode.

Two-circle hero: the agent's code reaches 8 fields, the running user sees 2, the red ring is a 6-field escalation gap (1 GDPR-labelled), 100% classification coverage, 2/2 actions system-mode

The proof — findings with the Authority Path confirmed

Eight ERROR findings, each with rule, why and fix. The headline PS506: the GDPR/PII field Stripe_Payment_Status__c is read in system mode and the running user has no FLS on it — and 'Authority Path CONFIRMED: the field's value flows to the action's @InvocableVariable output, so it reaches the model.' Not 'can reach' — proven to reach, traced on the parse tree. The fix is concrete: WITH USER_MODE or Security.stripInaccessible.

ERROR findings: PS506 on Stripe_Payment_Status__c with 'Authority Path CONFIRMED — the value flows to the @InvocableVariable output, so it reaches the model', plus PS502 field escalations, each with a concrete fix

Honest unknowns — a boundary, not a proven leak

The WARN section is where the tool refuses to cry wolf. A pre-v67 trigger fires on a write (PS509) — flagged as a boundary to review, because no escalating write was proven. A dynamic reach can't be fully determined (PS504) — a silent false-clean is worse than an honest unknown. A platform-event publish (PS514) — the true blast radius can be larger than this report, an honest unknown edge, not a proven leak. A security tool that hides its blind spots is worse than none.

WARN findings worded as honest unknowns: a pre-v67 trigger boundary (PS509) where no escalating write was proven, an undetermined dynamic reach (PS504), a platform-event publish (PS514) — 'an honest unknown edge, not a proven leak'

Beyond the agent — and a fingerprint that seals the tool itself

The whole-org context: 113 of 113 of the org's own Apex is pre-v67 (system-mode by default), 2 permission sets grant Modify All Data — a map of where the next agent will escalate before it's even built. And the footer closes the loop: no agent was invoked, 0 Flex Credits, bound to a fingerprint that seals both the inputs AND the tool that produced the verdict — the analyzer, the parser version, and each class's own apiVersion. A verdict is only reproducible against the exact tool that made it.

Org-health section: 113/113 of the org's own Apex is pre-v67, 2 permission sets grant Modify All Data; footer states the fingerprint seals both the inputs AND the tool (analyzer, parser version, per-action apiVersion), at zero Flex Credits

The problem

Salesforce says give agents least privilege, and a whole product category (AISPM) audits agent permissions — at the configuration layer, what the agent is allowed. None of it reads what the agent's code can actually do. On Salesforce the two genuinely differ, because execution mode — not the running user — decides whether an action honors that user: an action's Apex may predate API v67 and still run system-mode by default; a WITH USER_MODE clause overrides a without-sharing declaration; a Flow's runInMode can silently grant system context; a clean user-mode DML can fire a legacy trigger that escalates anyway. And a naive scanner that flags 'without sharing' without understanding this precedence produces false positives that destroy its own credibility.

The approach

Prove the physics before building the instrument. Six hand-run experiments in a live org (zero credits, self-contained fixtures) established the precedence law — explicit clause beats apiVersion default beats sharing declaration — and disproved three common assumptions along the way: a missing sharing declaration is not 'without sharing'; v67's user-mode default overrides even an explicit without-sharing for plain operations; and a trigger's DML mode follows the trigger's own API version, not the action's. Only then the pipeline: an Apex introspector resolving execution mode per operation, a Flow introspector reading runInMode declaratively, a permission resolver computing the running user's effective CRUD/FLS (including the View-All-Records-but-not-FLS nuance), and an authority analyzer that joins them into PS5xx findings — with the org's own ComplianceGroup labels marking which escaped fields are GDPR/PII. The Apex is read on a real parse tree (ANTLR apex-parser), and an intra-method source→sink taint proves whether a queried field actually flows to the action's @InvocableVariable output rather than merely being reachable; on an Agent Script agent, read with Salesforce's own parser, that proof extends one hop further, into the prompt template. Reports are deterministic and fingerprint-bound; undetermined reach is reported as an honest unknown, never a silent pass, and the tool degrades honestly to a regex backend when Node is absent. 307 unit tests cover the chain.

The outcome

Run against a live agent authored in Salesforce's open-source Agent Script — compiled by Salesforce's own validator and published to the org with sf agent publish — the report needs one line to justify the whole tool: Escalation Gap — 1 field, 1 GDPR-labelled. A pre-v67 action class reads a Private object in system mode (PS501); the Diagnosis field — ComplianceGroup PII;GDPR;HIPAA — reaches the model although the running user has no field-level access to it (PS506); and the value is traced into the model's prompt at line 125 (PS522) — a proof provably absent from the compiled metadata, so a metadata-based scanner cannot produce it at all. Standard managed actions are surfaced as honestly non-analysable rather than assumed safe. All of it static, on every commit, at zero Flex Credits, with a CI gate that fails the build on ERROR. Along the way, wiring in Salesforce's own Agent Script SDK surfaced a packaging bug that breaks three published npm packages, reported with a reproduction and root cause (issue #71) and a fix PR that is open upstream (PR #72 — CLA signed, checks green, awaiting maintainer review). Honestly framed: this is design-time bounding of the possible blast radius — it complements runtime monitoring rather than replacing it; the taint is intra-method, the permission model is a grant snapshot, and inter-procedural flow and deeper call-graph following are documented as the next steps, not claimed.

Watch the demoGitHub & Notion — private, walkthrough on request