From process-probe
Inspect a running process — its command line, environment variables, file descriptors, and network connections — through the credential-safe `process-probe` command (subcommands `env-keys`, `env-values`, `cmdline`, `info`, `fds`, `network`) that this plugin puts on your PATH. Never read /proc/PID/environ or /proc/PID/cmdline directly; the proc-probe-guard.sh PreToolUse hook blocks those reads across Bash, Read, Edit, Write, and NotebookEdit. The helpers redact values when either the variable name matches a sensitive-name keyword (password, secret, token, key, …) or the value matches a known credential shape (sk-…, ghp_…, AKIA…, JWT, PEM private key) or a length+entropy gate. A two-stage env workflow (list keys first, then explicitly request specific values) is the canonical entry point. Use this skill for any process probing — debugging a hang, diagnosing why something is stuck, checking what arguments or environment a daemon was started with, exploring file descriptors or network connections — even when the specific operation isn't itself credential-sensitive (the goal is one central, judgment-call-free place for all process probing).
How this skill is triggered — by the user, by Claude, or both
Slash command
/process-probe:process-probeThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Reading `/proc/<pid>/environ` directly is a credential-exposure landmine. Most production daemons inherit credentials from their launching shell — database passwords, OAuth tokens, API keys, encryption passphrases — and those values sit in the process's environment for its lifetime. Anything that prints `/proc/<pid>/environ` (whether `cat`, `tr`, `xargs`, or a Python `open(...).read()`) prints ...
Reading /proc/<pid>/environ directly is a credential-exposure landmine. Most production daemons inherit credentials from their launching shell — database passwords, OAuth tokens, API keys, encryption passphrases — and those values sit in the process's environment for its lifetime. Anything that prints /proc/<pid>/environ (whether cat, tr, xargs, or a Python open(...).read()) prints those credentials.
Ad-hoc redaction is unsafe. A one-line sed-style filter is one wrong character away from emitting the raw values: subtle regex bugs (anchoring the keyword on the wrong side of =, treating _ as a word boundary, forgetting case-insensitivity, etc.) cause the redaction to silently no-op. The output looks fine until you read it carefully — by which time the secrets are already in your terminal scrollback, your CI logs, or, for an AI agent, the conversation transcript and any session log file.
The lesson: blacklist redaction fails open. The correct posture is whitelist redaction (only emit known-safe values; redact anything that looks credential-shaped) plus a hook that blocks direct /proc/*/environ and /proc/*/cmdline reads, so the unsafe path can't be taken accidentally even when someone reaches for cat out of habit. This skill is the whitelist side; the proc-probe-guard.sh hook is the block side.
Use these helpers whenever investigating a running process — debugging, diagnosing a hang, checking startup state, etc.
Do not invoke raw cat /proc/<pid>/environ or cat /proc/<pid>/cmdline or equivalent reads — the PreToolUse hook will block them. If a genuinely-novel probe is needed that the helpers don't cover, extend the helpers (and add a test) rather than reaching past them.
All take a single <pid> argument unless noted. All exit non-zero on missing-process / permission-denied; stderr explains.
All subcommands emit JSON or JSONL. The convention across the suite: present fields signal state; absent fields are not "null", they're "not applicable here".
| Subcommand | Output | What it does |
|---|---|---|
process-probe env-keys <pid> | JSONL: {"name": "..."} per line | Sorted list of env var names the process inherited. No values. |
process-probe env-values <pid> NAME... | JSONL: one of three shapes per requested name | Three mutually-exclusive states: {"name": "X", "value": "v"} (emitted), {"name": "X", "redacted": "REASON"} (held back; REASON is sensitive name or sensitive value), or {"name": "X", "unset": true} (not in env). Per-name override: --unsafe-show NAME makes that name print with value even if it would have been redacted. |
process-probe cmdline <pid> | JSONL: {"index": N, ...} per argv element | Each element gets value or redacted (or both, for the partial-redact case): full-emit {"index": 0, "value": "my-app"}; next-token redact {"index": 2, "redacted": "secret flag"} (the previous token was --token/--password/…); value-shape redact {"index": 6, "redacted": "secret value"} (matches sk-…/ghp_…/AKIA…/JWT/PEM or the length+entropy gate); partial redact {"index": 7, "value": "--api-key=", "redacted": "secret flag"} for the --flag=value form (flag prefix kept, value half held). --unsafe bypasses both layers. |
process-probe info <pid> | one JSON object | ps summary: pid, state, pcpu_percent, rss_kb, user, elapsed_seconds, command, started. Username is resolved via /proc/PID/status + pwd to avoid ps's 8-char truncation. |
process-probe fds <pid> | JSONL, one object per fd | Open file descriptors: {"fd": N, "kind": "REG"|"DIR"|"CHAR"|"FIFO"|"UNIX"|"SOCKET"|"ANON"|"PIPE"|"OTHER", "target": "..."}. target is omitted for SOCKET entries (use network for per-connection info). To get counts by kind, aggregate after reading. |
process-probe network <pid> | JSONL, one object per connection | {"protocol": "tcp"|"udp", "state": "ESTAB"|"LISTEN"|…, "local": "addr:port", "peer": "addr:port", "recv_q": N, "send_q": N}. Uses ss -tunp on Linux; falls back to lsof -nP -i -a -p (which omits recv_q/send_q because lsof doesn't expose them). |
The info, fds, and network subcommands wrap ps, lsof, and ss — tools that aren't themselves credential-bearing. They live in this skill anyway for a few reasons:
process-probe" replaces "decide whether this particular question needs the safe path or whether the raw tool is fine" — a judgment call that's easy to get wrong and is the kind of mistake this skill exists to prevent.ss vs lsof, ps field-format differences, Linux vs macOS), so you can probe processes on different systems the same way.jq, or feed back into another tool.ps / ss / lsof output ever becomes sensitive, there's one place to add redaction or filtering — no need to update every call site.Examples use placeholder values — PID 12345, username alice, [email protected], IPs in the RFC 5737 documentation ranges (192.0.2.0/24, 198.51.100.0/24).
env-keys — what env vars does this process have?
$ process-probe env-keys 12345
{"name": "HOME"}
{"name": "LANG"}
{"name": "OBSIDIAN_EMAIL"}
{"name": "OBSIDIAN_PASSWORD"}
{"name": "OBSIDIAN_SYNC_DIR"}
{"name": "OBSIDIAN_VAULT_NAME"}
{"name": "OBSIDIAN_VAULT_PASSWORD"}
{"name": "PATH"}
{"name": "USER"}
env-values — read specific ones; sensitive vars auto-redact, mix is fine:
$ process-probe env-values 12345 \
OBSIDIAN_VAULT_NAME OBSIDIAN_SYNC_DIR OBSIDIAN_PASSWORD OBSIDIAN_EMAIL MISSING
{"name": "OBSIDIAN_VAULT_NAME", "value": "MyVault"}
{"name": "OBSIDIAN_SYNC_DIR", "value": "/home/alice/MyVault"}
{"name": "OBSIDIAN_PASSWORD", "redacted": "sensitive name"}
{"name": "OBSIDIAN_EMAIL", "value": "[email protected]"}
{"name": "MISSING", "unset": true}
See "Override semantics" below for the per-name --unsafe-show flag.
cmdline — argv with secret-flag and secret-shape redaction:
# A process started as: my-app --token SECRET --port 8080 sk-ant-api03-... --api-key=K positional
$ process-probe cmdline 12345
{"index": 0, "value": "my-app"}
{"index": 1, "value": "--token"}
{"index": 2, "redacted": "secret flag"}
{"index": 3, "value": "--port"}
{"index": 4, "value": "8080"}
{"index": 5, "redacted": "secret value"}
{"index": 6, "value": "--api-key=", "redacted": "secret flag"}
{"index": 7, "value": "positional"}
Index 2's redaction was triggered by the --token flag pattern (the previous element). Index 5 was caught by the value-shape heuristic spotting a positional sk-ant-… credential. Index 6 is the partial-redact form: the flag prefix (--api-key=) is kept in value, the value half is held back via redacted.
info — single JSON object:
$ process-probe info 12345
{"pid": 12345, "state": "Sl+", "pcpu_percent": 0.0, "rss_kb": 23724, "user": "alice", "elapsed_seconds": 98504, "command": "my-daemon", "started": "Tue May 12 15:26:18 2026"}
fds — JSONL, one open file descriptor per line:
$ process-probe fds 12345 | head -8
{"fd": 0, "kind": "CHAR", "target": "/dev/pts/5"}
{"fd": 1, "kind": "CHAR", "target": "/dev/pts/5"}
{"fd": 2, "kind": "CHAR", "target": "/dev/pts/5"}
{"fd": 5, "kind": "REG", "target": "/home/alice/.my-daemon/state.db"}
{"fd": 6, "kind": "ANON", "target": "anon_inode:[eventpoll]"}
{"fd": 8, "kind": "SOCKET"}
{"fd": 9, "kind": "SOCKET"}
# Aggregate by kind (consumers compute their own summaries):
$ process-probe fds 12345 | jq -r '.kind' | sort | uniq -c
3 ANON
3 CHAR
3 REG
14 SOCKET
network — JSONL, one connection per line:
$ process-probe network 12345
{"protocol": "tcp", "state": "ESTAB", "recv_q": 0, "send_q": 0, "local": "192.0.2.10:53086", "peer": "198.51.100.42:443"}
Typical investigation chain — "what is this process doing and why?" — is info → cmdline → env-keys (then env-values for specific ones) → fds and network for file-handle / connection state. Pipe through jq for filtering or projection.
When you need an env var's value, always go through both stages:
env-keys <pid> — confirms the variable is set without exposing its value. Decide which specific names you actually need.env-values <pid> NAME1 NAME2 ... — request only the names you need. Sensitive names are auto-redacted even when requested.# Stage 1
process-probe env-keys 12345
# → JSONL of {"name": "..."} — see what's there.
# Stage 2: request only what you need.
process-probe env-values 12345 OBSIDIAN_VAULT_NAME OBSIDIAN_SYNC_DIR
# → {"name": "OBSIDIAN_VAULT_NAME", "value": "MyVault"}
# {"name": "OBSIDIAN_SYNC_DIR", "value": "/home/alice/MyVault"}
# Trying to read a sensitive var (intentional):
process-probe env-values 12345 OBSIDIAN_PASSWORD
# → {"name": "OBSIDIAN_PASSWORD", "redacted": "sensitive name"}
--unsafe-show NAME is per-name, not globalBy default, every requested variable prints — sensitive ones (by name OR value, see "Secret-detection heuristics" below) show as <redacted: REASON>; everything else prints its value normally. There is no global "show everything" flag. Each individual sensitive name you want raw must be named with its own --unsafe-show:
# Default behavior — sensitive ones redact, non-sensitive ones print clean.
process-probe env-values 12345 OBSIDIAN_VAULT_NAME OBSIDIAN_PASSWORD
# {"name": "OBSIDIAN_VAULT_NAME", "value": "MyVault"}
# {"name": "OBSIDIAN_PASSWORD", "redacted": "sensitive name"}
# Bypass for ONE specific name only:
process-probe env-values 12345 OBSIDIAN_VAULT_NAME OBSIDIAN_PASSWORD --unsafe-show OBSIDIAN_PASSWORD
# {"name": "OBSIDIAN_VAULT_NAME", "value": "MyVault"}
# {"name": "OBSIDIAN_PASSWORD", "value": "<actual value>"}
# To bypass multiple, repeat the flag:
process-probe env-values 12345 --unsafe-show TOKEN_A --unsafe-show TOKEN_B
Each --unsafe-show NAME is a literal CLI argument and so appears in the transcript, making the exception auditable. There is no shorthand for "all" because that would defeat the purpose: if unfiltered access is the goal, cat /proc/<pid>/environ would be the direct route — and the hook is there to stop exactly that.
Use --unsafe-show NAME only when the operator has given explicit permission to read that specific value.
env-values redacts a variable when either axis fires — same two-axis shape used by detect-secrets, gitleaks, and truffleHog. Both axes live in _secret_heuristics.py (shared module; unit-tested in _test_heuristics.py).
Variable names containing any of these keywords (case-insensitive, with letter-only boundaries so API_TOKEN matches but MONKEY doesn't):
password, passwd, pword, pwrd, passphrase, passcode, pinsecrettoken, jwt, bearer, oauthkey (bare), and api_key, access_key, secret_key, private_key, encryption_key, signing_key, client_secretcredential, credsauthsession, cookieprivatesalt, nonce, signature, hmacsensitive, confidentialmfa, otp, 2faThe boundary trick: \b inside API_TOKEN does not mark the boundary between _ and T because _ is a regex word character. The actual pattern uses letter-only lookarounds ((?<![A-Za-z])keyword(?![A-Za-z])) so _, -, digits, and start/end-of-string all count as boundaries.
Even if a variable has an innocent name, the value gets redacted when it matches a known credential format or looks long-and-random:
Known formats (KNOWN_SECRET_VALUE_PATTERNS): Anthropic / OpenAI sk-…, GitHub PATs (ghp_, gho_, ghs_, ghu_, ghr_, github_pat_), Slack tokens (xoxb-, xoxa-, etc.), AWS access key IDs (AKIA…, ASIA…), JWTs (eyJ… with two more base64-url segments), Google API keys (AIza…), Stripe live keys (sk_live_, rk_live_, pk_live_), PEM-encoded private keys.
Entropy fallback: values ≥ 20 chars, in the token charset [A-Za-z0-9+_./=\-], with Shannon entropy > 4.5 bits/char. Reference points: random hex ≈ 4.0, random base64 ≈ 6.0, English text ≈ 4.0–4.5, structured config (URLs, paths) ≈ 3.5. The charset gate excludes URLs (://), paths-with-other-chars, and email addresses (@), so those don't trip the entropy check.
The patterns are heuristics, not guarantees. They err toward redacting more (acceptable — opt out per-name via --unsafe-show NAME with audit trail).
To extend: edit the constants in lib/_secret_heuristics.py (SENSITIVE_NAME_PATTERN, KNOWN_SECRET_VALUE_PATTERNS, or the threshold constants). Whitelist-additions only — never remove an existing keyword or pattern, since that would silently widen exposure. After every edit, run python3 lib/_test_heuristics.py from the plugin root; tests cover both axes and the combined redaction_reason entry point.
The redaction placeholder names which axis fired: <redacted: sensitive name> vs <redacted: sensitive value> — useful for understanding why a value was held back.
proc-probe-guard.sh is a PreToolUse hook wired in .claude/settings.json with matcher Bash|Read|Edit|Write|NotebookEdit. It JSON-stringifies the entire tool_input and scans for a real procfs read — catching the path wherever it appears (Bash command, Read/Edit/Write file_path, Edit/Write content containing source code that would do the read, etc.). On match the hook exits with decision: block and points the message at this skill.
The hook deliberately whitelists only the path forms an actual read uses — numeric pids, literal self / thread-self, and shell-variable references $pid / ${pid} / $(cmd) — so prose like /proc/<pid>/environ in a commit message or doc passes through. The synthetic test suite covers both block and allow paths across Bash, Read, Edit, Write, NotebookEdit, and Edit's new_string (code-being-written) scanning.
The cmdline check is included alongside environ because some programs accept secrets on argv. The cmdline helper redacts those tokens; raw access is blocked.
Residual bypass surfaces:
Write a script that doesn't contain the literal path (built via variable concatenation), then Bash-run it. The hook can't see inside the rendered script. Trust boundary is at the helper-script interface, not at the syscall — the existence of the helpers and the discipline of using them is the actual safeguard.path argument, the hook's tool_input-wide scan catches it as long as the path literal appears in the input. If the path is somehow obfuscated (base64, etc.), the hook won't see it.If you need a new probe operation, add it as a new executable in the plugin's libexec/ directory (Python or shell) with the same shape as the existing helpers:
--unsafe opt-in only when there's a real case.Then wire it up:
SUBCOMMANDS tuple in bin/process-probe.Usage examples block with a representative invocation.The single-binary-with-subcommands shape exists so that only process-probe lives on PATH (avoiding name collisions with generic system commands like info).
Creates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.
npx claudepluginhub stefankarpinski/karpinski-claude-plugins --plugin process-probe