ironheights 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,11 +2,11 @@
2
2
 
3
3
  Ironheights is a local-first scanner and integrity monitor for [OpenClaw](https://docs.openclaw.ai) skills. 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.
4
4
 
5
- It does not call the network, does not send telemetry, and does not run the files it scans.
5
+ It does not send telemetry, and it does not run the files it scans. It does not call the network unless you run `fetch`, `safe-install`, `advisories update`, `--online`, `scan --llm`, or `review`.
6
6
 
7
7
  ## Install
8
8
 
9
- Ironheights requires Node.js 20 or newer.
9
+ Ironheights requires Node.js 20 or newer on macOS, Linux, and Windows.
10
10
 
11
11
  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.
12
12
 
@@ -60,16 +60,16 @@ Verdict: review
60
60
  Absence of findings is not proof of safety.
61
61
  ```
62
62
 
63
- | Exit | Meaning |
64
- | ---- | ---------------------------------------------------------------------------------------------- |
65
- | 0 | No findings |
66
- | 1 | Review |
67
- | 2 | Block |
68
- | 3 | Incomplete. A file was skipped, and the files that were scanned did not reach review or block. |
69
- | 64 | Usage or config error |
70
- | 70 | Internal error |
63
+ | Exit | Meaning |
64
+ | ---- | ----------------------------------------------------------------------------------------------------------------------- |
65
+ | 0 | No findings |
66
+ | 1 | Review |
67
+ | 2 | Block |
68
+ | 3 | Incomplete. A file was skipped, or `.git` / `node_modules` was not entered, and the rest did not reach review or block. |
69
+ | 64 | Usage or config error |
70
+ | 70 | The command stopped: an internal error, a failed network request, or a rejected signature. |
71
71
 
72
- `--fail-on low|medium|high|critical` returns `0` when every finding is below that severity and no file was skipped. A skipped file still returns `3` unless you pass `--allow-skipped`. That flag keeps the skipped-file warning and returns the finding verdict (`0`, `1`, or `2`) instead.
72
+ `--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](docs/limits.md).
73
73
 
74
74
  A scan that skipped a file looks like this:
75
75
 
@@ -85,19 +85,149 @@ Absence of findings is not proof of safety.
85
85
  ## Commands
86
86
 
87
87
  ```text
88
- ironheights scan <path> [--all] [--json] [--sarif <file>] [--md <file>] [--fail-on <severity>] [--config <file>] [--allow-skipped] [--no-color] [--quiet]
89
- ironheights baseline create|update|show
90
- ironheights verify
88
+ ironheights scan <path> [--all] [--json] [--sarif <file>] [--md <file>] [--html <file>] [--format text|json|html] [--since-baseline [file]] [--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]
89
+ ironheights review <path> [the same flags as scan]
90
+ ironheights fetch <owner>/<slug>[@version] [--out <dir>] [--json] [--sarif <file>] [--md <file>] [--fail-on <severity>] [--config <file>] [--allow-skipped] [--no-color] [--quiet]
91
+ ironheights safe-install <owner>/<slug>[@version] [--dir <skills-dir>] [--accept-review] [--out <dir>] [--json] [--config <file>] [--no-color] [--quiet]
92
+ ironheights advisories update
93
+ ironheights advisories show
94
+ ironheights baseline create|update|show [--key <file>]
95
+ ironheights verify [--key <file>]
91
96
  ironheights quarantine <skill>
92
97
  ironheights quarantine restore <id>
93
98
  ironheights rules list
94
99
  ironheights rules show <id>
95
100
  ironheights doctor
101
+ ironheights doctor audit
102
+ ironheights audit-config [--json] [--sarif <file>] [--md <file>] [--fail-on <severity>] [--openclaw-config <file>]
96
103
  ironheights bench <corpusDir> [--external <file.json>]
97
- ironheights --online
104
+ ironheights guard status [--policy <file>] [--config <file>] [--json]
105
+ ironheights guard policy check [--policy <file>] [--config <file>] [--call <json>] [--call-file <file>] [--json]
106
+ ironheights guard log [--tail <n>] [--policy <file>] [--json]
107
+ ironheights --online <command>
98
108
  ```
