Skip to content

About

Can your Agentforce agent's code read fields its running user cannot? Static, zero-credit analysis of Salesforce execution semantics - apiVersion 67 user-mode default, sharing, Flow runInMode, triggers - reported as the Aksu Index: four numbers, one agent, one user. 16 in-org experiments, 323 tests, 21 cases adjudicated by a live org.

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

128 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Agent Blast Radius

A static, zero-credit analyzer that computes the real data-access surface of a Salesforce Agentforce agent — at the execution-semantics layer — and flags every place that surface exceeds the running user's own permissions or reaches a field the org has labelled as regulated.

What it reports is the Aksu Index — four numbers for one agent and one running user:

Aksu Index: 6 proven (1 regulated) · 0 unproven boundaries · 1 unresolved

The Aksu Index band from a real report: 6 fields proven reachable beyond the running user, 1 of them regulated, 0 unproven boundaries, 1 unresolved — and the reach strip showing 8 fields the agent's code reaches against 2 the user can read.

A real run, not a mock-up — TechnoStore, measured 2026-08-31, sealed as da237145496c. The full report is in this repository, alongside a second org whose Index is 0/0/0/7.

proven — fields the code demonstrably reaches past the user · regulated — the subset carrying the org's own compliance labels · unproven boundaries — a real boundary the analysis could not prove was crossed · unresolved — reach it could not determine at all.

Quoting the proven number alone while unresolved is above zero is a violation of the specification, and no API here returns fewer than four numbers. An unknown never reads as clean. The Index is defined by a public specification and licensed CC BY 4.0, precisely so a number carrying its name can be checked by someone who did not produce it — including against this tool.

No agent is ever invoked; no Flex Credits are consumed; it runs on every commit and can fail the build when the number gets worse.

The measurement, in one table

Same Apex source. Same user. Same records. The only difference is a number in a -meta.xml file that nobody reads:

apiVersion records returned fields returned
58 5 the value
67 0 blocked — No such column

Measured in a live org (E2, E2b). That number decides whether the code behind your agent is bounded by the user it runs as, and most orgs have classes on both sides of it.

Run it

# macOS / Linux
sf org login web --set-default
git clone --branch v1.0.0 --depth 1 https://github.com/aksumustafa1625/agent-blast-radius ~/agent-blast-radius
cd ~/agent-blast-radius
python3 measure.py
# Windows PowerShell
sf org login web --set-default
git clone --branch v1.0.0 --depth 1 https://github.com/aksumustafa1625/agent-blast-radius $HOME\agent-blast-radius
cd $HOME\agent-blast-radius
py measure.py

Prerequisites: Python 3.12 or newer, git, and the Salesforce CLI, plus an org you can log in to. Node is optional - without it the Apex extractor falls back from the parse tree to regex, and the report names which one it used.

Three things in those four lines are deliberate, and each one is a bug someone already hit:

  • The clone target is spelled out. A shell opened from the Start menu can be sitting in a directory you cannot write to, and git clone with no target writes to wherever you happen to be.
  • There is no &&. Windows PowerShell 5.1 does not have it and answers with a parser error rather than a hint.
  • Windows uses py, not python. python.exe and python3.exe under WindowsApps are 0-byte Microsoft Store stubs that are on PATH by default, so python measure.py can open the Store while Python 3.12 sits installed on the same disk. The py launcher gets its own PATH entry whether or not Add to PATH was ticked.

No git? The release page carries a source zip for v1.0.0; unzip it and start from the cd line. If PowerShell answers running scripts is disabled on this system to the sf line, your Salesforce CLI came from npm and PowerShell is refusing its .ps1 shim - use sf.cmd org login web --set-default, or install the CLI from its own Windows installer.

No arguments. It asks the org for your agents and, for each one, the identity Salesforce actually runs it as — BotDefinition.BotUserId, which is a fact rather than a guess. For an employee agent there is no single running user, and the tool says so instead of picking one.

