Ironheights
Ships an installable GitHub Action (mode: threatmodel or both) that checks every pull request against ironheights.threatmodel.yml, reports each claim as passed, violated or untested, and posts a single summary comment per PR; also supports threatmodel diff --base origin/main to report only what a PR adds.
Provides a reusable GitHub Actions workflow that enforces the repo's threat model as code on each pull request and posts a per-PR summary comment of claim results.
This server does not currently have a deployable release on Glama. Follow the repository's setup instructions to install and configure it for your MCP client, including any required credentials and local dependencies.
Ironheights: The Immune System for AI Agents
Catch bad skills before they spread.
If Ironheights is useful to you, a star on ClawHub or GitHub helps other OpenClaw, Claude and Grok Bot users find it.
Ironheights protects any AI bot that installs it. It is an open-source, local-first security scanner for AI agent skills (OpenClaw, Claude Code and Claude.ai, Grok Bot, Codex, Cursor, MCP), plus:
Threat model as code:
ironheights threatmodelchecks every pull request againstironheights.threatmodel.yml, and reports each claim as passed, violated or untested (docs).MCP server:
ironheights mcplets any MCP client callcheck_skillbefore installing a skill (docs).Drift guard:
ironheights driftflags a skill or MCP tool that changes after you approved it, including config keys that were added or restrictions that were loosened (docs).Posture report:
ironheights reportcovers worst reach, grades, a config hash, drift, threat-model coverage and the OWASP LLM Top 10 across a repo or org (docs).
Website: www.ironheights.dev
Ironheights scans any skill folder with a SKILL.md, whichever agent it is for, and adds an integrity monitor and runtime guard for OpenClaw. To check a Claude or Grok Bot skill, run npx ironheights scan ./path/to/skill before you upload or install it. Auto-discovery of installed skills, baselines, the guard, audit-config and safe-install are OpenClaw/ClawHub-only for now. It reads skill files as data, looks for risky patterns with fixed rules, and compares skill and agent files with a baseline you save on the same machine.
It does not send telemetry, and scan does not run the files it scans. Only sandbox run runs a skill, and only when you ask, inside a throwaway container with no network. Scans are fully local. The only network call made without a flag is a version check against npm and ClawHub, at most once every 6 hours; it only reports new versions, never installs anything, and can be turned off (see Updates). Other network use happens only when you run fetch, safe-install, advisories update, --online, scan --llm, scan --check-registry, or review.
Install
Install the OpenClaw skill from ClawHub (recommended):
clawhub install ironheightsThe skill uses the ironheights CLI, which requires Node.js 20 or newer on macOS, Linux, and Windows.
OpenClaw 2026.9.3 requires Node.js >=24.16.0 <25 || >=26.1.0. ironheights doctor prints whether the current runtime is inside that range.
Then install the CLI, or run it once with npx:
npx ironheights scan ./path/to/skillOr install the command:
npm install -g ironheights
ironheights scan ./path/to/skillih is the same command. From a git checkout:
npm install
npm run build
node dist/cli/index.js scan test/fixtures/benignRelated MCP server: inkog
Drift watch (rug-pull detection)
ironheights drift approve <skills> records what you reviewed; ironheights drift check (or watch) flags any later change: file hashes, the SKILL.md description, new URL hosts, new credential paths, MCP server entries, MCP tool descriptions, and config-key drift (a new key, a removed restriction, or a restriction whose value got looser). Local only, no telemetry. See docs/drift.md.
Posture report
ironheights report <repo-or-org-folder> --html posture.html lists every installed skill with its worst reach, grade, config hash, threat-model coverage, drift since approval and an OWASP LLM Top 10 mapping, with a trend over runs (--history). A skill whose config hash changed since the last history line is marked regraded. See docs/report.md.
MCP server
npx -y ironheights@0.29.0 mcp runs Ironheights as an MCP server, so Claude, Cursor, VS Code, Grok Bot or any MCP client can call check_skill (a fail-closed allow/blocked decision with provenance, an expiry and a server-written receipt, plus grade, findings, dependency closure and drift) before installing a skill, check_shortlist before choosing between several, redeem_authorization as the single-use install gate, tool_manifest_check for MCP tool-description drift, verify_check to re-confirm a remembered verdict, plus drift_status, threatmodel_check and get_rule. Local. See docs/mcp.md.
Threat model as code (checked on every PR)
Keep an ironheights.threatmodel.yml in your repo that lists the skills and MCP servers your agent trusts and what each may touch: network hosts, filesystem paths, environment variables, tokens, shell and a minimum grade. ironheights threatmodel init writes the first version from what is installed, threatmodel check fails on anything outside it, and threatmodel diff --base origin/main reports only what a pull request adds. Each claim is passed, violated or untested (a check that matched nothing is not a pass). Capabilities the model never names are listed as uncovered, with a suggested claim; they fail the check only with --fail-on-uncovered or defaults.uncovered: fail. A built-in remote-instructions claim reuses IH-INJ-004. The GitHub Action (mode: threatmodel or both) posts one summary comment per PR. Local only. See docs/threatmodel.md and examples/threatmodel.
Check where it runs, not just what it is
New in 0.28.0. Ironheights: The Immune System for AI Agents. It protects any AI bot that installs it, and this release adds:
ironheights surfaces, which fails a PR that changes whose credential a skill uses, how often it runs or where it runs without editing the threat model. It also derives surfaces from workflows, crontabs and container files and fails when the model omits one, checks declared max concurrent copies against the scheduler's own ledger, and checks contract call budgets against the last real run.ironheights fleet-posture, which puts ignore debt (the age of the oldest live ignore) next to the grade and gives one row per machine. The fleet is clean only when every running copy was checked and the file it actually ran matches.ironheights run-receipt write|audit, signed per-run receipts written by the loader, one line per running copy, with the trigger and the approver of the exact hash.MCP
type_verdict,verdict_gate,lookup_verdictandsession_binding: a check after load is never a pass, unknown verdict types fail closed, lookups need a content hash plus a verified publisher, and a reach report expires when the launching session changes. See docs/surfaces-fleet-receipts.md.
Bind the verdict to what actually runs
New in 0.27.0. Ironheights: The Immune System for AI Agents. It protects any AI bot that installs it, and this release adds:
MCP
check_invocation, which gives one verdict per exact command (argv, working directory, binary, transport, scopes, resolved install set and caller permissions), so any change voids it. It is joined byskill_copies,load_binding,session_lockandcheck_coverage. See docs/mcp.md.ironheights coverage, which shows coverage first and gives a grade only when no runs were missed.ironheights authority, which fails a PR when a CI runner's ambient authority grows or a removed skill leaves cron jobs, tokens or DNS records behind.ironheights record approve|audit, a signed, append-only install record an auditor can accept. See docs/coverage-authority-records.md.
Run it, watch it, install exactly what was checked
New in 0.25.0. Ironheights protects any AI bot that installs it, and these three commands cover what a static scan cannot see:
ironheights sandbox run ./skill -- node run.jsruns the skill once in a throwaway docker or podman container with no network except a logging proxy that refuses everything. It records every file written, every host tried and every child process, then fails on anything the threat model does not declare and lists declared writes that never happened. The GitHub Action runs it withsandbox-run. See docs/sandbox.md.ironheights watch --activity ./skills --events events.jsonlrecords file and network activity, observer heartbeats, check durations (busy queue or hang) and what the agent did after each refusal to an ed25519-signed, hash-chained log.ironheights report --from-watch activity.jsonlgrades what really happened. See docs/watch.md.ironheights install ./skill --lock skill.lock.jsonwrites only the exact bytes that were checked plus the pinned dependency closure, and refuses on any mismatch. See docs/install.md.
Retained receipts and attestations
New in 0.26.0, open and opt-in. ironheights receipts ingest keeps every MCP check receipt and activity-log reference in a signed, append-only local store with configurable retention; receipts export and receipts verify --bundle prove it offline. ironheights attest ./skill writes a signed "verified by Ironheights" attestation and badge SVG. See docs/receipts.md.
Also opt-in: signed alerts on block, drift and failed verification (IRONHEIGHTS_ALERT_WEBHOOK), ironheights policy init --template strict|balanced|ci, and ironheights fleet inventory across bots and hosts. See docs/fleet.md.
For publishers and registries: ironheights attest owner/slug@version attests a ClawHub skill read-only, attest --batch attests a list with a signed summary, and ironheights publisher reputation reports a signed tier per publisher from local attestations. See docs/publisher.md.
60-second quickstart
npx ironheights doctor
npx ironheights scan ~/.openclaw/workspace/skills/some-skill
npx ironheights baseline create
npx ironheights verifydoctor prints which OpenClaw directories it found and which it did not. scan prints findings grouped by skill. A run with nothing to report looks like this:
benign
No findings
Verdict: no-findings
Absence of findings is not proof of safety.A review looks like this:
demo
[high] IH-CRED-001 SKILL.md:3
cat ~/.ssh/id_rsa
Do not read these paths from a skill. Use a scoped environment variable or the platform secret store.
Verdict: review
Absence of findings is not proof of safety.Exit | Meaning |
0 | No findings |
1 | Review |
2 | Block |
3 | Incomplete. A file was skipped, or |
64 | Usage or config error |
70 | The command stopped: an internal error, a failed network request, or a rejected signature. |
--fail-on low|medium|high|critical returns 0 when every finding is below that severity and nothing was skipped. A skipped file, or a .git or node_modules directory that was not entered, still returns 3 unless you pass --allow-skipped or you list that directory under ignoreDirs with a reason. --allow-skipped keeps the warning and returns the finding verdict (0, 1, or 2). The grade stays incomplete. Limits and ignoreDirs are in docs/limits.md.
A scan that skipped a file looks like this:
padded
warning: skipped payload.md (file exceeds 1048576 bytes)
Scan incomplete: 1 file was not scanned.
Verdict: incomplete
Absence of findings is not proof of safety.Commands
ironheights scan <path> [--all] [--json] [--sarif <file>] [--md <file>] [--html <file>] [--format text|json|html] [--since-baseline [file]] [--print-suppressions] [--stdin] [--text <text>] [--fail-on <severity>] [--config <file>] [--allow-skipped] [--no-color] [--quiet] [--llm] [--llm-url <url>] [--llm-model <name>] [--llm-api ollama|openai] [--llm-consent] [--dry-run] [--check-registry]
ironheights review <path> [the same flags as scan]
ironheights fetch <owner>/<slug>[@version] [--out <dir>] [--json] [--sarif <file>] [--md <file>] [--fail-on <severity>] [--config <file>] [--allow-skipped] [--no-color] [--quiet]
ironheights sandbox run <skill> [--model <file>] [--image <image>] [--trace <file>] [--save-trace <file>] [--external <file>] [--fail-on-unused] [--json] -- <command...>
ironheights watch --activity <skills-dir> [--log <file>] [--events <file>] [--model <file>] [--check-budget <seconds>] [--once]
ironheights watch --verify-log <file> [--public-key <file>]
ironheights receipts <ingest|list|export|verify|prune> [--from <file>] [--activity <log>] [--retention-days <n>] [--since <date>] [--skill <name>] [--kind <kind>] [--out <file>] [--bundle <file>] [--json]
ironheights alerts <test|verify> [--body <file>] [--signature <base64>] [--public-key <file>]
ironheights policy init --template <strict|balanced|ci> [--dir <dir>] [--force] [--list]
ironheights fleet inventory <bot=dir|inventory.json...> [--out <file>] [--audit-export <file>] [--trust-key <file>] [--json]
ironheights publisher reputation [publisher] [--min-skills <n>] [--out <file>] [--json]
ironheights attest <skill|owner/slug@version> [--batch <file>] [--out-dir <dir>] [--out <file>] [--badge <file>] [--valid-days <n>] [--verify <file>] [--public-key <file>] [--json]
ironheights install <skill-folder|owner/slug[@version]> (--lock <file> | --expect-digest <sha256> | --write-lock <file>) [--dir <skills-dir>] [--with-deps] [--accept-review] [--json]
ironheights safe-install <owner>/<slug>[@version] [--dir <skills-dir>] [--accept-review] [--out <dir>] [--json] [--config <file>] [--no-color] [--quiet]
ironheights advisories update
ironheights advisories show
ironheights baseline create|update|show [--key <file>]
ironheights verify [--key <file>]
ironheights quarantine <skill>
ironheights quarantine restore <id>
ironheights rules list
ironheights rules show <id>
ironheights doctor
ironheights doctor audit
ironheights coexist [--json] [--fail-on <severity>] [--openclaw-config <file>] [--repo <dir>]
ironheights audit-config [--json] [--sarif <file>] [--md <file>] [--fail-on <severity>] [--openclaw-config <file>]
ironheights bench <corpusDir> [--external <file.json>]
ironheights guard status [--policy <file>] [--config <file>] [--json]
ironheights guard policy check [--policy <file>] [--config <file>] [--call <json>] [--call-file <file>] [--json]
ironheights guard log [--tail <n>] [--policy <file>] [--json]
ironheights update [--check] [--json] [--disable] [--enable]
ironheights --online <command>scan reads a cached advisory feed when one is present and does not download it. --online prints the feed URLs, downloads https://ironheights.dev/advisories.json and https://ironheights.dev/advisories.json.sig, checks the signature, then runs the rest of the command. --online with no other command only updates the cache. --all scans the skill directories from config, or the OpenClaw locations below when config does not set skillDirs.
Fetch and safe-install
fetch <owner>/<slug>[@version] downloads a ClawHub skill into a temp directory, or into --out, and scans it. It prints every URL before requesting it. The host is https://clawhub.ai. Redirects are refused. Files are written mode 0600 and are not executed. Zip and tar bytes are stored and not extracted. Absolute paths, .., and symlinks are refused. See docs/fetch.md.
fetch ada/notes@1.0.0
This command contacts the network. Scans stay local. Besides fetch, safe-install, advisories update, --online, scan --llm, and review, the only network call is a version check every 6 hours (off with IRONHEIGHTS_NO_UPDATE=1).
Requesting:
GET https://clawhub.ai/api/v1/skills/notes?owner=ada
GET https://clawhub.ai/api/v1/skills/notes/versions/1.0.0?owner=ada
Requesting:
GET https://clawhub.ai/api/v1/skills/notes/file?owner=ada&path=SKILL.md&version=1.0.0
notes
No findings
Verdict: no-findings
Absence of findings is not proof of safety.
Staged 1 files at /tmp/ironheights-fetch-.../notessafe-install uses the same download, then copies into the OpenClaw workspace skills directory, or into --dir. It copies only when the verdict is no-findings, or review if you pass --accept-review. block and incomplete are not installed. An existing copy is moved to ~/.ironheights/install-backup/ first and restored if the copy fails.
Advisory feed
advisories update downloads the signed feed from https://ironheights.dev/advisories.json and the detached signature beside it, then stores both under ~/.ironheights. A bad signature is not cached. scan and fetch compare the skill name, file sha256, and indicator hosts with that cache and report IH-ADV-001 with the source links. The feed format, the pinned public key, and the signing steps are in docs/advisories.md.
advisories update
This command contacts the network. Scans stay local. Besides fetch, safe-install, advisories update, --online, scan --llm, and review, the only network call is a version check every 6 hours (off with IRONHEIGHTS_NO_UPDATE=1).
Requesting:
GET https://ironheights.dev/advisories.json
GET https://ironheights.dev/advisories.json.sig
Advisory feed signature verified.
Cached 2 advisories generated at 2026-10-10T00:00:00.000Z
Wrote /home/ada/.ironheights/advisories.jsonThe production public key is pinned in this release: 8424b837422792656e6e7500869395a5afe64bf163baa63acea83f1e97cc2b6c. A feed signed by another key is rejected. scan with no cache still runs offline. Rotating the key is a new CLI release first, then a feed signed by the new key. The private key is not in this repository.
audit-config reads the local OpenClaw config and reports high-risk settings as IH-CFG-001 through IH-CFG-009. doctor audit is the same command. It does not replace openclaw security audit. See docs/audit-config.md.
Grade
Every scan prints a grade on the line before "Absence of findings is not proof of safety." The number is a trust score: 100 minus the risk score, and never below 0. A is 90–100, B is 80–89, C is 70–79, D is 60–69, and F is 0–59. The grade uses the worst skill after suppressions. Findings omitted by --since-baseline still count toward the grade.
The risk score behind the grade starts from the finding severities (critical 100, high 40, medium 15, low 5) and then removes double counting. The same evidence line for one rule, copied into two files, counts once. Repeat hits of one rule in one file are grouped: the first counts in full, each further hit counts half, and the group is capped at twice its worst hit. A first-party finding (an IH-INJ-004 remote-instruction or self-update line whose URLs all point at the skill's own declared host) counts as a review item, 10 points. It stays a medium finding with firstParty: true and a note, and the verdict is unchanged. A critical finding always counts 100, so anything critical is F. score in the JSON keeps the plain severity sum and drives the verdict.
A skipped file, or a .git or node_modules directory that was not acknowledged in ignoreDirs, makes the grade incomplete and hides the number, including when you pass --allow-skipped. The exit code can still be 0 in that case. The verdict line is unchanged.
Verdict: review
Grade: D (60/100)
Absence of findings is not proof of safety.
ironheights 0.9.0 | demo | grade D (60/100) | verdict review | 1 finding
Badge: [ironheights 0.9.0: grade D (60/100), verdict review](https://github.com/Frank-Masciopinto/ironheights) — Absence of findings is not proof of safety.The badge line is text you can paste into a README. It does not load an image from a badge service.
Dependencies unscanned
The scan reads the skill's own files. When a skill installs a package from a registry (a metadata.<agent>.install entry in SKILL.md frontmatter of kind node, npm, uv, pip, brew, go, cargo, apt, or an install command in SKILL.md such as npm install -g, npx, pip install, uvx, brew install, go install), that package's code was not scanned. Each one is listed as dependency unscanned: with the exact package name, the version as written, and whether it is pinned to an exact version. The grade then reads, for example, A (100/100), skill files only: 1 dependency unscanned, so a wrapper skill no longer gets a bare clean A. The verdict and exit code are unchanged. JSON reports carry the list as unscannedDependencies.
scan --check-registry also asks registry.npmjs.org and pypi.org whether each npm and PyPI install name exists. A name the registry does not know (HTTP 404) is a high IH-DEP-001 finding: anyone could register it. Only a 404 counts. Offline, a timeout, a rate limit or any other status is reported as not checked, never as missing. Nothing about the skill is sent beyond the package name in the URL.
Docs examples
IH-PRIV-001 (sudo, broad chmod, disabling OS protections) and IH-FS-001 (path traversal) hits inside a fenced code block in a Markdown file are graded one step lower than the same line in a shipped script, with a Docs example note: the docs tell a person to run it, a script runs it. Remote-execution, injection, exfiltration and credential rules keep full severity in docs. A line that names one nearby file (at most two ../) and says it is only read, with no write, delete, walk, wildcard, network verb or sensitive target, is an info IH-FS-001 finding.
Rescan on update
A scan covers the copy on disk. ironheights scan --changed <skills folder> (or --all) rescans only skills that are new or whose content hash or SKILL.md version changed since their last scan, and prints why ("version changed (1.12.0 -> 1.13.0)"). The hash and version of each scanned skill are kept in ~/.ironheights/scan-state.json. When nothing changed, it prints "Nothing to rescan." and exits 0.
ironheights watch [paths...] (or watch --all) runs that check every 60 seconds (--interval <seconds>, minimum 5) until stopped. watch --once checks once and exits, which suits cron or a post-update hook. The exit code is the worst seen.
Findings since a baseline
scan --since-baseline compares the scan with a saved result and lists only findings that are new. Omitted findings are counted under "Omitted as pre-existing" and are not removed from the grade. The exit code follows the new findings, so an update that adds nothing new exits 0 even when the grade is still D or F.
With no path, the flag reads ~/.ironheights/baseline.json (or $IRONHEIGHTS_HOME/baseline.json). That file is the integrity baseline from baseline create. A finding is pre-existing when the file's bytes still match the saved hash. A changed or new file reports every finding in that file. If treeHash does not match the file hashes, the scan stops with exit 64 and hides nothing.
You can also pass a previous --json report. A finding is pre-existing when the skill name, rule id, file, and evidence match. Line numbers are ignored. Save that report from a scan that did not use --since-baseline.
ironheights scan ./skill --since-baseline
ironheights scan ./skill --json > previous.json
ironheights scan ./skill --since-baseline previous.jsonPut the skill path before --since-baseline when you omit the file, so the flag does not take the path as its argument.
HTML report
--html report.html writes one HTML file and still prints the text report. --format html prints the HTML to stdout. The file contains its CSS, no scripts, and a content security policy of default-src 'none'. Skill names, paths, and evidence are escaped. The page does not request anything from the network.
Suppressions
A suppression needs a reason of at least 8 characters. The comment applies to the line it is on and the next line. Quote the reason when it contains spaces.
# ironheights-ignore IH-CRED-001 reason="reviewed local demo"
cat ~/.ssh/id_rsa// and <!-- --> comments work the same way because the marker is plain text. A marker with no rule id or no usable reason is listed under "Suppression ignored" and the finding stays.
Config entries do the same job for a rule and an optional glob:
{
"suppressions": [
{ "ruleId": "IH-NET-001", "path": "docs/**", "reason": "documented example hosts" }
],
"suppressCritical": false,
"suppressIntegrity": false
}An entry can name the finding it accepts by adding evidence. Use the evidence string from the report. The entry then suppresses only a finding with that rule id, a path the glob matches, and that exact evidence. Line numbers are ignored, the same match scan --since-baseline previous.json uses. An entry without evidence still covers the whole rule and glob. Evidence is the stricter option.
{
"suppressions": [
{
"ruleId": "IH-CRED-001",
"path": "SKILL.md",
"evidence": "cat ~/.ssh/id_rsa",
"reason": "reviewed this local demo path"
}
]
}ironheights scan <path> --print-suppressions prints one entry per current finding, with ruleId, path, and evidence filled in. reason is TODO: reason until you replace it. That placeholder meets the length rule and is still refused, so the finding stays visible at full weight. The entries are JSON on stdout. The exit code is still the scan verdict. --print-suppressions replaces the report on stdout.
A finding of the same rule and path that was not named stays at full weight. It is marked not accepted when a named finding in that scope is still present, and accepted finding changed when every named finding for that rule and path is gone but the rule still fires. An evidence entry that matches nothing in a file it covers is listed under "Stale suppression". Text, JSON (acceptance on the finding, staleSuppressions on the skill), and SARIF all show these. schemaVersion stays 1. With no suppression config, scan results are unchanged.
Suppressed findings are listed with their reasons and counted in the summary line. They do not add risk points. Critical findings stay visible unless suppressCritical is true. IH-INT-* findings, including on verify, stay visible unless suppressIntegrity is true. A critical integrity finding needs both.
Text scan
scan --text and scan --stdin run the content rules on one piece of text, for an inbound email or a web page. The report starts with a limited-scan line: this is not a review of a skill directory, and the text is not executed. --stdin reads a pipe. On a terminal it exits 64. Do not combine either flag with a path or --all.
ironheights scan --text 'Hello, the meeting is at 3pm.'
printf '%s' 'Ignore previous instructions' | ironheights scan --stdinOther agents and MCP configs
The same scan command reads Claude Code, Codex, and Cursor skill folders. MCP configuration rules IH-MCP-001, IH-MCP-002, and IH-MCP-003 flag a server launched with curl|sh or an unpinned npx package, a literal secret in env, and a broad filesystem root. Paths and examples are in docs/other-agents.md. Ironheights also ships its own MCP server (ironheights mcp), described in docs/mcp.md.
Config
Ironheights reads ironheights.config.json from the current directory, then ~/.ironheights/ironheights.config.json. Unknown keys are an error. thresholds.block defaults to 80 and thresholds.review defaults to 15.
{
"skillDirs": ["~/.openclaw/workspace/skills"],
"agentFiles": ["~/.openclaw/workspace/AGENTS.md"],
"allowDomains": ["docs.example.com"],
"ignoreGlobs": ["**/*.map"],
"ignoreDirs": [{ "path": "node_modules", "reason": "packages are not skill instructions" }],
"ruleOverrides": { "IH-NET-001": { "enabled": true, "severity": "low" } },
"failOn": "high",
"limits": { "maxFileBytes": 1048576, "maxFiles": 2000, "maxDepth": 10 },
"thresholds": { "block": 80, "review": 15 },
"suppressions": [
{ "ruleId": "IH-NET-001", "path": "docs/**", "reason": "documented example hosts" },
{
"ruleId": "IH-CRED-001",
"path": "SKILL.md",
"evidence": "cat ~/.ssh/id_rsa",
"reason": "reviewed this local demo path"
}
],
"suppressCritical": false,
"suppressIntegrity": false,
"llm": { "url": "http://127.0.0.1:11434", "model": "llama3.2", "api": "ollama" }
}ignoreDirs acknowledges .git or node_modules so those directories stay listed and do not make the scan incomplete. Each entry needs a reason of at least 8 characters. dist is scanned either way. ignoreGlobs drops matching paths with no finding. The default list is empty. See docs/limits.md.
An allowlist entry matches that host and its subdomains. The built-in list includes example.com, example.org, example.net, localhost, the loopback addresses 127.0.0.1 and ::1, GitHub, npm, PyPI, and openclaw.ai.
llm sets the model server used by scan --llm and review. It does not turn the review on, and it cannot grant consent to send skill text off the machine. Unknown keys are still an error, including a consent key under llm.
JSON output
--json prints a document with schemaVersion 1. New fields below are additive. Consumers that ignore unknown keys keep working, so schemaVersion stays 1. Evidence-bound drift adds acceptance on a finding and staleSuppressions on a skill. Both are omitted when they do not apply.
{
"schemaVersion": 1,
"tool": { "name": "ironheights", "version": "0.9.0" },
"scannedAt": "2026-10-09T00:00:00.000Z",
"verdict": "no-findings",
"skippedFileCount": 0,
"grade": {
"letter": "A",
"score": 100,
"disclaimer": "Absence of findings is not proof of safety."
},
"scanMode": "directory",
"summary": "ironheights 0.3.0 | no skills | grade A (100/100) | verdict no-findings | 0 findings",
"badge": "[ironheights 0.3.0: grade A (100/100), verdict no-findings](https://github.com/Frank-Masciopinto/ironheights) — Absence of findings is not proof of safety.",
"skills": []
}grade.score is omitted when grade.letter is incomplete. A text scan sets scanMode to text and adds scanLimit. --since-baseline adds sinceBaseline on the document and on each skill, including omitted. Each skill also has suppressed, suppressedCount, suppressionRefused, and suppressionRefusedCount. When a cached advisory feed was checked, the document also has advisoryFeed with entries and generatedAt.
llmReview is present only after scan --llm or review. A normal scan does not include that key. advisory is always true there. The grade and verdict stay the deterministic result, including an IH-ADV-001 match.
Each skill has skillName, root, filesScanned, filesSkipped, skippedFileCount, findings, score, and verdict. skippedFileCount is the number of files the scan did not read. filesSkipped lists each path and the reason. skippedDirectories lists .git and node_modules directories that were not entered. An explicit flag means ignoreDirs acknowledged that directory. A finding has ruleId, severity, confidence, file, optional line and column, evidence, message, remediation, and references (owaspLlm, owaspAgentic, mitreAtlas). With --since-baseline, score and verdict describe the new findings. The grade still describes the full scan, including an IH-ADV-001 match. --sarif writes SARIF 2.1.0. The run properties include skippedFileCount, gradeLetter, disclaimer, and gradeScore when the scan is complete. Rule properties and tags include the same references. An IH-LLM-001 result sets properties.advisory to true. tool.driver.informationUri is https://github.com/Frank-Masciopinto/ironheights.
Scoring is critical 100, high 40, medium 15, low 5, info 0. The verdict is block when any finding is critical or the score is at least 80. It is review when any finding is high or medium, or the score is at least 15. It is incomplete when files were skipped and the score would otherwise be no-findings. Otherwise it is no-findings.
Rules
The full list is generated in docs/rules.md, including OWASP LLM Top 10 2025, OWASP Agentic Top 10 2026, and MITRE ATLAS ids. none means no defensible mapping was recorded. P0 rules cover remote shells, prerequisite installs, undeclared hosts, credential paths, hard-coded secrets, hard-coded environment fallbacks, instruction overrides, hidden text, bundled executables, persistence, and MCP launch and secret checks. P1 rules cover dynamic execution, exfiltration shape, secrets in chat, weakened approvals, ambient credential inheritance to a child process, obfuscation, privilege bypass, filesystem tricks, skill metadata, and broad MCP filesystem roots. Integrity rules IH-INT-001 through IH-INT-004 come from verify, not from a content scan. IH-ADV-001 comes from the cached advisory feed, not from a content pattern. Config rules IH-CFG-001 through IH-CFG-009 come from audit-config, not from a skill scan. IH-LLM-001 is an advisory model note. It is not a pattern rule, and it appears only after scan --llm or review.
IH-CRED-002 uses Shannon entropy. A quoted value assigned to a key-like name is reported when it is at least 20 characters and at least 4 bits per character. Evidence keeps the first four characters and masks the rest.
IH-CRED-006 flags a credential used as the default of an environment lookup (os.getenv, os.environ.get, getenv(...) or, process.env with || or ??, and ${VAR:-...}), including when the call is split across lines. A known key prefix (AIza, sk_, sk-, ghp_, github_pat_, xox[abprs]-, AKIA, or 0x plus 64 hex) of length at least 16 is enough in a code file. Other literals need Shannon entropy of at least 3.5, length at least 20, and a secret-like variable name. A 42-character 0x address is not a secret. Markdown is not scanned. Evidence keeps the first four and last four characters.
OpenClaw locations
Verified against the OpenClaw docs for the 2026.9.3 line:
Source | Path |
Workspace skills |
|
Project agent skills |
|
Personal agent skills |
|
Managed skills |
|
Workshop skills |
|
Bundled skills |
|
Custodian skills |
|
Plugin skills | real paths linked from |
Extra directories |
|
The default workspace is ~/.openclaw/workspace. Config is $OPENCLAW_CONFIG_PATH or ~/.openclaw/openclaw.json. State moves when OPENCLAW_STATE_DIR is set. On Windows, ~ is %USERPROFILE%, so the same defaults are %USERPROFILE%\.openclaw\workspace and %USERPROFILE%\.openclaw\openclaw.json. Paths in config may use ~, ~/, ~\, %USERPROFILE%, %APPDATA%, or %LOCALAPPDATA%.
Bundled skills on Windows are also looked up under %APPDATA%\npm\node_modules\openclaw\skills, %LOCALAPPDATA%\pnpm\global\node_modules\openclaw\skills, %LOCALAPPDATA%\OpenClaw\deps\portable-node\node_modules\openclaw\skills, %ProgramFiles%\nodejs\node_modules\openclaw\skills, and the git-install wrapper %USERPROFILE%\.local\bin\openclaw.cmd. npm_config_prefix is checked on every platform.
Watched agent files default to AGENTS.md, SOUL.md, IDENTITY.md, USER.md, TOOLS.md, BOOTSTRAP.md, MEMORY.md, the memory/ directory, openclaw.json, credentials/, and .env under the OpenClaw state directory. Change the list with agentFiles.
Bundled skills are the skills/ directory in the OpenClaw package. Ironheights finds that package from OPENCLAW_BUNDLED_SKILLS_DIR, from the ~/.openclaw/bin/openclaw wrapper, from ~/.openclaw/tools/node-v*/lib/node_modules/openclaw, or from the usual global node_modules/openclaw locations. Custodian skills are the sibling custodian-skills/ directory. Plugin skills are the directories linked from ~/.openclaw/plugin-skills. doctor says when the bundled directory was not found.
SKILL.md needs YAML frontmatter with name and description. Optional fields include metadata.openclaw, homepage, user-invocable, disable-model-invocation, and the command-dispatch keys.
metadata.ironheights.allowDomains is a list of hosts that this skill is allowed to contact. IH-NET-001 skips those hosts and their subdomains for that skill only. Another skill that calls the same host still reports. The bundled OpenClaw skills do not declare their hosts, so scan --all on a stock install still reports their API hosts.
A download, install, or fetch instruction is a contact, and so is a paste site or a file-drop host. A schema link, or an official API host mentioned in prose, is not.
Integrity
baseline create writes ~/.ironheights/baseline.json with mode 0600. The file stores a sha256, size, and mode for each path, plus a treeHash of the sorted content hashes. verify reports added, modified, removed, and mode-changed files. Pass --key (or set IRONHEIGHTS_BASELINE_KEY) to sign baseline.json and to check baseline.json.sig. A bad signature is reported as a tampered baseline. See docs/baseline-signing.md.
An attacker who can write your home directory and who has the key can still replace the baseline. Keeping a copy of the key off the machine is still worth doing.
quarantine <skill> moves a skill to ~/.ironheights/quarantine/<timestamp>-<name>. That directory is mode 0700 where the operating system honors Unix modes. quarantine restore <id> moves it back. A symlink or junction inside the skill is moved with the skill and is not followed into a delete. Ironheights does not delete a skill on its own.
Runtime guard
ironheights guard evaluates a tool call against a local policy. The OpenClaw plugin in this package registers before_tool_call and can block that call when the policy mode is enforce. The default mode is monitor: it appends a redacted line to ~/.ironheights/guard.log.jsonl and does not block.
The hook runs inside the OpenClaw process. It is not a sandbox and not a process outside the agent. A skill that can edit OpenClaw config or the policy file can turn it off. Details, the threat model, and the policy file are in docs/guard.md.
ironheights guard status
ironheights guard policy check --call '{"toolName":"read","params":{"path":"~/.ssh/id_rsa"}}'guard policy check exits 0 when the call would be allowed, 1 when it would be blocked, and 64 when the policy file is invalid.
Other security tools
Many OpenClaw setups already run another scanner or guard. ironheights coexist finds them and reports overlaps, read-only and offline. It never runs the other tool and never edits a file.
ironheights coexist # text report: tools found, with the file that showed each one
ironheights coexist --json # JSON for CI and scripts
ironheights coexist --fail-on medium # exit 1 when a finding is medium or higherIt looks at OpenClaw config plugin and hook entries, plugin manifests, skill and hook folders, known state paths, PATH, and CI files in the folder you pass with --repo. Findings IH-COEX-001 to IH-COEX-011 cover two guards on before_tool_call, Ironheights enforce mode blocking another tool, two tools owning the same files, shared state paths, double network use, and a config that exempts a security tool by name. Each has a severity and a fix.
The guard plugin runs at priority 80 by default and takes a priority setting. When another guard already enforces, keep Ironheights in monitor and let one tool own blocking. scan notes a skill that carries the name of a known security tool and lowers confidence on its Markdown injection and credential findings. It does not allowlist anything and does not change the verdict.
Detection is heuristic. A renamed or unlisted tool is not found, and a name can be copied. Absence of a finding is not proof that tools will not interfere. Details and limits: docs/coexistence.md.
The advisory skill
skill/ironheights/SKILL.md tells an OpenClaw agent to run ironheights scan <path> --json, summarize the result, and stop on a block verdict until you confirm. The skill asks for no secrets. Updates are offer-only: the agent never runs ironheights update, clawhub update or a global npm install on its own. When asked, it runs ironheights update --check and shows you the update command to run yourself. Install the skill with clawhub install ironheights.
This skill is advisory. It runs inside the agent, and a hostile skill can try to bypass it. The CLI you run yourself, or a process outside the agent, is the trusted path.
What Ironheights cannot detect
Novel attacks and attacks that are heavily obfuscated in a way the current rules do not describe.
Behavior that only appears at runtime and never shows up as one of the tool calls the guard knows how to read. The guard is described in docs/guard.md. It does not see a process that is already running.
A host that is already compromised, including a baseline an attacker can rewrite.
Social engineering that never lands in a file the scanner reads.
Whether a model review is right.
scan --llmcan miss an attack and it can invent one.
No findings means the rules did not match. It does not mean the skill is safe.
Updates
Ironheights checks for a newer version at most once every 6 hours. Any command reads a local timestamp in ~/.ironheights/update-check.json; when a check is due it starts a detached background process and the command continues at once. That process asks registry.npmjs.org for the latest version of ironheights and clawhub.ai for the latest ironheights skill version, with a 3-second timeout. A network failure is ignored and never affects a scan. Results go to ~/.ironheights/update.log.
Updates are offer-only. Ironheights never installs, updates or replaces anything. When a newer version exists, ironheights update prints it and the command to run yourself:
CLI: offered only for a strictly newer stable version, and only when npm reports SLSA provenance and an sha512 integrity for that exact version:
npm install -g ironheights@<exact version>(ornpx ironheights@<exact version>).ClawHub skill:
clawhub update ironheights. Nothing is ever downgraded.
ironheights update # check now and print any update commands (changes nothing)
ironheights update --check # same; kept for compatibility
ironheights update --disable # turn automatic checks off (--enable to turn them back on)Automatic checks are off when IRONHEIGHTS_NO_UPDATE=1 is set, when CI is set, or after ironheights update --disable (writes {"autoUpdate": false} to ~/.ironheights/update-config.json).
Privacy
Scans stay on the machine. There is no telemetry. The 6-hourly version check reads two version numbers (registry.npmjs.org and clawhub.ai) and sends nothing about your skills; turn it off with IRONHEIGHTS_NO_UPDATE=1 or ironheights update --disable. fetch, safe-install, advisories update, and --online open a connection, and each one prints the URLs first. scan --llm and review are the other network path. They are off unless you pass them, and a normal scan does not call the network. The scanner reads files up to the configured size and does not extract archives. Set IRONHEIGHTS_HOME to put the baseline, the advisory cache, and the quarantine directory somewhere else.
Use in CI
After a clean scan in an interactive terminal, scan prints one line to stderr suggesting a star on ClawHub. It is never printed when CI is set, when output is not a terminal, with --quiet, --json, or --format html, or when IRONHEIGHTS_NO_STAR_HINT=1 is set. It is a printed line only; nothing is sent anywhere.
The repository root action.yml is a composite action. After a release tag, call it as Frank-Masciopinto/ironheights@v0.29.0 (pin the tag you trust). It runs npx ironheights@<version> scan and does not read secrets. Exit codes match the scanner, including 3 when a file or a .git / node_modules directory was skipped. See docs/ci.md for the pre-commit hook and a SARIF upload example.
name: ironheights
on: [pull_request]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: Frank-Masciopinto/ironheights@v0.29.0
with:
version: '0.29.0'
path: skills
fail-on: highThis repository's action workflow runs that action against a benign fixture (exit 0), a malicious fixture (exit 1 or 2), and an oversized file (exit 3). It installs npm package 0.29.0, the current latest on the registry. The action file in this commit is what the workflow executes.
Windows
GitHub Actions runs this repository on windows-latest for Node.js 20.0.0 and Node.js 24. Each Windows job runs npm run build, npm test, and npm run test:packed. That is the tested surface: the build, the unit and fixture tests, and the packed CLI smoke test. It is not a manual test of the OpenClaw Windows Hub app or a running Gateway service.
Scanned text is normalized from CRLF to LF. Directory junctions are treated like symlinks. Baseline keys under the home directory use ~/ with forward slashes so a baseline written on Windows still points at the same files when it is read back.
Node on Windows applies only the read-only attribute when Ironheights asks for mode 0600 or 0700. audit-config does not report world-readable config permissions on Windows, and it does not run icacls. A baseline key file is refused for group or world read on macOS and Linux only. OpenClaw's own openclaw security audit is the check that understands Windows ACLs. A skills directory is refused when a path component is a symlink or a junction. An 8.3 short name is the same directory, not a symlink.
Optional model review
scan --llm and review ask a language model for a second opinion. They are off unless you pass them. A scan without those commands is unchanged, and the review is never required.
The model is a reviewer of evidence the rules already extracted. It is not an authority. It can only add advisory notes with rule id IH-LLM-001. Those notes are severity info, they can be wrong, and they do not change the verdict or the exit code. A block stays a block. An empty model reply is not a clearance. --fail-on ignores IH-LLM-001.
ironheights scan ./path/to/skill --llm
ironheights scan ./path/to/skill --llm --dry-run
ironheights review ./path/to/skill --llm-url http://127.0.0.1:1234/v1 --llm-model local-modelThe default server is Ollama at http://127.0.0.1:11434 (/api/chat, model llama3.2). A loopback URL (localhost, 127.0.0.0/8, or ::1) does not need an extra flag. An OpenAI-compatible base URL works with --llm-api openai, and a URL that already ends in /v1 or /chat/completions uses that API. Pass --llm-model when the default name is wrong.
Any other host needs both --llm-consent and IRONHEIGHTS_LLM_API_KEY. The key is sent as a bearer token and is not printed. Putting the key in the URL is refused. Config cannot store consent.
Before a request is sent, Ironheights prints the exact request body to stderr. --dry-run prints that body to stdout and does not send it, and it does not write the findings report. Skill text is untrusted data: it is wrapped in delimiters, size-capped, stripped of control characters, and scrubbed for common secret shapes. The scrub can miss a secret. Do not point a remote model at a skill that contains a live one. Absolute paths on your machine are not included. Redirects are not followed.
The model must reply with one JSON object, {"notes":[...]}, and nothing else. Any other output is discarded. If the server is down, times out, or returns a bad reply, the command prints why and keeps the deterministic result and exit code. --llm-api must be ollama or openai; a different value is a usage error.
Details and the request limits are in docs/llm.md.
Benchmark
See docs/benchmark.md. The corpus in this repository is synthetic. Keep real samples on an isolated machine, on a read-only mount, and do not run them.
Releases
npm releases are published with provenance. The tag workflow also writes SHA256SUMS for the package tarball and attaches it to the GitHub release. See docs/release-verification.md.
Reporting a vulnerability
See SECURITY.md. Use a private GitHub security advisory.
License
Apache-2.0. See LICENSE.
This server cannot be deployed
Maintenance
Related MCP Connectors
AgentGuard — 20-tool AI safety MCP: policy preflight, risk scoring, audit logging, rate limits.
Pre-execution governance for AI agents. Deterministic PASS/FAIL/REVIEW verdicts, replayable proof.
The WAF for agents. Pattern-based + heuristic firewall scans prompts, RAG documents, tool argume...
Membrane for AI agents: threat intel you can check, IOC enrichment, pre-install package checks.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceA local-first security system for autonomous AI agents that provides tools for security verification, goal anchoring, and action logging. It protects against prompt injection and goal drift by enforcing user-defined rules and offering performance insights through session grading.4 npmMIT
- AlicenseAqualityBmaintenanceSecurity co-pilot for AI agents. Scans for vulnerabilities like prompt injection, infinite loops, and token bombing in AI Agents, audits MCP servers, verifies AGENTS.md governance, and generates EU AI Act compliance reports.10120 npm3Apache 2.0
- AlicenseNot gradedqualityCmaintenanceAdaptive security for AI agents: assess inputs for prompt injection, scan outputs for credential/PII leaks, teach new attack patterns to semantic memory, harden prompts, and monitor metrics. Runs locally via MCP stdio.41 PyPIApache 2.0
- AlicenseNot gradedqualityDmaintenanceRuntime safety guardrails for AI coding agents. Checks file access, validates shell commands, and scores your repo's AI safety — all via MCP.5 npm8MIT