99
109
 
100
- `--online` prints `not implemented in MVP` and does nothing else. `--all` scans the skill directories from config, or the OpenClaw locations below when config does not set `skillDirs`.
110
+ `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`.
111
+
112
+ ## Fetch and safe-install
113
+
114
+ `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](docs/fetch.md).
115
+
116
+ ```text
117
+ fetch ada/notes@1.0.0
118
+ This command contacts the network. ironheights does not call the network unless you run fetch, safe-install, advisories update, --online, scan --llm, or review.
119
+
120
+ Requesting:
121
+ GET https://clawhub.ai/api/v1/skills/notes?owner=ada
122
+ GET https://clawhub.ai/api/v1/skills/notes/versions/1.0.0?owner=ada
123
+
124
+ Requesting:
125
+ GET https://clawhub.ai/api/v1/skills/notes/file?owner=ada&path=SKILL.md&version=1.0.0
126
+
127
+ notes
128
+ No findings
129
+
130
+ Verdict: no-findings
131
+ Absence of findings is not proof of safety.
132
+ Staged 1 files at /tmp/ironheights-fetch-.../notes
133
+ ```
134
+
135
+ `safe-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.
136
+
137
+ ## Advisory feed
138
+
139
+ `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](docs/advisories.md).
140
+
141
+ ```text
142
+ advisories update
143
+ This command contacts the network. ironheights does not call the network unless you run fetch, safe-install, advisories update, --online, scan --llm, or review.
144
+
145
+ Requesting:
146
+ GET https://ironheights.dev/advisories.json
147
+ GET https://ironheights.dev/advisories.json.sig
148
+
149
+ Advisory feed signature verified.
150
+ Cached 2 advisories generated at 2026-10-10T00:00:00.000Z
151
+ Wrote /home/ada/.ironheights/advisories.json
152
+ ```
153
+
154
+ The 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.
155
+
156
+ `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`](https://docs.openclaw.ai/cli/security). See [docs/audit-config.md](docs/audit-config.md).
157
+
158
+ ## Grade
159
+
160
+ 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.
161
+
162
+ 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.
163
+
164
+ ```text
165
+ Verdict: review
166
+ Grade: D (60/100)
167
+ Absence of findings is not proof of safety.
168
+ ironheights 0.2.0 | demo | grade D (60/100) | verdict review | 1 finding
169
+ Badge: [ironheights 0.2.0: grade D (60/100), verdict review](https://github.com/Frank-Masciopinto/ironheights) — Absence of findings is not proof of safety.
170
+ ```
171
+
172
+ The badge line is text you can paste into a README. It does not load an image from a badge service.
173
+
174
+ ## Findings since a baseline
175
+
176
+ `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.
177
+
178
+ 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.
179
+
180
+ 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`.
181
+
182
+ ```bash
183
+ ironheights scan ./skill --since-baseline
184
+ ironheights scan ./skill --json > previous.json
185
+ ironheights scan ./skill --since-baseline previous.json
186
+ ```
187
+
188
+ Put the skill path before `--since-baseline` when you omit the file, so the flag does not take the path as its argument.
189
+
190
+ ## HTML report
191
+
192
+ `--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.
193
+
194
+ ## Suppressions
195
+
196
+ 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.
197
+
198
+ ```text
199
+ # ironheights-ignore IH-CRED-001 reason="reviewed local demo"
200
+ cat ~/.ssh/id_rsa
201
+ ```
202
+
203
+ `//` 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.
204
+
205
+ Config entries do the same job for a rule and an optional glob:
206
+
207
+ ```json
208
+ {
209
+ "suppressions": [
210
+ { "ruleId": "IH-NET-001", "path": "docs/**", "reason": "documented example hosts" }
211
+ ],
212
+ "suppressCritical": false,
213
+ "suppressIntegrity": false
214
+ }
215
+ ```
216
+
217
+ 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.
218
+
219
+ ## Text scan
220
+
221
+ `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`.
222
+
223
+ ```bash
224
+ ironheights scan --text 'Hello, the meeting is at 3pm.'
225
+ printf '%s' 'Ignore previous instructions' | ironheights scan --stdin
226
+ ```
227
+
228
+ ## Other agents and MCP configs
229
+
230
+ 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](docs/other-agents.md). Ironheights does not ship an MCP server. See [docs/ideas.md](docs/ideas.md).
101
231
 
