An npm CLI that audits a codebase against ISO/IEC 27001:2022 Annex A and writes a detailed
iso-jevdit-report.md you can hand to an auditor.
It works differently from a normal "AI code review" tool. Every verdict comes from TypeSafe's Jev, a decision model: you give it state and typed questions, and it returns a typed answer with a probability attached — never prose. Every sentence in the report comes from a knowledge base that ships with the tool. So the model decides whether a control is violated, and reviewed human text explains what it means and how to fix it. Nothing in the remediation advice is generated on the fly, and nothing can be hallucinated into it.
iso-jevdit --provider=openrouter --key=sk-or-v1-... # store a credential, once
iso-jevdit --estimate . # what would this cost? (no API calls)
iso-jevdit . # audit the project
iso-jevdit src/auth/token.php # audit a single fileThis is version 0.1.3 and the audit engine is still being expanded. Being straight about it, because a compliance tool that overstates itself is worse than useless:
| Area | State |
|---|---|
Configuration, credentials, --init |
Working |
File discovery, the five ignore layers, --explain-ignores |
Working |
Chunking, small-file packing, --estimate cost forecast |
Working |
| Check catalog | Working, 3 of ~36 checks written |
| Request building, answer parsing, the OpenRouter adapter | Working |
| Sending chunks and collecting live run statistics | Working |
| Writing Markdown and JSON reports | Working |
| Caching and narrowing findings to line ranges | Not yet |
Today, iso-jevdit . discovers and chunks your code, calls Jev, shows live file/request/finding
statistics, prints a final summary, and writes iso-jevdit-report.md plus a JSON sibling. Finding
locations are approximate chunk ranges until localization lands. --estimate remains a no-network
cost preflight.
During a run, the live line includes the current folder and file. If OpenRouter returns HTTP 429,
the CLI prints Rate limit detected, waits at least 10 seconds (or longer when Retry-After asks
for it), and retries. Recoverable status is written atomically to .isojevdit/last-run.json, including
the last folder, file, chunk, counters, rate-limit state, and final report paths. If the terminal is
closed or the process is interrupted, that file remains as the last known run state.
Follow CHANGELOG.md for what lands when.
Requires Node.js 20.3 or newer.
# one-off, no install
npx iso-jevdit --estimate .
# project dev dependency (recommended for CI)
npm install --save-dev iso-jevdit
# global
npm install --global iso-jevditThe default provider is OpenRouter. Jev's pricing there is $0.042 per million input tokens and $0 for output, which is what makes this affordable — see Cost below.
iso-jevdit --provider=openrouter --key=sk-or-v1-...Credential saved.
provider openrouter
key sk-or-v1****************************9f2a
verified yes (accepted, no credit limit)
file C:\Users\<you>\.isojevdit\credentials.json (ACL limited to <you>)
The key is verified against the provider before it is stored, so a typo fails immediately rather than halfway through your first audit. See Credentials for where it lives and why.
cd my-project
iso-jevdit --initThis creates .isojevdit/ with a commented settings.json, a generated schema.json for editor
autocomplete, and a .gitignore for the cache. Commit settings.json — it is team configuration
and contains no secrets.
iso-jevdit --estimate .Scan
root /home/you/my-project
target .
files 412 eligible, 186 skipped, 51 not audited types
chunks 80 (13 packed from 45 files)
languages php 41, typescript 28, yaml 11
Forecast (--estimate: no API calls made)
provider openrouter / ~typesafe/jev-latest
checks 3 selected, 1-3 asked per chunk
requests 80
tokens 920.4K input (state 600.1K + questions 320.3K)
cost $0.0387 band $0.0290 - $0.0483
budget maxSpendUsd $10.00
Happy with the number? Drop --estimate.
your code
│ discovery which files are auditable, honouring five layers of ignore rules
▼
files
│ chunking split big files at function boundaries; pack small ones together
▼
chunks one chunk = one or more contiguous file regions
│ questions every applicable check becomes a typed question over that chunk
▼
Jev returns a verdict + a probability for each question
│ gating probability ≥ your threshold becomes a finding
▼
findings
│ knowledge base adds requirement, impact and remediation text per control
▼
iso-jevdit-report.md
Three consequences of using a decision model are worth understanding, because they shape everything:
- It returns no prose. All wording is ours, from
src/checks/kb/. Remediation advice is reviewed like code, and is identical for every user. - It cannot return line numbers. A verdict applies to a chunk. To narrow it down, the tool re-asks the same single question about smaller slices and keeps the ones still flagged. Because output tokens are free, that narrowing costs almost nothing.
- Questions are nearly free; re-sending code is not. So the tool asks every applicable check in one request per chunk rather than one request per check.
Only input tokens are billed. The block of questions is re-sent with every request, which means the number of requests drives the bill, not how much code you have. Two settings therefore matter most:
| Setting | Effect |
|---|---|
chunk.maxTokens (default 8000) |
Bigger chunks → fewer requests → cheaper. Too big and a single violation can get lost in the volume. |
chunk.pack (default false) |
Opt-in cost saving that packs small files together. Keep it off for exact per-file attribution until localization is available. |
For a 400-file, 2.4 MB repository with a full catalog of ~35 checks:
| Configuration | Requests | Input tokens | Cost |
|---|---|---|---|
| No packing, 8K chunks | ~450 | 2.4M | ~$0.10 |
| Packing on, 8K chunks | ~80 | 920K | ~$0.04 |
| Packing on, 24K chunks | ~30 | 720K | ~$0.03 |
Guard rails:
--estimateprints the forecast and makes no API calls.maxSpendUsd(default10.0) aborts before a run can exceed it — exit code 5. Raise it with--max-spend.- Token counts are estimates with a ±25% band, because Jev's tokenizer is not published. Runs reconcile against the usage each response reports.
Settings are read from, in increasing precedence:
- Built-in defaults
~/.isojevdit/settings.json(your personal defaults, all projects)<project>/.isojevdit/settings.json(committed, shared with your team)--config <file>- Environment:
ISO_JEVDIT_PROVIDER,ISO_JEVDIT_MODEL,ISO_JEVDIT_CONCURRENCY,ISO_JEVDIT_MAX_SPEND - Command-line flags
Objects merge deeply. Arrays replace, except ignore, ignoreDirs, unignore,
extraExtensions, extraChecks and includeDotDirs, which accumulate across layers.
A misspelled key is a warning that names the closest valid key, not an error — your audit still runs. An out-of-range value is always fatal (exit 2).
Comments and trailing commas are allowed in settings.json.
.isojevdit/settings.json, with defaults shown:
Five layers, applied in order. The last one to match wins.
| # | Layer | What it is |
|---|---|---|
| 1 | Shipped skip-list | node_modules, vendor, dist, build, coverage, target, __pycache__, lockfiles, *.min.*, *.map, … Turn off with ignoreDefaults: false. |
| 2 | .gitignore |
Every level: the repository root, nested files, .git/info/exclude, and your global excludes file. Negations (!keep.php) work. Turn off with respectGitignore: false or --no-gitignore. |
| 3 | ignoreDirs |
The simple knob. A bare name (.vscode) matches that directory at any depth; a name with a slash (storage/logs) is relative to the project root. Matching prunes the whole subtree. |
| 4 | ignore |
Gitignore-style globs, for finer cuts than a whole directory. |
| 5 | unignore |
Globs that override every rule above, including .gitignore. |
{
"ignoreDirs": [".vscode", ".idea", ".config", "storage/logs"],
"ignore": ["**/*.generated.php", "tests/snapshots/**"],
"unignore": ["config/security.php", ".env.example"]
}Two more rules worth knowing:
- Dot-directories are skipped by default, because most are editor or tool state. The exceptions
are CI and container configuration, which is real audit evidence, so
.github,.gitlab,.circleciand.dockerare scanned. Add more withincludeDotDirs..gitis never scanned. - Naming a file explicitly overrides everything.
iso-jevdit storage/cache.phpaudits that file even thoughstorage/is gitignored. Naming a directory applies the filters normally.
Nothing disappears quietly: every skipped file is counted, and you can always ask why.
$ iso-jevdit --explain-ignores vendor/lib/Thing.php
vendor/lib/Thing.php: not scanned - directory vendor/ is excluded by defaults rule "vendor"
$ iso-jevdit --explain-ignores src/Auth.php
src/Auth.php: scanned - no rule excludes it; it is auditedKeys are stored in ~/.isojevdit/credentials.json and nowhere else. settings.json is meant to
be committed, so it is never a place for a secret — a separate file outside the project can be kept
out of every repository by construction.
// ~/.isojevdit/credentials.json
{
"version": 1,
"openrouter": { "apiKey": "sk-or-v1-...", "savedAt": "...", "lastVerifiedAt": "..." }
}When reading, the first of these wins:
--keyon this runOPENROUTER_API_KEYin the environment (so CI needs no file at all)~/.isojevdit/credentials.json
Managing them:
iso-jevdit --provider=openrouter --key=sk-or-... # save (verifies first)
iso-jevdit --key=sk-or-... --no-verify # save without the live check (offline)
iso-jevdit --key=sk-or-... --no-save # use for this run only, store nothing
Get-Content key.txt | iso-jevdit --key - # read from stdin (PowerShell)
cat key.txt | iso-jevdit --key - # read from stdin (bash)
iso-jevdit --clear-key # forget the active provider's key
iso-jevdit --clear-key openrouter # forget a named provider's key
iso-jevdit --clear-credentials # forget everything, delete the fileHow the key is protected:
- Written atomically with mode
600. On Windows,chmodalone restricts nobody, so the file's ACL is also rewritten to your account only. - Masked to the first 8 and last 4 characters everywhere it is displayed — confirmations,
--show-config, logs, errors, and the report. - Not encrypted at rest. Anything the tool could decrypt unattended would be obfuscation, not
protection. This is the same posture as
gh,npmand the AWS CLI. --key=...on a command line lands in your shell history (on Windows, PowerShell writesConsoleHost_history.txtin plaintext). The tool says so after every such save. Use--key -if that matters to you.
iso-jevdit [path] file or directory to audit; default is the working directory
Credentials
| Flag | Meaning |
|---|---|
--provider <name> |
Which provider configuration to use (currently openrouter) |
--key <key|-> |
Store a key; - reads it from stdin |
--no-save |
Use --key for this run only |
--no-verify |
Skip the live key check when saving |
--clear-key [provider] |
Remove one provider's stored key |
--clear-credentials |
Remove every stored key and delete the file |
--model <id> |
Override the provider's model |
Configuration
| Flag | Meaning |
|---|---|
--config <file> |
Extra settings layer, highest file precedence |
--show-config |
Print the effective configuration, key masked, and exit |
--init |
Scaffold .isojevdit/ |
--list-checks |
List the selected checks and exit |
Scope
| Flag | Meaning |
|---|---|
--checks <ids> |
Only these check ids or globs, e.g. --checks a8-*; repeatable |
--exclude-checks <ids> |
Skip these check ids or globs |
--ignore <glob> |
Extra ignore pattern for this run; repeatable |
--ignore-dir <name|path> |
Extra ignored directory for this run; repeatable |
--no-gitignore |
Ignore the .gitignore stack |
--explain-ignores <path> |
Say which rule excluded a path, and exit |
--changed [ref] |
Only files changed against a git ref (default HEAD) |
Run
| Flag | Meaning |
|---|---|
--estimate |
Forecast files, chunks, tokens and cost; make no API calls |
--max-spend <usd> |
Stop before the forecast exceeds this |
--threshold <n> |
Override thresholds.report (0–1) |
--concurrency <n> |
Parallel requests |
--chunk-tokens <n> |
Token budget per chunk |
Output
| Flag | Meaning |
|---|---|
--out <file> |
Markdown report path; the JSON sibling uses the same base name |
--verbose / -v |
Show per-file detail and retries |
--quiet |
Print errors only |
--no-color |
Disable color output |
--version |
Print the version |
--help / -h |
Print command help |
Diagnostics go to stderr; results (--show-config, --list-checks, --explain-ignores) go to
stdout, so you can pipe them.
| Code | Meaning |
|---|---|
| 0 | Clean, or nothing at or above failOn |
| 1 | Findings at or above failOn |
| 2 | Configuration or usage error |
| 3 | Missing or rejected credential, or a provider failure with no usable result |
| 4 | No auditable files found |
| 5 | Stopped by the spend guard |
| 70 | Internal error |
In CI:
- run: npx iso-jevdit --changed origin/main --max-spend 0.50
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}Each check asks one narrow question about a chunk of code and maps to one or more Annex A controls.
iso-jevdit --list-checks prints what is active.
| ID | Controls | Severity | What it asks about |
|---|---|---|---|
a5-17-hardcoded-credentials |
A.5.17 | critical | Passwords, API keys, tokens or private keys written into source |
a8-24-weak-password-hash |
A.8.24, A.5.17 | high | Credentials hashed with a fast or unsalted algorithm |
a8-28-sql-injection |
A.8.28, A.8.26 | critical | SQL assembled from a value that isn't a bound parameter |
Around 33 more are planned, covering the code-observable part of Annex A: access control, logging and monitoring, secure configuration, input validation, cryptography, data masking, supply-chain hygiene, environment separation and secure-development gates. Organizational, people and physical controls cannot be judged from source code and will be listed as out of scope in the report's appendix rather than guessed at.
Every answer carries a probability. The tool gates on the probability mass on the check's
violation labels — not on the model's confidence value, which measures how concentrated the
answer distribution is and is therefore just as high for a confident pass as a confident fail.
- probability <
thresholds.report(0.6) → discarded - between
reportandhigh→ reported, one severity step lower, tagged low confidence - ≥
thresholds.high(0.8) → reported at full severity
These are policy, not physics. Tune them per check with thresholds.perCheck once you have seen how
they behave on your code.
Two configuration hooks, no forking required:
{
"prompts": {
// Added to every question. The cheapest way to stop a whole class of false positive.
"projectContext": "PHP 8.2 payment service. All queries go through App\\Db\\QueryBuilder, which parameterises.",
// Added to one check's instructions.
"append": {
"a8-24-weak-password-hash": "Hashes in App\\Cache are cache keys, not credentials."
}
}
}Add to extraChecks in settings.json. A check needs an id, a title, at least one Annex A control,
instructions, criteria labels, and remediation text — the tool validates all of it and refuses to run
on a malformed check.
{
"extraChecks": [
{
"id": "org-1-no-plaintext-pii-export",
"title": "Customer PII must not be written to CSV exports",
"controls": ["A.8.11", "A.5.34"],
"severity": "high",
"appliesTo": ["**/export/**"], // optional path globs
"languages": ["php"], // optional language filter
"type": "choice",
"instructions": "Does this code write customer PII to a file or export without masking?",
"criteria": {
"violation": "PII fields (name, email, phone, address, national id) reach a file, CSV or stream unmasked.",
"compliant": "PII is masked or tokenised, or no PII reaches an export here.",
"not_applicable": "This code does not export data."
},
"threshold": 0.75,
"remediation": "Route exports through App\\Export\\Masker."
}
]
}Guidance that makes a real difference to accuracy:
- Always include a no-match label such as
not_applicable, so the model has somewhere to put irrelevant code instead of choosing between two wrong answers. - The criteria text is the check. It is where the control's meaning actually lives — spend your effort there, not in the title.
- For labels that are easy to confuse, a criteria value may be an object instead of a string, with
fields you invent (the model sees the names).
what/not_for/examplesworks well:
"criteria": {
"violation": {
"what": "A credential is hashed with a fast digest such as MD5 or SHA-1.",
"not_for": "Hashing that does not protect a credential.",
"examples": ["$stored = md5($password);"]
},
"not_applicable": {
"what": "This code does not hash credentials.",
"note": "Cache keys, ETags and checksums belong here, whatever algorithm they use."
}
}Please read this before putting a report in front of an auditor.
- This is not a certification, and it is not a conformity assessment. It produces AI-assisted evidence for a human auditor to review. The report says so in its own appendix.
- It only judges what source code can show. Management-system clauses 4–10 and the organizational, people and physical controls of Annex A are out of reach and are never claimed.
- Findings are probabilistic. Expect both false positives and false negatives, and treat the thresholds as your policy. Nothing here replaces a review.
- Your source code is sent to the configured provider. Chunks of your files travel to
OpenRouter, which routes them to TypeSafe's model. Do not run this against code you are not
permitted to send to a third party. Use
ignoreDirs,ignoreand--checksto narrow what leaves your machine, and--estimateto see exactly how much would be sent. - No static analysis is performed. There is no parser, no data-flow analysis and no CVE lookup. Dependency checks judge pinning and provenance hygiene, not vulnerability identity.
"no auditable files found" (exit 4) — everything under the target is ignored or of a type that
is not audited. Run --explain-ignores <path> on a file you expected to be scanned.
A file you expected is missing — most often an extension that is not in the audited set. Add it
with extraExtensions, or name the file directly, which bypasses every filter.
"no credential for provider" (exit 3) — save one with --provider=openrouter --key=<key>, or set
OPENROUTER_API_KEY.
The forecast is higher than you want — raise chunk.maxTokens, confirm chunk.pack is on, cut
scope with ignoreDirs or --checks, or audit only what changed with --changed.
A warning about chunks exceeding the context — prompts.projectContext plus a chunk cannot
exceed the model's 32,000-token window. Shorten the context or lower chunk.maxTokens.
Windows: the key file could not be locked down — the ACL rewrite failed. The file is still mode
600, but check who can read ~/.isojevdit/.
- CHANGELOG.md — what changed, and what is coming
- CONTRIBUTING.md — development setup, and how to add a check
- SECURITY.md — reporting a vulnerability, and how this tool handles your data
- .claude/PLAN.md — the full design of record, including decisions and trade-offs
- Questions and bug reports: github.com/vidux/iso-jevdit/issues
- Security problems: see SECURITY.md — please do not open a public issue
{ "$schema": "./schema.json", // ── Provider ─────────────────────────────────────────────────────────────── "provider": "openrouter", "providers": { "openrouter": { // No apiKey here, ever. Keys live in ~/.isojevdit/credentials.json. "apiKeyEnv": "OPENROUTER_API_KEY", "baseUrl": "https://openrouter.ai", "model": "~typesafe/jev-latest", "headers": {}, "timeoutMs": 60000, "maxRetries": 4 } }, // ── What to read ─────────────────────────────────────────────────────────── "extensions": null, // unset = the shipped list (~60 types); setting it REPLACES that list "extraExtensions": [], // adds to the shipped list, e.g. [".blade.php", ".twig"] "filenames": null, // unset = Dockerfile, Makefile, nginx.conf, docker-compose.yml, ... "maxFileSizeKb": 512, "followSymlinks": false, // ── What to skip (see "Ignore rules" below) ──────────────────────────────── "ignoreDirs": [], // ".vscode" matches at any depth; "storage/logs" is root-relative "ignore": [], // gitignore-style globs, e.g. ["**/*.generated.*"] "unignore": [], // overrides everything above, including .gitignore "ignoreDefaults": true, // false = drop the shipped skip-list entirely "includeDotDirs": [], // adds to [".github", ".gitlab", ".circleci", ".docker"] "respectGitignore": true, // ── How code is batched ──────────────────────────────────────────────────── "chunk": { "maxTokens": 8000, "overlapLines": 20, // carried into the next chunk so a straddling issue is still visible "pack": false, "packSameDirOnly": true }, // ── Confidence policy ────────────────────────────────────────────────────── "thresholds": { "report": 0.6, // below this, a flagged chunk is discarded "high": 0.8, // at or above this, a finding keeps its full severity "perCheck": {} // e.g. { "a8-24-weak-password-hash": 0.85 } }, "severityOverrides": {}, // e.g. { "a8-15-missing-security-logging": "low" } // ── Which checks ─────────────────────────────────────────────────────────── "checks": { "include": ["*"], "exclude": [] }, "extraChecks": [], // your own checks; see "Writing your own checks" // ── Telling the model about your project ─────────────────────────────────── "prompts": { "projectContext": "", // added to every question's state "append": {} // extra note for one check: { "<check-id>": "..." } }, // ── Run behaviour ────────────────────────────────────────────────────────── "concurrency": 4, "maxSpendUsd": 10.0, // ── Accepted today, acted on when the audit engine lands ─────────────────── "localize": { "enabled": true, "slices": 3, "maxDepth": 2, "minLines": 12 }, "report": { "out": "iso-jevdit-report.md", "json": true, "includePassed": true, "includeConfig": true, "snippetLines": 6, "maxFindingsPerCheck": 50, "groupBy": "control", // "control" | "file" | "severity" "timestamp": true // false keeps committed reports diff-clean }, "cache": { "enabled": true, "dir": ".isojevdit/cache", "ttlDays": 30 }, "waivers": ".isojevdit/waivers.json", "failOn": "high", // CI gate: "critical"|"high"|"medium"|"low"|"info"|"none" "redactSecrets": true }