Metadata-Version: 2.4
Name: bastionskill
Version: 0.10.0
Summary: Skill-poisoning scanner: detect malicious bundled code (network egress, secret theft, hook-install persistence, destructive commands) in agent skills before you install them — the code-layer that a plain grepper and a prompt-scanner miss.
Author-email: Stefano Rizzello <rizzellostefano@gmail.com>
License-Expression: MIT
Project-URL: Homepage, https://github.com/Rinkia/bastionskill
Project-URL: Repository, https://github.com/Rinkia/bastionskill
Project-URL: Issues, https://github.com/Rinkia/bastionskill/issues
Keywords: skill,agent-skill,claude-code,security,supply-chain,prompt-injection,ai-agent,skill-poisoning,agent-security
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Security
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: prompt
Requires-Dist: bastionsupply>=0.4.0; extra == "prompt"
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Dynamic: license-file

# bastionskill

Static scanner for **skill-poisoning**. Point it at an agent skill (a `SKILL.md`
plus its bundled scripts) and it inspects the *bundled executable code* for
malicious behavior — then reports the **shadow**: what the code does that the
skill's description never declared.

Agent skills bundle scripts that run when the skill is invoked, and can install
hooks that run afterward. That is an arbitrary-code-execution surface. bastionskill
is the code-layer leg of the bastion suite; the prompt-layer (malicious SKILL.md
text) is [bastionsupply](https://github.com/Rinkia/bastionsupply)'s job.

## Install

```bash
pip install bastionskill
# optional: full prompt-layer scanning via bastionsupply
pip install "bastionskill[prompt]"
```

Zero required dependencies. Python 3.10+.

## Use

```bash
bastionskill scan ./some-skill              # scan a local skill dir
bastionskill scan ~/.claude/skills          # batch-scan every skill under a dir
bastionskill scan owner/repo                # pre-flight a REMOTE skill (shallow clone, no exec)
bastionskill scan https://github.com/o/r    #   ... by full URL
bastionskill scan ./skill --prompt          # + hidden-unicode / prompt-layer
bastionskill scan ./skill --json            # machine-readable
bastionskill scan ./skill --report out.json # signable manifest (per-file hashes, verdict)
bastionskill scan ./skill --record          # append result to the local ledger
bastionskill scan ./skill --fail-on block   # CI gate: block|review|none (default: review)
bastionskill harden ./skill -o skill-policy.yaml   # v2 verdict(s), pinned by digest
bastionskill install owner/repo --to ~/.claude/skills   # install only if it passes
bastionskill install ./skill --to ~/.claude/skills --policy skill-policy.yaml
bastionskill lock ./skills -o skill.lock    # pin a folder of skills (commit skill.lock)
bastionskill verify ./skills               # CI: fail if a skill was added/removed/changed
bastionskill rules                          # every check: verdict effect, why risky, what to check
bastionskill ledger                         # list previously scanned skills + dates
```

Every reason in a report comes with a one-line **what to check** (in `--json` as
`why` / `what_to_check`, in SARIF as the rule's description and help). `bastionskill
rules` (or `rules --json`) lists every check with its effect on the verdict.

## lock / verify: gate skill updates in CI

`lock` scans every skill under a dir and, if all pass `--fail-on` (default `review`),
writes `skill.lock`: each skill's path, its digest (every file it ships, SKILL.md
included) and its verdict. Commit it. `verify` recomputes the digests and exits 1 if
any skill was **added, removed or changed**, so an update can't land unreviewed:
re-scan it, then `lock` again to accept it.

```yaml
- run: pip install bastionskill && bastionskill verify .claude/skills --lock skill.lock
```

## install: the gate that enforces the verdict

`install` copies a skill into a skills dir (`~/.claude/skills`, or a project's
`.claude/skills`) only if it passes. It stages the copy first and scans and hashes
**that copy**, so the verdict covers exactly the bytes installed.

- **No policy:** the scan decides at `--fail-on` (default `review`).
- **`--policy` (a `harden` file):** a `deny` matching the skill's name or digest
  refuses it. An `allow` counts only through its `digest`, the sha256 of every file
  installed: a reviewer can approve a skill the scanner rates `review` by flipping its
  entry to `allow`, and that approval covers those exact bytes only. If the skill
  changes (a rug-pull), the allow no longer matches and the scan decides again.
- A folder of skills installs all-or-nothing. The skill's own `.bastionskillignore` is
  not honored here (the author can't hide files from the check that admits them),
  symlinks are refused, and an existing install needs `--force`.

`harden` on a folder writes one verdict file for every skill in it; names that collide
(nested copies) are keyed by their path.

## Verdict, not a wall of severities

The scan ends in one of three verdicts, because capability is not malice — a legit
power-tool exercises network, secrets, and hooks too:

- **allow** — clean, or capability the skill legitimately has (even a lot of it).
- **review** — a poisoning *signal* a human should eyeball: a **shadow** (the code
  exercises a capability `SKILL.md` never declared), obfuscation, an opaque binary,
  or the exfil pattern (reads secrets *and* has egress).
- **block** — hard malice with no honest use: a staged-exec (decode piped to a shell).

Capability findings are reported as informational context, not as blockers.
`--fail-on` (`block|review|none`, default `review`) is the CI gate. See
[docs/github-action.md](docs/github-action.md).

`--sarif` emits SARIF 2.1.0 (file + line per finding) for GitHub code scanning:

```yaml
- run: bastionskill scan ./skill --sarif > bastionskill.sarif
  continue-on-error: true
- uses: github/codeql-action/upload-sarif@v3
  with:
    sarif_file: bastionskill.sarif
```

**Remote pre-flight** shallow-clones the repo to a temp dir, scans statically, and
deletes it. The skill's own code is never executed.

**Ledger & rug-pull.** `--record` writes each scan to `~/.bastionskill/ledger.jsonl`
(source, content hash, date, verdict). Re-scan the same source after it changes and
you get a `! DRIFT` warning — the poisoned-update vector.

## What it catches (code-layer)

| Detector | Example |
|---|---|
| **hook-install (lead)** | a script that writes a `PostToolUse` hook into `settings.json` = persistence |
| network egress | `socket.connect`, `requests.post`, `curl`/`wget`, `fetch()` |
| secret read | `~/.aws/credentials`, `id_rsa`, `.env` |
| obfuscation | `base64 -d | sh`, `eval(atob(...))` |
| remote-exec | `curl … | sh`, `bash <(curl …)`, `iwr … | iex`, `exec(requests.get(…).text)`: code fetched at run time was never scanned (review, not block: honest installers do this too) |
| dynamic exec | `exec()`, `eval()`, `getattr(m,n)()` (Python AST tier) |
| destructive | `rm -rf`, `Remove-Item -Recurse` |
| git-exfil | `git push https://…` to an explicit URL, or a push to a remote pointed at a network URL **earlier in the same file** (shell or `["git", "remote", "add", …]` argv form) |
| clipboard-read / env-dump | `pbpaste`, `Get-Clipboard`, `pyperclip.paste()`; `printenv`, `json.dumps(os.environ)`, `JSON.stringify(process.env)` (count as secret reads: with egress they become exfil-combo) |
| raw-ip-egress | a hardcoded **public** IP as a destination (`http://45.33.12.9/…`, `connect(("198.51.100.7", …))`); private/loopback ignored (review) |
| keylogger | `pynput`, `keyboard.Listener`, `GetAsyncKeyState`, `SetWindowsHookEx`, `iohook` (review) |
| lateral-tamper | writes to `CLAUDE.md`, MCP config, or other skills |
| **opaque-binary** | bundles a compiled/loadable file it can't inspect (incl. renamed binaries, magic-byte sniffed) |
| **shadow** | code exercises a capability SKILL.md never declared |

Python files get a real `ast` pass (stdlib) on top of regex, so dynamic exec /
import / attribute-built calls survive reflow. Bash and JS use regex heuristics;
shell and PowerShell commands continued over several lines (`\`, a trailing `|` /
`&&` / `||`, a backtick) are joined and read as one command.

Findings are reported **regardless of dead-code or `if False:` / env-flag guards** —
the scanner reads source, it never runs it, and malware hides behind guards too.

## How it fits the suite

- Prompt-layer → [bastionsupply](https://github.com/Rinkia/bastionsupply) (dependency, optional extra)
- Runtime gating → bastiongate
- `harden` emits a `policy_version: 2` skill verdict (allow/deny, the checks and
  capabilities that tripped it, and a content `digest`) under the `skill:` block;
  `bastionskill install --policy` enforces it. agentbastion and bastiongate don't run
  skills: they load the file, ignore the block, and get no tool policy from it.

## Test fixture

The inert, defanged demo skill this scanner is built against lives at
[Rinkia/poisoned-skill-demo](https://github.com/Rinkia/poisoned-skill-demo) — a
"markdown formatter" that actually exfiltrates and installs a hook. See its
`EXPECTED.md` for the findings oracle.

```bash
bastionskill scan Rinkia/poisoned-skill-demo   # scan the demo straight off GitHub
```

## License

MIT © 2026 Stefano Rizzello