No pip install. Python standard library only. Python 3.12 · Salesforce CLI (sf) · an authenticated org · Node optional — without it the AST backend falls back to regex and the report says which one it used.

Clone to Index: 41–62 seconds, measured 2026-08-30 across two orgs on a Windows machine, against an already-authenticated session. The spread is the org's, not the tool's — an agent with nine actions has more metadata to read than one with a single action. If your laptop blocks the browser login flow, run sf org list and pass an alias you already have with --org.

It writes reports/<your-org>_<your-agent>.md and .html, opens the HTML for you when it finishes, and stops there. That folder is yours: nothing of mine is in it, and it is git-ignored, because a report carries your org's usernames and object names. The two published examples live in examples/ instead. Pass --no-open in CI.

No Agentforce org? Run the 28-case corpus instead — no org required. It validates the method; it does not measure your organisation.

Every other command — the test suite, the benchmark, the census, the determinism proof — is under Every command, after the evidence.

What this is, and is not

Is Static authority analysis for one Agentforce agent and one running user
Is not A runtime prompt-injection scanner
Is not Adversarial payload testing
Is not An org-wide permission review
Is not A certificate

And on an agent authored in Salesforce's open-source Agent Script, it does not stop at reachability — it follows the value all the way into the prompt:

[PS522] Regulated field HealthRecord__c.Diagnosis__c is interpolated into the
        model's prompt at line 125, and the running user has no FLS on it.

  Traced:  HealthRecord__c.Diagnosis__c
             → @outputs.summary            (Apex: SOQL → @InvocableVariable)
             → @variables.record_summary   (.agent line 128)
             → prompt                      (.agent line 125)

  This is not inferred reachability — every hop is a node in a parse tree.

Searching the compiled agent artifact for that chain found zero occurrences — it survives only in the Agent Script source. So for the compiled forms tested, a metadata-based scanner has nothing to read.

Honest framing. A reference implementation built on my own initiative, not client work. The analyzer, the in-org experiments, the 307 unit tests, the accuracy benchmark, the live agent authored in Agent Script and published to the org, and the reports against four real orgs are all real, produced at zero Flex Credits. The health-records domain is fictional demo data. This is not a certificate — it is an agent-scoped security review accelerator that produces evidence for a DPIA.

Full case study: mustafaaksu.dev/en/projects/agent-blast-radius

Everything this README claims is published and checkable:

The specification, CC BY 4.0 aksuindex.com
The benchmark — 28 cases, 21 adjudicated by a live org, sha256-sealed, CC BY 4.0 agent-authority-benchmark
Two real reports, as the analyzer wrote them TechnoStore · HanseWatt
What the tool refuses to say, and why agentblastradius.com

Run your own analyzer against that corpus and publish the score. That is what it is for, and no permission is needed.


Measured against Salesforce's own Graph Engine

Salesforce ships sfge, a data-flow engine whose two rules overlap this tool almost exactly — the closest technical neighbour it has. Two engines disagreeing proves nothing about either, so benchmark/sfge_diff.py runs both on exactly the cases a real org has already adjudicated, generating the same statements the org executed. Every disagreement has a referee:

Each case scored on the axis its own runtime shape adjudicates:
   sfge contradicts the org on                 8/21
   Agent Blast Radius contradicts the org on   0/21    (WARN = "did not assert")
   ...and on sfge's own binary scale           2/21    (any finding = an assertion)

Scored both ways on purpose: a sceptic can fairly say a three-level scale against a binary engine is not the same scale, and publishing only the flattering number would be selective reporting. The result holds either way.

