pipelock skill-scan

August 22, 2026 ยท View on GitHub

pipelock skill-scan inventories local agent skill files, compares them to an operator-owned lock file, and flags drift, hidden instructions, direct transfer patterns, and bounded source/sink co-occurrences with file and line evidence.

This command is static defense-in-depth for files at rest. Runtime network and tool enforcement remains Pipelock proper; a clean static scan is not a runtime allow.

pipelock skill-scan
pipelock skill-scan ~/.claude/skills ~/.codex/agents
pipelock skill-scan --baseline --lock-file ./pipelock-skill-lock.yaml ~/.claude/skills
pipelock skill-scan --lock-file ./pipelock-skill-lock.yaml ~/.claude/skills
pipelock skill-scan --lock-file ./pipelock-skill-lock.yaml --update ~/.claude/skills
pipelock skill-scan --json --min-severity low ~/.claude/skills

When no path is given, Pipelock scans existing local skill directories under $XDG_CONFIG_HOME/claude/skills, ~/.claude/skills, ~/.claude/agents, and ~/.codex/agents.

What it can and cannot prove

skill-scan is a lightweight static analyzer, not a data-flow engine. It does not prove that a source value actually reaches a sink (that needs taint tracking). Findings are therefore tiered by confidence, and combinations are named honestly:

ClassSeverityWhat it means
ProvablehighLock drift (content hash change), referenced-file tamper, an oversize file past the size limit, an uninspectable path the scanner declined to read, and direct transfers where the command shows an obvious source-to-network transfer (e.g. cat ~/.aws/credentials | curl ...). Both oversize and uninspectable also bar a lock refresh.
Advisory co-occurrencemedium / lowA source and sink appear in the same code region within a few lines but no direct transfer is shown. Named *-cooccurrence. Worth a look, not an assertion of exfiltration.
InventorynoneCapability mentions (including in prose). Descriptive context, never a finding by itself.

Hidden-Unicode findings inherit their severity from pipelock scan's per-rune policy (a right-to-left override inside an instruction file is high; a leading BOM or emoji ZWJ in prose is low), so they are not always high. They are reported in normal scans plus --baseline and --update, so a hidden instruction can never be silently baselined. The explicit --inventory-only mode emits inventory only and skips all findings.

The default --min-severity is high, so a first run gates only on provable issues. Lower it to medium once you have curated co-occurrence findings (see Allowlist).

Code-context scoping

Combination findings are generated only from executable context: fenced code blocks inside Markdown skill files and the full body of scanned script files. Prose, tables, headings, and blockquotes are never read as commands, so a Markdown table cell like | CLAUDE.md | or a blockquote > note is not mistaken for a guard-file target or a filesystem write. A source and sink must also co-occur in the same region within 10 lines; mentions farther apart, or in different code blocks, are not paired.

Scanned script files are those named by relative path in SKILL.md plus every file under the skill's scripts/, bin/, and hooks/ directories (so an unreferenced script dropped into one of those directories is still scanned). Line endings are normalized (\r\n and bare \r to \n) before scanning, and any single file larger than 2 MiB is skipped with a high-severity oversize finding rather than read into memory.

A relative path counts as a reference whether or not it carries a recognized extension, so a launcher named ./bootstrap is scanned and locked exactly like ./bootstrap.sh. An extensionless reference must carry an explicit ./ or ../ marker; a bare word in prose is not treated as a file even when a file of that name exists.

Any scanned file the scanner did not read, whether a skill file or a referenced one, is reported so unknown content is never counted as clean. Two distinct high-severity kinds cover it. A file larger than the 2 MiB limit is oversize. A path the scanner declined to read for any other reason is uninspectable, naming the reason: a referenced path that exists but is not a regular file, a file the hidden-instruction pass skipped such as one containing a NUL byte, or a path that changed between validation and reading.

A symlink is not followed, because its target can point outside the skill directory, so it is reported rather than read. A file skipped by the hidden-instruction pass is likewise reported rather than dropped; that skip previously produced no finding and no skip line at all.

--baseline and --update refuse to write the lock when any path went unread, and say so. A lock records a skill's hashes together with its capability summary, so baselining an unread file would freeze an empty capability set as the reviewed inventory and every later scan would compare clean against it. A path that does not resolve at all is not reported, since a name pointing at nothing is prose or a stale document reference rather than an uninspected dependency. The distinction matters: the scanner did not inspect that dependency and does not claim it is clean. A referenced path that does not exist at all is not reported, since a name that resolves to nothing is prose or a stale document reference rather than an uninspected dependency.

Capability inventory still records mentions anywhere in the file (including prose) so the inventory stays complete.

Mechanisms