102
232
  ## Config
103
233
 
@@ -108,38 +238,61 @@ Ironheights reads `ironheights.config.json` from the current directory, then `~/
108
238
  "skillDirs": ["~/.openclaw/workspace/skills"],
109
239
  "agentFiles": ["~/.openclaw/workspace/AGENTS.md"],
110
240
  "allowDomains": ["docs.example.com"],
111
- "ignoreGlobs": ["**/.git/**"],
241
+ "ignoreGlobs": ["**/*.map"],
242
+ "ignoreDirs": [{ "path": "node_modules", "reason": "packages are not skill instructions" }],
112
243
  "ruleOverrides": { "IH-NET-001": { "enabled": true, "severity": "low" } },
113
244
  "failOn": "high",
114
245
  "limits": { "maxFileBytes": 1048576, "maxFiles": 2000, "maxDepth": 10 },
115
- "thresholds": { "block": 80, "review": 15 }
246
+ "thresholds": { "block": 80, "review": 15 },
247
+ "suppressions": [
248
+ { "ruleId": "IH-NET-001", "path": "docs/**", "reason": "documented example hosts" }
249
+ ],
250
+ "suppressCritical": false,
251
+ "suppressIntegrity": false,
252
+ "llm": { "url": "http://127.0.0.1:11434", "model": "llama3.2", "api": "ollama" }
116
253
  }
117
254
  ```
118
255
 
256
+ `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](docs/limits.md).
257
+
119
258
  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`.
120
259
 
260
+ `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`.
261
+
121
262
  ## JSON output
122
263
 
123
- `--json` prints a document with `schemaVersion` `1`:
264
+ `--json` prints a document with `schemaVersion` `1`. New fields below are additive. Consumers that ignore unknown keys keep working, so `schemaVersion` stays `1`.
124
265
 
125
266
  ```json
126
267
  {
127
268
  "schemaVersion": 1,
128
- "tool": { "name": "ironheights", "version": "0.1.5" },
269
+ "tool": { "name": "ironheights", "version": "0.2.0" },
129
270
  "scannedAt": "2026-10-09T00:00:00.000Z",
130
271
  "verdict": "no-findings",
131
272
  "skippedFileCount": 0,
273
+ "grade": {
274
+ "letter": "A",
275
+ "score": 100,
276
+ "disclaimer": "Absence of findings is not proof of safety."
277
+ },
278
+ "scanMode": "directory",
279
+ "summary": "ironheights 0.2.0 | no skills | grade A (100/100) | verdict no-findings | 0 findings",
280
+ "badge": "[ironheights 0.2.0: grade A (100/100), verdict no-findings](https://github.com/Frank-Masciopinto/ironheights) — Absence of findings is not proof of safety.",
132
281
  "skills": []
133
282
  }
134
283
  ```
135
284
 
136
- 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. A finding has `ruleId`, `severity`, `confidence`, `file`, optional `line` and `column`, `evidence`, `message`, and `remediation`. `--sarif` writes SARIF 2.1.0. The run `properties.skippedFileCount` is the same total, and `tool.driver.informationUri` is `https://github.com/Frank-Masciopinto/ironheights`.
285
+ `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`.
286
+
287
+ `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.
288
+
289
+ 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`.
137
290
 