The 8 are not one thing, and the run says which — quoted from FACTS.md, not retyped: 4 apiVersion blindness on both axes (v67 read ×2, v67 record, v67 write — sfge gives no credit for secure-by-default, and the platform bounds that code); 1 SOSL (ApexFlsViolation never walks a RETURNING, so sfge misses an escape the org hands over); 1 platform-event publish (E11's shape: a user with no ObjectPermissions row at all — v58 publish lands, v67 is blocked); and 2 sanitizer rows.

Read the 8 as 6 + 2. Six are cases where sfge differed from the org-adjudicated label. The two sanitizer rows are a difference of severity discipline between two tools — this one does not call them clean either, it says WARN — and counting them as wins would be the same inflation as a remediation list with twelve jobs that are really six.

Why it is not in CI. Not for speed — it is 42 seconds, measured. The gate's contract is to prove this analyzer correct; the differential proves a comparative claim that does not change per commit, and gating it would put a third-party Java engine in the critical path and turn the build red when sfge changes. It is a dated measurement — 2026-08-29, Code Analyzer 5.15.0, corpus v1.1 — and it is re-run when either side ships. (ADR-006)

This is not "sfge is bad." It is a general-purpose, deliberately conservative scanner answering a different question — "is an FLS check present?" — with no notion of a running user or a compliance label. The supportable claim is narrow: for this question — what can this agent reach as this user — a version-aware, user-scoped analysis is measurably more precise, and the org is what says so.

The gap this fills

Salesforce says: give agents least privilege. A whole product category (AISPM — AppOmni, Zenity, Security Center) audits agent permissions. All of it works 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.

A naive scanner that flags without sharing without understanding this precedence produces false positives that destroy its own credibility. So the precedence law was proven before it was coded.

The evidence-first method

MILESTONE_0_EVIDENCE.md documents hand-run, in-org experiments (self-contained fixtures, System.runAs, zero credits) that established:

1. explicit clause      WITH USER_MODE / SYSTEM_MODE / AccessLevel.*
2. apiVersion default   >= v67 user mode ; <= v66 system mode
3. sharing declaration  governs record access only under system mode

…and disproved three widely-assumed shortcuts along the way:

  1. a missing sharing declaration is not without sharing (it inherits the caller);
  2. v67's user-mode default overrides even an explicit without sharing for plain operations;
  3. a trigger's DML mode follows the trigger's own apiVersion, not the action's access level.

Two axes are tracked separately, which is the distinction most reviews get wrong: with sharing at v58 enforces the record axis but still bypasses CRUD/FLS — so "bounded by the running user" requires both.

Accuracy — not a test count

"307 tests green" is not an accuracy claim. A test suite proves the code does what its author expected. blast_radius/benchmark/ measures how often that expectation is right:

cases: 28   passed: 28        PS501…PS514 → 100% precision, 100% recall
mutation score: 8/8 caught
label strength: 21 experiment · 3 platform-doc · 4 reasoned

28 of 28 is not the claim; the label strength is. Passing means the analyzer agrees with 28 hand-written labels — and for the 4 reasoned ones, that proves I am consistent with myself, nothing more. What carries weight is the 21 measured in a real org and the runtime oracle behind them.

Read honestly — and the runner prints this under the score:

  • The analyzer predicts; the ORG judges. oracle.py deploys each case as real Apex, runs it as the modelled user, and asserts the analyzer's own prediction — so a red test means the analyzer is wrong. It is the only ground truth that doesn't share a mind with the implementation. 21 of 28 cases carry a shape; all 21 agree. It has a negative control (grant FLS on one field and read it under the same enforcement that blocks the other — if that ever goes red, every other green is vacuous), and it has already caught a real false positive in this tool.
  • Label strength. Every case names where its ground truth comes from: experiment: (21, measured in a real org), platform-doc (3), reasoned (4 — the author's reasoning, which proves consistency, not correctness). That ratio is the benchmark's real quality metric; it was 6/10/7 before the oracle existed.
  • Not every case can be settled by an org, and the corpus says which. PS504's "we do not know" and PS514's "we flag what we do not follow" assert what the analyzer must report, not what the platform does — an org cannot measure the absence of our knowledge. Counting those as gaps would overstate what is missing.
  • Mutation score. A benchmark that passes on day one may just agree with the code beside it, so mutate.py breaks the analyzer on purpose — one semantic at a time — and checks the corpus notices. An escape is a finding. 8/8 caught, including "ignore apiVersion" — the exact mistake sfge makes.
  • Expectations are hand-written, never generated from the law under test (that would be a mirror, not an oracle).

The benchmark and the mutation score run in CI and fail the build, so an accuracy regression is caught like a broken test. The sfge differential deliberately does not — not for speed (measured: 42s), but because the gate's job is to prove this analyzer correct, while the differential proves a comparative claim that doesn't change per commit; gating it would put a third-party engine in the critical path and go red when sfge changes. It exits non-zero only if this tool contradicts the org.

The pipeline

agent config ─► reach readers ─────► authority_analyzer ─► report
  .agent  (Agent Script,   apex_introspect   × permission_resolver   Escalation Gap
           official parser)  ← real Apex AST × ComplianceGroup labels  + PS5xx findings
  or GenAi metadata        flow_introspect                            (deterministic,
                           genai_prompt_introspect                     fingerprint-bound)

Apex reach is read from a real parse tree (ANTLR apex-parser), with the regex extractor kept as an honest fallback when Node is absent. An .agent file is read with Salesforce's own open-source parser. Both input paths are supported, because Agent Builder agents still compile to GenAiPlugin metadata.

Reach covers Apex (SOQL, SOSL, DML, dynamic queries, one-level delegation), Flow runInMode, prompt templates (every version — a field only an inactive version reaches is latent, PS513), and standard-action channels. Security.stripInaccessible is modelled as the real sanitizer it is; async/event/callout hand-offs are surfaced as explicit unknown edges rather than silently dropped.

Full rule table (PS501–PS514, PS520–522) and module map in blast_radius/ and CLAUDE.md.

What it needs from your org

A security tool that opens with "grant me System Administrator" has already lost the argument. Here is everything the analyzer reads, so you can decide rather than trust.

It only ever reads. No record is written, no agent is invoked, no Flex Credit is spent, and nothing is transmitted anywhere — the analysis runs on your machine against your own authenticated session, and there is no server to transmit to.

what it reads why
ApexClass, ApexTrigger the source behind the agent's actions, and their apiVersion
GenAiFunctionDefinition which Apex or Flow each action actually invokes
EntityDefinition, FieldDefinition field metadata and the org's own ComplianceGroup labels
ObjectPermissions, FieldPermissions what the running user is allowed to see
PermissionSet, PermissionSetAssignment how that permission is composed, groups and muting included
User resolving the agent's own running user from BotDefinition.BotUserId

Plus a metadata retrieve of the agent bundle, and — only with --include-counts — a COUNT() per reached object, which returns a number and never a record.

The permissions those imply: API Enabled, View Setup and Configuration (the permission objects above are setup entities), and read on the objects the agent's own actions touch. This list is derived from the queries the analyzer makes, not measured by running it as a restricted user — if you run it under a narrower identity and something fails, that is a finding and the report will say so rather than going quiet.

One trap, and it is measured. FieldDefinition is itself FLS-gated (E4). An analysis identity too narrow to read it will silently see fewer compliance labels — so a 0 regulated result would mean "I could not look", not "nothing is labelled". The report prints its own classification coverage for exactly this reason: a blind spot is reported as a blind spot, never as a clean result.

Every command

# 307 tests. In a fresh clone, before `npm install`, 32 of them skip cleanly -
# the AST and Agent-Script suites need Node - and the run still reports OK.
# (Measured in a fresh clone. Keep the clone path short: on Windows a checkout
# nested past ~240 characters makes 5 more skip, because os.path.exists fails
# on the long path rather than because anything is wrong.)
python -m unittest discover -s blast_radius -p "test_*.py"

# accuracy, not just green tests
python blast_radius/benchmark/run.py
python blast_radius/benchmark/mutate.py

# audit any authenticated org's agent — GenAi metadata path
python blast_radius/cli.py --agent <PlannerBundle> --permission-set <PermSet> \
       --org <alias> --include-counts --fail-on ERROR

# …or the Agent Script path, which additionally proves the data → prompt chain
python blast_radius/cli.py --agent-script path/to/My_Agent.agent --permission-set <PermSet>

# whole-org API-version census (how much of the org still defaults to system mode)
python blast_radius/org_census.py --org <alias>

# prove determinism: two runs, byte-identical output.
# The honest form of that claim: two runs WITH THE SAME --snapshot-date input are
# byte-identical. A run using --include-counts is outside byte-determinism by
# construction, because the org's own row counts can change between runs.
python blast_radius/verify_deterministic.py -- --agent <X> --permission-set <Y> --org <alias>

What it found, across four real orgs

HospitalOrg runs HealthRecord Assistant AS — a real Agentforce agent authored in Agent Script, validated by Salesforce's own compiler and published with sf agent publish. One line justifies the tool:

Escalation Gap: 1 field — 1 regulated.

A pre-v67 action reads a Private object in system mode (PS501); HealthRecord__c.Diagnosis__c — ComplianceGroup: PII;GDPR;HIPAA — is read past the running user's FLS (PS506); the value is traced into the prompt at a specific line (PS522). The safe twin (v67 + USER_MODE) reports clean — no false positive.

HanseWatt reports 0 proven (0 regulated) · 0 unproven boundaries · 2 unresolved. Read that carefully, because the tool insists on it: it is not clean. Zero proven beside two unresolved means zero proven and two unresolved, and the report prints that caveat unprompted — in the author's own demonstration org.

It is also the most instructive measurement in the project. The org is 182 of 219 Apex files pre-v67 — 83% legacy — yet all nine of its agent's actions are v67. So the actionable sentence is not "modernise your org"; it is that you cannot know which part matters until it is measured, and it is a far smaller part than you fear.

TechnoStore is the opposite and the more common case: 113 Apex files, 100% pre-v67, reporting 6 proven (1 regulated) · 0 unproven boundaries · 1 unresolved with a regulated field reaching the model, and a build-failing gate. The report's Org health footer ties the two together: the org-wide pre-v67 debt is the root cause of that agent's blast radius — at v67 the gap would be zero.

What external review changed

Two independent technical reviews were commissioned against the project brief. Their findings were verified against the code, not accepted — and the outcome is the part worth reading:

Three of this tool's own headline numbers were wrong, and it now says so:

  • "HanseWatt: the agent reaches 31 records where the user sees 0" — false. Those classes are v67, so the agent is bounded by its user; there is no record escalation. The record count was mode-blind, and predicate-blind besides (the org COUNT() is an upper bound — WHERE/LIMIT are not resolved). Both fixed; the report now says "could reach up to N".
  • "TechnoStore's legacy trigger: ERROR" — unproven. Its body performs no DML. PS509 is now proof-based: ERROR only when the trigger's own body writes something the user can't.
  • Our flagship demo action publishes a platform event that the analyzer could not see at all. Publishing needs Create on the event and fires that event's trigger — the same cascade plain DML causes — so it is now modelled as a write, and PS503/PS509 apply to it. On TechnoStore that surfaced a new ERROR: the agent publishes an event the running user has no Create on, and that event drives MuleSoft and a customer email — work the user could not start. The premise underneath it did not stay believed: E11 measured it in-org (v58 publish lands without Create; v67 is blocked). What is still not followed — a Flow, process, or off-platform subscriber — is what PS514 now names precisely.

And one review's CRITICAL finding was refuted by measurement (E8): Permission Set Groups are handled — a group assignment points at the platform-computed aggregate permission set, whose permissions equalled the union of its components exactly. A stale docstring claiming otherwise is what produced the false finding — documentation that overstates limitations costs credibility exactly like documentation that overstates capability.

That lesson has a sequel worth stating, because it kept happening: four more "known gaps" turned out to be already-closed once anyone measured them — aliasing in the Authority Path, @future/queueable reach, cross-object classification, and the entry-point result E2 had asserted without a control. Each was a note telling the next reader to go rebuild something that worked. E8's untested edge became E9 (muting: the aggregate applies it — measured, and it mattered in the dangerous direction, since crediting a user with a permission they lack is how a static analyzer produces a false clean).

Also closed from that review: SOSL was a silent blind spot (and dynamic SOQL was quietly producing no honest-unknown at all); stripInaccessible was unmodelled, so correct code was flagged. Full detail in CLAUDE.md, Appendix AD.

For a security tool, finding your own claims wrong is the point.

The three occasions where this analyzer itself was wrong — what it claimed, what corrected it, and what changed — are written up in docs/ERRATA.md. None were found by reading documentation; all three were settled by running code against a live org.

An upstream find

Wiring Salesforce's official Agent Script SDK in surfaced a packaging bug: the main entry of @sf-agentscript/agentforce (npm latest) cannot be imported at all — it is compiled against a newer @sf-agentscript/language than its own manifest pins, and three published packages are affected. Reported with a reproduction and root-cause analysis (issue #71) and fixed upstream with a post-publish smoke test that installs every published package into a clean directory and imports it (PR #72).

Where it stands

Done: the precedence law (experiment-established), both extraction backends over one shared core, Authority Path taint (incl. helper hops, the for (X x : [SELECT …]) loop and named-argument constructors), the Agent Script data→prompt proof, org-agnostic CLI + sf plugin + CI gate, org census and org-health, the accuracy benchmark with mutation testing, the runtime oracle (21 of 28 cases, the org judges) and the refereed sfge differential, permission-set groups and muting (E8/E9, measured), cross-object classification, and four real orgs scanned.

Open — the full list, with who found each gap and what measurement closes it, is docs/LIMITATIONS.md. It leads with the one category that matters most: undetected escalation paths — routes by which a real agent exceeds its user and this tool currently says nothing. There is one, it was found by a measurement of something else, and it is named there rather than folded in with "we do not parse managed packages".

The short form: Flow run context is resolved from documentation and not yet measured in-org; four v67 documentation claims touching the precedence law are unmeasured, one of which is probably unmodelled; inter-procedural taint leaves 44% of real agent-action verdicts undetermined (down from 66%); Flow, process and off-platform subscribers are not followed and PS514 names exactly which; formula inputs stay a statement about our resolution until E12 can be deployed; and the field gap is a lower bound because restriction rules are not modelled.

Documentation

Document What it answers
ARCHITECTURE.md Source layout + the rules that keep two backends honest over one core
docs/adr/ 13 Architecture Decision Records — incl. why the sfge differential is not in CI, why there is no schema_version, and why PS516 is not (yet) a leak claim
docs/architecture/ Mermaid views: context, container, scan sequence, data model, CI contracts
CONTRIBUTING.md Standing rules: experiment-locked semantics, severity discipline, both-orgs verification, PR checklist
SECURITY.md Read-only trust model, analysis-identity honesty, what a report should be trusted to mean
MILESTONE_0_EVIDENCE.md The first six in-org experiments (E1–E6), written up in full; E8–E16 are recorded in CLAUDE.md §2 with their probe classes under force-app/
docs/demo/ The rehearsed demo recording script with its on-camera liveness experiment
CLAUDE.md Working context: the precedence law, the rule table, the mistakes already paid for

Related projects

  • Prüfstand (private) — the behavioral half: a red-team corpus + deterministic verifier for what an agent does. Blast Radius is the authority half: what its code could reach. Together they bound an agent from both sides.
  • hospital-org-mcp — the org this lab lives in was itself built by an AI agent loop over six MCP deployment tools.

Author: Mustafa Aksu — Salesforce Developer & ISV Partner (Agentforce · MCP · Data 360). Portfolio: mustafaaksu.dev · Licensed MIT.

About

Can your Agentforce agent's code read fields its running user cannot? Static, zero-credit analysis of Salesforce execution semantics - apiVersion 67 user-mode default, sharing, Flow runInMode, triggers - reported as the Aksu Index: four numbers, one agent, one user. 16 in-org experiments, 323 tests, 21 cases adjudicated by a live org.

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages