scanent (second name: Agent Estate Scanner, command scanent, alias aes)
runs inside an organisation's own estate and takes an inventory of the AI
agents and agent-shaped principals there: service principals, service
accounts, API tokens, CI workflows, MCP servers, tracing projects and agent
definitions in code. For each one it reports the credentials it holds (by
reference), the tools it can call, the model endpoints it uses, and verdicts
against a six-rung harness ladder (H0 to H5). The result is one signed JSON
report.
Status: Phase 4, hardening (in progress). This repository is the
scanner carved out of the Inflect Compliance repository (see
docs/EXTRACTION.md for what was moved and what was not).
It works with no Inflect tenant at all: scanent init writes a configuration
and a signing key, scanent doctor checks that the estate can be read, and
scanent scan writes a page you can open, two spreadsheets, the results as
JSON and the signed report. See Quick start and
See your results locally. A container image is
built and tested in CI; no image or package is published yet.
For the organisation being scanned: docs/guide/README.md is a guide in plain language for an IT or security lead who is not a developer: what the scanner is and is not, what it reads and the access to grant, how to run a first scan, how to read the report, what to do about each finding, and the questions a security team asks.
For a security team: docs/network-and-permissions.md lists every request the scanner can make and every grant it asks for, docs/threat-model.md what it defends against and what it does not, docs/data-protection.md the personal data it touches (with a DPIA template), and docs/verify-a-release.md how to check a release's signature, SBOM and provenance. Report a vulnerability as SECURITY.md says.
The security intake pack is the index for a reviewer: architecture, a table of each control with the code that enforces it and the test that proves it, and a blunt page on what has and has not been done to test the scanner. No pilot on a real estate, no external penetration test and no signed release exist yet.
For whoever arranges a scan: the engagement kit has a scoping checklist, a ready-to-send read-only access request for each source, a data-handling statement, a run-book and how to make a sample report from made-up data. docs/roadmap.md lists what is not in this version.
For whoever operates it: docs/operations.md has the
log's events and fields, the exit codes, and scanent support-bundle.
Running it at your own site: docs/quick-start.md, "Run it at your site in 15 minutes", needs a container runtime and nothing else. docs/deployment.md covers Docker, Podman, Compose, Kubernetes (a Helm chart) and a site with no internet (an air-gapped bundle).
- A source holds an HTTP client whose only verb is GET. The one POST a source
can cause is the token exchange with the identity provider, behind its own
interface. MCP servers are sent
initializeandtools/list, nevertools/call; the one exception is the read of the scanner's own tenant's register. - A secret's value is never requested. Credentials are reported by reference
(
env://NAME,azure-kv://…,github-secret://…), and the scanner refuses to sign a report in which any string has a secret's shape. - Code is parsed, never run: the
codesource imports, evaluates and spawns nothing. - Every request the scanner can make is declared in one file
(
packages/scanner/src/scanner-surface.ts). The hash of that declaration is in every report, and the tests hold every request the fakes see to it. - A source that could not be read in full is in the report as
INCOMPLETEwith its reason. It is never left out and never reported as complete.
docs/scanner.md describes each source in detail.
"Implemented" means the reader is in this repository and its tests pass against fakes of the product's API.
| Kind | Product | Status |
|---|---|---|
entra-sp |
Microsoft Entra service principals and managed identities | Implemented |
okta-app |
Okta service apps and API tokens | Implemented |
vault |
Azure Key Vault secrets and certificates (metadata only) | Implemented |
gcp-sa |
Google Cloud IAM service accounts and their keys | Implemented |
cicd |
GitHub Actions workflows and repository secrets (names only) | Implemented |
runtime |
LangSmith tracing projects and deployments | Implemented |
code |
LangChain, LangGraph and Claude Agent SDK agent definitions in Python, JavaScript and TypeScript | Implemented |
mcp |
MCP servers' tool listings | Implemented |
inflect-register |
An Inflect tenant's agent register | Implemented; needs an Inflect tenant |
runtime |
NVIDIA OpenShell gateway | Not available. A reader exists in the source tree, but no configuration selects it |
| Package | What it is |
|---|---|
packages/contract (@rodnapamet/scanent-contract) |
The report contract: strict schemas, the harness ladder, the findings planner, canonical JSON and signature verification. The one package a release publishes, to GitHub Packages; docs/contract.md says how a consumer installs it. Not published yet |
packages/scanner (@scanent/scanner) |
The scanner and its command line |
packages/report (@scanent/report) |
Local results: scores a signed scan against the ladder with no tenant, from the report and an optional operator inventory; renders results.json, the two CSV files and summary.html; compares two runs. Pure functions |
Needs Node.js 22 or later, and nothing else.
npm ci
npm run build
# 1. Set up. Asks which sources you have, then writes ./scanent/config.json,
# ./scanent/scanent.env (the NAMES of the credentials to fill in) and a
# signing key, and prints the read-only access each source needs.
npx scanent init
# 2. Put each credential in scanent.env, then load it. The shell reads this file here, and
# the shell rewrites $, #, ; and spaces: put each value you add in single quotes
# (OKTA_API_TOKEN='...'), and take the quotes out again before giving the same file to
# `docker --env-file`, which takes a value as written. A NAME_FILE=/path line needs neither.
set -a; . ./scanent/scanent.env; set +a
# 3. Check: the configuration, the key, each credential, each host, each read.
npx scanent doctor --config ./scanent/config.json
# 4. Scan. The results are written under the directory you chose.
npx scanent scan --config ./scanent/config.jsoninit never asks for a secret value, and refuses an answer that looks like
one. doctor reads only: it writes and posts nothing, and it says "not
observable" where it cannot check something rather than passing it. Both are
described in docs/scanner.md, and the
access each source needs is in docs/access.md.
To try it on the example estate in this repository, with no credential and no
network, give init its answers as a file:
cat > /tmp/scanent-answers.json <<ANSWERS
{
"answers": "scanent-init-answers/1",
"estate": "example-code",
"outputDir": "$HOME/scanent-out",
"inventoryPath": "$(pwd)/examples/code-only/inventory.example.json",
"sources": [
{ "kind": "code", "roots": [{ "label": "example", "path": "$(pwd)/examples/code-only/estate" }] }
]
}
ANSWERS
npx scanent init --dir /tmp/scanent-example --answers /tmp/scanent-answers.json
set -a; . /tmp/scanent-example/scanent.env; set +a
npx scanent doctor --config /tmp/scanent-example/config.json
npx scanent scan --config /tmp/scanent-example/config.jsondoctor exits 3 here, with one warning: the example estate is writable by
you, and it advises mounting agent code read-only. The scan runs all the same.
npx scanent and npx aes are the same program, and so is
node packages/scanner/dist/cli.js. npx scanent help lists every command.
A configuration can also be written by hand, and the signing key made with
openssl and passed in the environment:
export SCANNER_SIGNING_KEY="$(openssl genpkey -algorithm ed25519)"
# A configuration that reads the example estate. A code root is an absolute path.
sed "s#__REPO__#$(pwd)#" examples/code-only/config.template.json > /tmp/scanent-example.json
# The public half of the key, then one scan printed instead of posted.
npx scanent public-key --config /tmp/scanent-example.json
npx scanent scan --dry-run --config /tmp/scanent-example.json--dry-run prints the signed report on stdout and posts nothing; the log is
JSON lines on stderr. The code source reads local files only, so this example
makes no network request. Without --dry-run the scanner posts the report to
the tenant the configuration's inflect block names.
For any credential the configuration names, <NAME>_FILE may name a file
holding the value instead of the variable holding it
(SCANNER_SIGNING_KEY_FILE=/path/to/key.pem). Behind a proxy, set
HTTPS_PROXY and NODE_USE_ENV_PROXY=1; to trust a private certificate
authority, set NODE_EXTRA_CA_CERTS to its PEM file. See
docs/scanner.md.
No Inflect tenant is needed. --out <dir> writes each run's results under a
directory, and the example below uses a configuration with no inflect block
at all.
export SCANNER_SIGNING_KEY="$(openssl genpkey -algorithm ed25519)"
sed "s#__REPO__#$(pwd)#" examples/code-only/config.local.template.json > /tmp/scanent-local.json
npx scanent scan --config /tmp/scanent-local.json --out "$HOME/scanent-out"The same with the container. The output directory is the one writable mount;
the root filesystem stays read-only. The results are evidence, so the directory
is private to you (0700, not a world-writable one under /tmp), and the
container runs as you (--user), so it can write there and you can read what
it wrote:
mkdir -p /tmp/scanent-config && mkdir -m 0700 -p "$HOME/scanent-out"
sed "s#__REPO__/examples/code-only#/example#" \
examples/code-only/config.local.template.json > /tmp/scanent-config/config.json
docker run --rm --user "$(id -u):$(id -g)" \
--network none --read-only --cap-drop ALL --security-opt no-new-privileges \
-e SCANNER_SIGNING_KEY \
-v /tmp/scanent-config:/etc/scanent:ro \
-v "$(pwd)/examples/code-only:/example:ro" \
-v "$HOME/scanent-out:/out" \
scanent scan --config /etc/scanent/config.json --out /outWithout --user the image runs as uid 1000, and the run's directories (0750)
and files (0640) are readable by uid 1000 and root only: give it a directory
owned by uid 1000 (chown 1000), never one that every user can write. Rootless
Docker and Podman map users differently; there, run it as the user that maps to
you (Podman: --userns=keep-id --user "$(id -u):$(id -g)").
Each run is one new directory, written whole or not at all, and nothing that
exists is ever overwritten except the latest.json pointer:
<output directory>/
latest.json which run is the newest
runs/<scanId>/
summary.html open this one
agents.csv one row per agent, one column per criterion
findings.csv one row per finding
results.json everything above, as data (schema scanent-results/1)
report.signed.json the signed report, byte for byte what would be posted
manifest.json the names, sizes and SHA-256 of the other five
summary.html is one self-contained file: no script, no image, no font and no
request to anywhere, so it can be opened from disk, attached to a ticket or
printed. What it says, in the order it says it:
- Completeness first. Which sources were read in full. An agent whose sources were not is shown as "Not judged", with the reason, and is given no level anywhere. Nothing is shown as gone or resolved on a partial read.
- What this run could not have evidenced. Many criteria are read only from an Inflect tenant's register, and some by no reader yet. The page names each, so "nothing in this run could have seen it" is not mistaken for "a source saw it fail". Without a register no agent can be shown above H1.
- The estate: agents per level, shortfalls, shadow agents, orphan credentials, and every agent against every criterion.
- An empty scan is a finding, never a clean result.
To show who owns an agent without a register, give the configuration an
inventory ("inventory": { "path": ... }, a JSON or CSV file; the example
uses examples/code-only/inventory.example.json). Everything it supplies is
labelled "declared by the operator" in every file, counts for the H1 criteria
only, and never enters the signed report.
Compare two runs:
npx scanent diff "$HOME/scanent-out"/runs/<older>/results.json "$HOME/scanent-out"/runs/<newer>/results.jsondiff takes results.json files or signed reports. It calls an agent "gone",
or a finding "closed", only when the source that would have listed it was read
in full by the newer run; otherwise it says "not seen" and why. It exits 0 for
no difference, 1 for a difference, 2 for an error, and 3 when it found no
difference but could not compare everything.
No sample output is committed, because a run's files carry its time, a random scan id and a signature from the key it was run with. The commands above produce one in a few seconds.
To run the image at a site, follow docs/quick-start.md:
init, doctor and scan from the image, as yourself, with no openssl and
no checkout. What follows builds the image from this checkout and scans the
example estate with it. It needs Docker (or another runtime that builds a
Dockerfile) and openssl.
docker build -f deploy/Dockerfile -t scanent .
export SCANNER_SIGNING_KEY="$(openssl genpkey -algorithm ed25519)"
# The example estate is mounted at /estate, so the configuration names that path.
mkdir -p /tmp/scanent-config
sed "s#__REPO__/examples/code-only/estate#/estate#" \
examples/code-only/config.template.json > /tmp/scanent-config/config.json
docker run --rm \
--network none --read-only --cap-drop ALL --security-opt no-new-privileges \
-e SCANNER_SIGNING_KEY \
-v /tmp/scanent-config:/etc/scanent:ro \
-v "$(pwd)/examples/code-only/estate:/estate:ro" \
scanent scan --dry-run --config /etc/scanent/config.jsonThe image runs as a non-root user, writes nothing outside a mounted output
directory and needs no capability, so
--read-only --cap-drop ALL --security-opt no-new-privileges is how it is
meant to run everywhere. --network none suits this example only, because the
code source reads local files; a source that reads an API needs a network.
The mounted files must be readable by the user the container runs as (uid 1000
unless --user says otherwise).
init, keygen, doctor and hosts are in the image too. A credential can
be given as a mounted file: set <NAME>_FILE to its path in the container.
The image sets NODE_USE_ENV_PROXY=1, so HTTPS_PROXY and NO_PROXY take
effect when they are set.
With no arguments the image runs schedule --config /etc/scanent/config.json:
one scan at once, then one every schedule.everyHours, each delivered to the
configuration's outputs (by default, posted to the inflect block's tenant). See docs/scanner.md, "Container".
Behind a TLS-inspecting proxy, give the build that proxy's certificate
authority as the optional ca build secret; it is used by the two npm ci
steps only and reaches no layer:
docker build --secret id=ca,src=/path/to/ca.pem -f deploy/Dockerfile -t scanent .deploy/smoke.sh scanent runs the image's smoke test against the image you
built.
deploy/ also holds a Compose file and its egress overlay, a Helm chart
(deploy/helm/scanent), the air-gapped bundle's scripts (deploy/airgap) and
example configurations (deploy/examples).
npm run lint # eslint: typescript-eslint recommended, plus the scanner's import boundaries
npm run typecheck # sources and tests, nothing emitted
npm run build # tsc, each package to its dist/
npm test # jestCI (.github/workflows/ci.yml) runs those four on Node.js 22 and 24 on every
pull request and push to main, and in a second job builds the image,
smoke-tests it and scans it with Trivy.
scanent is licensed under the Business Source License 1.1 (BUSL-1.1); the licensor is Inflect Ltd. See LICENSE. BUSL-1.1 is a source-available licence. It is not an open-source licence: production use is subject to the licence's Additional Use Grant, and each version converts to the Apache License 2.0 on its Change Date.
NOTICE carries the third-party attribution: the receipt format the
contract verifies, and the run-time dependencies the image redistributes. The
image holds both files in /app.