MechanismBehavior
M1 inventoryRecords capability kinds, referenced files, hashes, modes, and file:line evidence. Descriptive; assigns no severity by itself.
M2 lock driftCompares the current inventory to a lock file and flags new, changed, removed, widened, or mode-changed skills and referenced files.
M3 combinationsFlags source-and-sink combinations found in executable context, classified as direct (high) or co-occurrence (medium/low), each with a stable fingerprint.
Hidden instructionsReuses pipelock scan file detection for zero-width, bidi, tag, and control characters on the same skill files.

Flags

FlagDefaultDescription
--lock-filenoneCompare against this lock file.
--baselinefalseWrite the current inventory to --lock-file, or ./pipelock-skill-lock.yaml when no lock path is given.
--updatefalseRewrite the lock file after operator review.
--allowlistnoneYAML file of exact combo fingerprints to suppress, each with a justification.
--min-severityhighMinimum finding severity that causes a non-zero exit: high, medium, or low.
--include-depsfalseInclude dependency install commands in the capability inventory.
--jsonfalseEmit inventory and findings as JSON.
--inventory-onlyfalseEmit M1 capability inventory only; skip all findings.
--no-colorfalseAccepted for tooling compatibility; report output is not colorized.

Lock File

The lock file is stable YAML intended for an operator-controlled repo. It records skill and referenced-file hashes plus the combination fingerprints present at baseline time:

schema_version: v1
baselined_at: "2026-05-25T12:00:00Z"
emitter_host: workstation
skills:
  example-skill:
    skill_path: /home/operator/.claude/skills/example-skill/SKILL.md
    skill_sha256: <hex>
    referenced_files:
      scripts/deploy.sh:
        sha256: <hex>
        mode: "0o600"
    capabilities_summary:
      - env_read
      - network_sink
    combos:
      - kind: credential-network-cooccurrence
        severity: medium
        region: scripts/deploy.sh#1
        fingerprint: 9028cbc10406a6a9d417d2c8f2c3aa73

After --baseline, only combinations whose fingerprint is not in the lock surface as findings, so an existing skill library does not turn red on day one while a newly introduced combination still alerts. This mirrors the baseline model used by secret scanners.

Drift severities:

DriftSeverity
New skillhigh
Skill content changedhigh
Referenced-file content changedhigh
Referenced-file mode changedmedium
Skill removedlow
Capability set widenedmedium

Combinations

skill-scan flags these source-and-sink combinations. The direct variant is higher severity than the co-occurrence variant (same region, within the line window). For network transfers, "direct" is deliberately conservative: a same-line source and sink is promoted to a direct transfer only with syntax-level proof โ€” a pipe into the network command, an upload/payload argument naming a credential file (--data/-d/--form/-T/@file, or a Python requests data=/files=/json=), or a command/process substitution that reads a credential file (curl -d "$(cat ~/.aws/credentials)" ...). Otherwise it stays co-occurrence.

CombinationDirectCo-occurrence
Credential source + outbound network sinkhigh credential-exfilmedium credential-network-cooccurrence
Guard file target + filesystem writehigh guard-file-writemedium guard-file-write-cooccurrence
Shell startup file target + filesystem writemedium shell-init-writelow shell-init-write-cooccurrence
Scheduled task target + write or enablemedium scheduled-task-writelow scheduled-task-write-cooccurrence
Clipboard read + outbound network sinkmedium clipboard-network-transferlow clipboard-network-cooccurrence

Allowlist

Each finding prints a stable fingerprint. To accept an expected combination, allowlist that exact fingerprint with a required justification (and an optional review expiry):

allow:
  - fingerprint: 9028cbc10406a6a9d417d2c8f2c3aa73
    reason: "deploy receipt upload reads kube config by design"
    expires: "2026-12-01"

An entry without a justification, or one past its expiry, does not suppress: the combination resurfaces for review (fail-closed). An allowlist entry that no longer matches any combination is reported as stale so dead exceptions get removed.

expires is a UTC date and is inclusive (the entry stays valid through the end of that day, UTC). A malformed date is treated as expired (fail-closed). The fingerprint binds the skill, the combo kind, the file, and the full normalized source and sink lines, so an allowlist entry cannot suppress a behaviorally different command that happens to share a matched keyword.

Known limitations

  • No data-flow proof. A co-occurrence is a heuristic, not evidence that the source reaches the sink.
  • A transfer split across two separate code blocks, or expressed only in prose instructions, will not raise a combination finding (the capability inventory still lists both capabilities). Prose-based social engineering is the hidden-instruction and runtime injection scanners' job, not the capability detector's.
  • The source and sink pattern set is deliberate and non-exhaustive. Covered sources include cloud-credential files, SSH / kube / gcloud config, .env, .netrc, Docker config, and common secret keywords; covered network sinks are curl / wget / fetch / HTTP-client calls to an http(s) URL. Other exfiltration channels (for example nc, scp, rsync, DNS tunnelling, git push) are not currently detected as combinations.

Exit Codes

Exit codeMeaning
0No findings at or above --min-severity.
1One or more findings at or above --min-severity.
2Fatal scan, config, lock-file, path, or IO error.