138
291
  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`.
139
292
 
140
293
  ## Rules
141
294
 
142
- The full list is generated in [docs/rules.md](docs/rules.md). P0 rules cover remote shells, prerequisite installs, undeclared hosts, credential paths, hard-coded secrets, instruction overrides, hidden text, bundled executables, and persistence. P1 rules cover dynamic execution, exfiltration shape, secrets in chat, weakened approvals, obfuscation, privilege bypass, filesystem tricks, and skill metadata. Integrity rules `IH-INT-001` through `IH-INT-004` come from `verify`, not from a content scan.
295
+ The full list is generated in [docs/rules.md](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, 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, 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`.
143
296
 
144
297
  `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.
145
298
 
@@ -159,7 +312,9 @@ Verified against the OpenClaw docs for the 2026.9.3 line:
159
312
  | Plugin skills | real paths linked from `~/.openclaw/plugin-skills` |
160
313
  | Extra directories | `skills.load.extraDirs` in `~/.openclaw/openclaw.json` |
161
314
 
162
- The default workspace is `~/.openclaw/workspace`. Config is `$OPENCLAW_CONFIG_PATH` or `~/.openclaw/openclaw.json`. State moves when `OPENCLAW_STATE_DIR` is set.
315
+ 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%`.
316
+
317
+ 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.
163
318
 
164
319
  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`.
165
320
 
@@ -173,11 +328,24 @@ A download, install, or fetch instruction is a contact, and so is a paste site o
173
328
 
174
329
  ## Integrity
175
330
 
176
- `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.
331
+ `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](docs/baseline-signing.md).
332
+
333
+ 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.
334
+
335
+ `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.
336
+
337
+ ## Runtime guard
177
338
 
178
- An attacker who can write your home directory can edit the baseline. Signing it, or keeping a copy off the machine, is future work. See [docs/ideas.md](docs/ideas.md).
339
+ `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.
179
340
 
180
- `quarantine <skill>` moves a skill to `~/.ironheights/quarantine/<timestamp>-<name>`. That directory is mode `0700`. `quarantine restore <id>` moves it back. Ironheights does not delete a skill on its own.
341
+ 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](docs/guard.md).
342
+
343
+ ```bash
344
+ ironheights guard status
345
+ ironheights guard policy check --call '{"toolName":"read","params":{"path":"~/.ssh/id_rsa"}}'
346
+ ```
347
+
348
+ `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.
181
349
 
182
350
  ## The advisory skill
183
351
 
@@ -188,20 +356,76 @@ This skill is advisory. It runs inside the agent, and a hostile skill can try to
188
356
  ## What Ironheights cannot detect
189
357
 
190
358
  - Novel attacks and attacks that are heavily obfuscated in a way the current rules do not describe.
191
- - Behavior that only appears at runtime, after a script is executed.
359
+ - 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](docs/guard.md). It does not see a process that is already running.
192
360
  - A host that is already compromised, including a baseline an attacker can rewrite.
193
361
  - Social engineering that never lands in a file the scanner reads.
362
+ - Whether a model review is right. `scan --llm` can miss an attack and it can invent one.
194
363
 
195
364
  No findings means the rules did not match. It does not mean the skill is safe.
196
365
 
197
366
  ## Privacy
198
367
 
199
- Scans stay on the machine. There is no telemetry and no default network call. The scanner reads files up to the configured size and does not extract archives. Set `IRONHEIGHTS_HOME` to put the baseline and quarantine directory somewhere else.
368
+ Scans stay on the machine. There is no telemetry. `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.
369
+
370
+ ## Use in CI
371
+
372
+ The repository root `action.yml` is a composite action. After a release tag, call it as `Frank-Masciopinto/ironheights@v0.2.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](docs/ci.md) for the pre-commit hook and a SARIF upload example.
373
+
374
+ ```yaml
375
+ name: ironheights
376
+ on: [pull_request]
377
+ jobs:
378
+ scan:
379
+ runs-on: ubuntu-latest
380
+ steps:
381
+ - uses: actions/checkout@v4
382
+ - uses: Frank-Masciopinto/ironheights@v0.2.0
383
+ with:
384
+ version: '0.2.0'
385
+ path: skills
386
+ fail-on: high
387
+ ```
388
+
389
+ This repository's [action workflow](.github/workflows/action.yml) 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.1.5`, the last release on the registry, until `0.2.0` is published. The action file in this commit is what the workflow executes.
390
+
391
+ ## Windows
392
+
393
+ 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.
394
+
395
+ 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.
396
+
397
+ 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.
398
+
399
+ ## Optional model review
400
+
401
+ `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.
402
+
403
+ 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`.
404
+
405
+ ```bash
406
+ ironheights scan ./path/to/skill --llm
407
+ ironheights scan ./path/to/skill --llm --dry-run
408
+ ironheights review ./path/to/skill --llm-url http://127.0.0.1:1234/v1 --llm-model local-model
409
+ ```
410
+
411
+ The 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.
412
+
413
+ 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.
414
+
415
+ 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.
416
+
417
+ 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.
418
+
419
+ Details and the request limits are in [docs/llm.md](docs/llm.md).
200
420
 
201
421
  ## Benchmark
202
422
 
203
423
  See [docs/benchmark.md](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.
204
424
 
425
+ ## Releases
426
+
427
+ 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](docs/release-verification.md).
428
+
205
429
  ## Reporting a vulnerability
206
430
 
207
431
  See [SECURITY.md](SECURITY.md). Use a private GitHub security advisory.
package/SECURITY.md CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  | Version | Supported |
6
6
  | ------- | --------- |
7
+ | 0.2.x | yes |
7
8
  | 0.1.x | yes |
8
9
 
9
10
  ## Reporting a vulnerability
@@ -14,4 +15,16 @@ Give the maintainers time to ship a fix before writing about the issue in public
14
15
 
15
16
  Do not send copies of real secrets or live malware. A synthetic snippet that matches the pattern is enough.
16
17
 
17
- Ironheights does not phone home. A report you send to GitHub is the only disclosure channel.
18
+ Ironheights does not phone home during `scan`. `fetch`, `safe-install`, `advisories update`, and `--online` are explicit network commands. `scan --llm` and `review` are the other network path, and they are off unless you pass them. Each download command prints the URL before connecting, talks only to `clawhub.ai` or `ironheights.dev` over HTTPS, and refuses redirects. A report you send to GitHub is the disclosure channel. The advisory feed is a signed document you choose to download. It is not telemetry.
19
+
20
+ ## Advisory signatures
21
+
22
+ The production advisory private key is not in this repository. The CLI rejects a feed whose signature does not match the public key pinned in `src/advisories/pinned-key.ts` (`8424b837422792656e6e7500869395a5afe64bf163baa63acea83f1e97cc2b6c`). See [docs/advisories.md](docs/advisories.md).
23
+
24
+ ## Runtime guard
25
+
26
+ The optional OpenClaw plugin logs tool calls on the machine and, in `enforce` mode, can block four kinds of call. The plugin runs inside the OpenClaw process. A caller who can change OpenClaw's config, the plugin file, or `~/.ironheights/guard-policy.json` can disable it. Treat that as defense in depth. The limits are in [docs/guard.md](docs/guard.md). The guard does not send the audit log anywhere.
27
+
28
+ ## Optional model review
29
+
30
+ `scan --llm` and `review` are off unless you pass them. A localhost model server stays on the machine. Any other host needs `--llm-consent` and `IRONHEIGHTS_LLM_API_KEY`. Ironheights redacts common secret shapes before sending and prints the request body first. Redaction can miss a secret. Do not send a skill that contains a live secret to a remote model. Model notes are advisory and can be wrong. A report to GitHub should still use a synthetic snippet, not a live secret.