A local-only Go CLI that pseudonymizes secrets and identifiers before logs go to an LLM, then restores them at a TTY when the report comes back.
- Local only. No telemetry, no network I/O, no daemon.
- Deterministic. The same value always maps to the same sentinel within a project, so correlated log lines stay correlated across reports.
- Reversible — for you.
unmaskruns at your terminal. The LLM never has a way to recover the original values. - Extensible. Built-in detectors cover common cloud/SaaS secrets and infrastructure identifiers; project-local rules cover application IDs.
⚠️ Project status: early / under-tested. This is a personal project that has only been exercised against the author's own workflows. It has not been validated at scale, against adversarial inputs, or in production environments. Detector coverage is incomplete by design — any new secret format that isn't in the rule set will pass through unmasked. Treat OpsMask as a leakage-reduction aid, never as a sole control protecting sensitive data. Review masked output before sending it to an LLM, especially during initial adoption. Bug reports and missed-detection samples are welcome via GitHub issues.
┌──────────────┐ masked text ┌─────────┐
raw logs ───────▶ │ opsmask mask │ ────────────────▶ │ LLM │
└──────┬───────┘ └────┬────┘
│ writes mapping │
▼ │ masked report
<project>/.opsmask/mapping.sqlite │ (sentinels echoed)
▲ │
│ reads mapping │
┌──────┴───────┐ │
plaintext ◀───────│ opsmask │ ◀──────────────────────┘
(TTY)│ unmask │
└──────────────┘
Use OpsMask whenever you want an LLM to help with real production data that contains values you would not paste into a public chat:
kubectl logs,kubectl describe, orkubectl get -o ...output.journalctl, syslog, or application logs from production.ssh host 'tail …',dmesg, cloud provider audit logs.- Crash dumps, stack traces, or support bundles with embedded credentials, emails, hostnames, or customer identifiers.
It is not the right tool for:
- Source code review, where line-level structure must stay readable —
except via the masked-repo round trip (
opsmask repo), which preserves untouched lines byte-for-byte so an agent can work on IaC source. See Masked repo round-trip below. - Documents where pseudonyms break meaning (e.g. legal contracts, human-readable narratives).
- Defending against a malicious LLM that ignores instructions to preserve sentinels — OpsMask is a leakage-reduction tool, not a sandbox.
- Go 1.26+ to build from source.
- A writable user-config directory. The user-wide secret used to seed
pseudonymization is generated on first run (mode
0600) underos.UserConfigDir():~/.config/opsmask/user_secreton Linux,~/Library/Application Support/opsmask/user_secreton macOS,%AppData%\opsmask\user_secreton Windows. Keep this directory on local disk — it is rejected on network filesystems. - A TTY for
unmask.opsmask unmaskrefuses to write to a non-terminal stdout to avoid accidentally piping plaintext into a file or another program. There is no bypass flag; this is intentional. - Optional: a git repository. Required for
opsmask install claude-code, not for plainmask/unmask.
Build from source (no signed binaries are published yet):
# from the repository root
go build -o ./bin/opsmask ./cmd/opsmask
sudo mv ./bin/opsmask /usr/local/bin/opsmask # or any directory on PATHOn macOS, an unsigned binary built locally runs without Gatekeeper prompts. If you copy the binary from another machine, clear the quarantine attribute:
xattr -d com.apple.quarantine /usr/local/bin/opsmaskConfirm the install before wiring it into anything:
echo "alice@example.com from 10.0.0.1 hit api.example.com" | opsmask maskExpected output (token indexes will differ):
⟪opsmask:email:01af3c1d…⟫ from ⟪opsmask:ip4:7c93a4ed…⟫ hit ⟪opsmask:hostname:b1d2e3f4…⟫
The pipeline above works without running opsmask init or config trust —
built-in detectors are always active. init and config trust only unlock
project-specific extensions (custom regex rules, internal TLDs, the exec
follow-up surface). See Configuration.
| Path | Use when | Start here |
|---|---|---|
| Plain CLI | You drive the LLM via copy/paste or your own scripts. | Quick start |
| Claude Code hook | You use Claude Code and want non-trivial Bash output masked automatically. | Claude Code Bash hook |
| MCP server | You use Claude Desktop, Cursor, Copilot, or another MCP client. | MCP server |
The three paths are independent — pick one to start, add others later.
# 1. (Optional) Initialize a project for custom rules and exec follow-up.
# Skip this if you only need built-in detectors.
opsmask init
opsmask config trust
# 2. Mask logs before sending them to the LLM.
kubectl logs deploy/api | opsmask mask --summary > masked.log
# 3. Paste masked.log into your LLM session, get a report back as report.md.
# 4. Restore sentinels at your terminal (never inside the agent).
opsmask unmask < report.mdThe LLM must echo sentinels verbatim (no paraphrasing, lowercasing, or
splitting) for unmask to restore them. If you use Claude Code, the
skill/opsmask directory ships a Skill that
encodes this contract. For other clients, paste a system prompt like:
The text I share has been pseudonymized by OpsMask. Tokens of the form
⟪opsmask:<type>:<index>⟫,[[opsmask:<type>:<index>]], and[OPSMASK_ESCAPED_SENTINEL:...]are placeholders for real values. Do not paraphrase, modify, lowercase, split, or "clean up" these tokens. Echo them character-for-character in your reply.
Input:
customer Example Corp from alice@example.com hit 10.0.0.1
After opsmask mask:
customer Example Corp from ⟪opsmask:email:01af3c1d2b4e5f60⟫ hit ⟪opsmask:ip4:7c93a4ed1b209f88⟫
unmask reverses the sentinels locally once the LLM's report comes back.
The opsmask repo commands mask a whole git repo into a disposable sibling
copy a coding agent (Claude Code, Codex) can edit, then fold the agent's
changes back onto the real repo as uncommitted plaintext for human review.
The motivation is the same interception gap that elsewhere forces the
copy-paste workflow: an agent cannot safely read an IaC repo with secrets and
PII baked in, and there is no reliable way to mask the agent's Read/tool-call
output at the source. Masking the repo at rest sidesteps that — inside the
copy there is nothing to intercept.
# 1. In the real repo (clean tracked worktree required), build the masked copy.
opsmask repo mask # add --yes to skip the TTY review gate
# 2. Review the mask report before handing the copy to an agent.
$EDITOR .opsmask/repo_report.txt
# 3. Point the agent at the sibling copy and let it edit.
cd ../mask_<name> # run Claude Code / Codex here
# ... agent edits, adds, deletes, renames files ...
# 4. Stop the agent, then fold its changes back onto the real repo.
cd - # back in the real repo
opsmask repo unmask # writes UNCOMMITTED plaintext changes
# 5. Review and validate the restored changes as a normal diff.
git status && git diff # only the agent's edits should appear
# ... run tests / plan / apply ...
# 6. Tear down the copy and session.
opsmask repo discardopsmask repo status reports, read-only, whether a session is open, the copy
path, drift state, and a change summary at any point in the workflow.
This is the one place OpsMask's mental model inverts, so read it carefully.
Normal opsmask mask destroys secret-class values: an AWS key or PEM block
becomes [REDACTED_*] and nothing is stored, so it can never be restored.
Repo mode instead pseudonymizes everything, secret-class values included,
into reversible sentinels — because the round trip must restore a secret the
agent moved or edited to its exact original bytes.
The cost: the real secret plaintext is persisted in the per-repo local
mapping store .opsmask/mapping.sqlite (mode 0600, local-only, never
networked — repo mode refuses to run if that store is on a network
filesystem). This does not widen exposure to the LLM — the agent only ever
sees the masked copy — but it does mean real secrets now live in the store on
the same local disk as the source, alongside the plaintext already in your
working tree.
opsmask repo discard does not purge those secret mappings in v1: it
reports how many remain and the opsmask mapping prune command to clear them.
Do not carry the normal-mask "destroy and store nothing" model into repo
mode.
The masked copy removes plaintext from the agent's intended workspace, so the
agent's tool-calls against the copy cannot leak secrets. It is not a
sandbox. The real repo still sits at the sibling path ../<repo>, readable by
a shell-capable agent via cat, grep, or an absolute path. The round trip
does not promise protection against an agent that reads outside its
workspace.
For stronger guarantees, run the agent with filesystem access scoped to the copy — a container, an OS sandbox, or a restricted user.
Untouched lines round-trip byte-for-byte; the agent's edits are written with sentinels resolved. The return trip is fail-closed — it refuses entirely, writing nothing, on any of:
- real-repo drift (a new commit or a modified tracked file since
mask); - a rotated user secret, or changed detector rules;
- a fabricated or unresolved sentinel, or a near-miss marker, in agent content;
- a path that escapes the repo, or an added/renamed file colliding with an existing untracked file or with another target under case-folding;
- an added binary file, or an agent-introduced symlink;
- an unreachable snapshot commit (the agent rebased or amended it).
One behavior is worth stating plainly because it is not an abort: the agent can introduce new detector-matching values (a new hostname, IP, or email) in added or edited lines. The agent has no access to real secrets, so an introduced value is the agent's own content and is written as plaintext as-is. The round trip aborts only when a sentinel fails to resolve or a stored mapping is inconsistent — never merely because new maskable-looking text appears.
While an agent works in the copy:
- Work only inside the masked copy.
- Do not run
opsmask mapping pruneduring the session — it breaks the reversibility guard the return trip depends on. - Do not rebase or amend the snapshot commit in the copy.
- Do not create symlinks.
- Stop the agent before running
opsmask repo unmask.
Masking preserves HCL/YAML/JSON parseability: repo mode forces the
Unicode-bracket token form (⟪opsmask:…⟫) so an unquoted YAML scalar stays a
valid plain scalar rather than reparsing as a flow sequence.
Three forms can appear in masked output. Agents must preserve them character-for-character:
- Unicode sentinel:
⟪opsmask:<type>:<index>⟫(default). - ASCII fallback:
[[opsmask:<type>:<index>]]— used when input is strict-ASCII or when--ascii-tokensis passed. Pick this when your LLM client mangles non-ASCII characters or your downstream tooling is not Unicode-clean. - Inert escape:
[OPSMASK_ESCAPED_SENTINEL:<base64url>]— planted before masking when source text already looks like a sentinel; decoded back to literal bytes duringunmask.
Setup
| Command | Purpose |
|---|---|
opsmask init |
Scaffold .opsmask/ (mode 0700) and a commented config.yaml. |
opsmask config |
Show current config status. |
opsmask config init |
Same scaffold as init, from the config command group. |
opsmask config trust |
Trust the current project config (path + SHA-256 bound). |
Runtime
| Command | Purpose |
|---|---|
opsmask mask [file|-] |
Mask stdin or a file. Flags: --summary, --ascii-tokens, --verify, --max-line (default 16MiB; raise it for very long single-line JSON or stack traces). |
opsmask mask --jobs N --out-dir DIR FILE... |
Mask multiple files concurrently with one shared mapping store and one token form for the whole bundle. Outputs are written under DIR by input basename and released all-or-nothing: nothing is renamed from its temp name until every file has masked (and, with --verify, verified) successfully. Stdin is not supported in jobs mode. |
opsmask mask --jobs N --out-dir DIR FILE... |
Mask multiple files concurrently with one shared mapping store and one token form for the whole bundle. Outputs are written under DIR by input basename and released all-or-nothing: nothing is renamed from its temp name until every file has masked (and, with --verify, verified) successfully. Stdin is not supported in jobs mode. |
opsmask unmask [file|-] |
Restore tokens. TTY-only. |
opsmask exec -- <cmd> [args...] |
Run a follow-up command with sentinels in argv; output is re-masked before it returns. |
Integrations
| Command | Purpose |
|---|---|
opsmask install claude-code [--team-shared] |
Install the Claude Code Bash hook for the current git project. |
opsmask uninstall claude-code |
Remove the Claude Code hook from the current git project. |
opsmask mcp serve |
Run the Model Context Protocol server on stdio for LLM clients. |
Repo round-trip
| Command | Purpose |
|---|---|
opsmask repo mask [--yes] |
Mask the current git repo into a sibling copy mask_<name> for an agent to edit. --yes skips the TTY review gate. |
opsmask repo unmask |
Fold the copy's changes back onto the real repo as uncommitted plaintext. Fail-closed; writes nothing on drift or unresolved sentinels. |
opsmask repo discard |
Delete the copy and session; report remaining secret mappings and the mapping prune command to clear them. |
opsmask repo status |
Read-only: session state, copy path, drift, and a change summary. |
Admin
| Command | Purpose |
|---|---|
opsmask mapping list [--type T] [--limit N] |
List pseudonyms (no plaintext). TTY-only. |
opsmask mapping prune --older-than <duration> [--type T] |
Delete old mapping rows. --older-than is required. |
Global flags: --config <path>, --mapping <path>, --verbose.
opsmask mask --verify is an opt-in fail-closed post-pass. It rescans masked
output incrementally for residual raw UUID, IPv4, Fernet-token-shaped blobs,
and configured resource-field values — plus any 256+-character base64 run, a
heuristic that catches the tail of a blob that ever gets split — staging bytes
in an unlinked spool file and releasing output only if the whole scan is
clean; on any residual it withholds all output and reports the type and
offset. Memory is bounded by the longest line, not output size. Keep it on
for high-risk bundles and audits.
OpsMask keeps state in two places:
- User-config directory (Go's
os.UserConfigDir()+opsmask/):~/.config/opsmask/on Linux,~/Library/Application Support/opsmask/on macOS,%AppData%\opsmask\on Windows. Holdsuser_secret(the 32-byte HMAC key that seeds deterministic pseudonymization),exec.log,pass_through.log, andmcp_calls.jsonl. Back this directory up; keep it out of cloud sync (Dropbox, iCloud Drive, etc.). <project>/.opsmask/— per-project config (config.yaml) and the mapping store (mapping.sqlite). Add.opsmask/to.gitignore; never commit it.
First-run creation of user_secret and the Claude Code hook_secret is
serialized across processes and published with an atomic rename. A malformed
existing secret is rejected rather than replaced.
Pseudonymization is deterministic per user_secret. Two machines with
different secrets produce different sentinels for the same input, even with
identical project config. Existing mapping rows already on disk remain
restorable by unmask regardless of the secret — unmask looks up by
token index — but if you lose user_secret, future masking on a fresh
project produces sentinels that differ from any prior run.
opsmask install claude-code opts the current git project into a Claude Code
PreToolUse hook for Bash calls. Non-trivial Bash commands are rewritten
through a hidden, signed opsmask claude-code-exec entry point so stdout and
stderr are masked before they can enter the agent context. The default install
writes .claude/settings.local.json and adds it to .gitignore; pass
--team-shared to write .claude/settings.json after accepting the teammate
fail-closed warning.
Trivial commands on a built-in skiplist (ls, pwd, git status, and
similar read-only Bash) pass through unwrapped and unmasked — their
output is logged to pass_through.log for audit but is not run through the
detector pipeline. The pass-through stream rotates before an append would
take it beyond 8 MiB, retaining one previous generation as
pass_through.log.1; both files remain private. Wrapped invocations are
logged to exec.log. Treat the hook as a safety net for production-shaped
output, not as a guarantee that every Bash result is masked.
Install bakes the resolved binary path into .claude/opsmask-hook.sh. If
you move or rename the opsmask binary after running install claude-code,
re-run opsmask install claude-code so the hook script picks up the new
path.
This is a deliberate second operating mode:
opsmask exec,mask,unmask, and MCP remain policy-gated by project trust andexec.enabled.- The Claude Code hook bypasses those policy gates only for a project that was
explicitly registered by
opsmask install claude-code. The bypass is gated by a per-user hook secret and git-toplevel-bound HMAC signature, and writes hook records toexec.log/pass_through.log.
The v0 hook covers Claude Code Bash output only. Read, Grep, MCP tool
outputs, and transcript sweeps are follow-up surfaces.
opsmask mcp serve exposes masking, detection, and follow-up exec to
LLM clients (Claude Desktop, Claude Code, Cursor, Copilot) over the
standard Model Context Protocol stdio transport. The server uses the
official modelcontextprotocol/go-sdk.
Find the absolute path to your binary (clients launch the server as a
subprocess and PATH is unreliable in that context):
which opsmask
# /usr/local/bin/opsmask (or wherever you installed it)Add the snippet to your client's MCP config. For Claude Desktop on macOS
(~/Library/Application Support/Claude/claude_desktop_config.json):
{
"mcpServers": {
"opsmask": {
"command": "/usr/local/bin/opsmask",
"args": ["mcp", "serve"]
}
}
}Cursor (~/.cursor/mcp.json):
{
"mcpServers": {
"opsmask": {
"command": "/usr/local/bin/opsmask",
"args": ["mcp", "serve"]
}
}
}Claude Code (~/.config/claude-code/mcp.json or per-project
.claude/mcp.json):
{
"mcpServers": {
"opsmask": {
"command": "/usr/local/bin/opsmask",
"args": ["mcp", "serve"]
}
}
}| Tool | Purpose |
|---|---|
mask_text |
Mask sensitive values in text using project detectors. Persists pseudonyms. |
detect_text |
Scan text for sensitive values without persisting. Returns counts by type. |
exec |
Run a follow-up command. Honors project allow-list, deny-base, re-masks output. |
mapping_stats |
Per-type counts of pseudonyms currently in the mapping store. |
list_detectors |
List active detector rules (built-ins + project rules). |
| Resource | URI |
|---|---|
| Mapping snapshot (token-only) | opsmask://mapping/{type}?limit=N |
unmask, init, and config trust are CLI-only by design. unmask
returns plaintext; exposing it as an MCP tool would put plaintext into
the agent's context window — voiding the project's headline guarantee.
init and config trust mutate trust anchors that must originate from
a human at a TTY.
The mapping resource returns sentinel tokens and byte lengths only —
no plaintext, no HMAC bytes. The resource handler is exercised by
internal/mcpsrv/resource_mapping_test.go against multiple HMAC
encodings (raw, hex, base64 std/url/raw) to enforce this contract.
MCP tool calls write JSON-Lines records to two audit streams in the
same directory as exec.log (configurable via OPSMASK_AUDIT_DIR,
defaults to ~/.config/opsmask/):
exec.log—exectool records, identical schema to CLI exec, plus a"source": "mcp"field. Fail-closed: subprocess execution refuses if the audit log is unwritable.mcp_calls.jsonl— lean records for non-exec tools (mask_text,detect_text,mapping_stats,list_detectors, resource reads). Contains call counts and sizes, never content. Fail-open: a bulk-masking call is not blocked by an unwritable audit, but the failure is logged to the server's stderr (visible to the launching client). No MCP tool surface exposes audit-failure status — exposing even a sticky boolean would create a denial-of-service oracle.
Common secret/token detectors are derived from a pinned review of the Gitleaks default configuration, with local extensions for LLM-bound log masking gaps and debug-useful identifiers. See docs/DETECTOR_RULES.md for sourcing, attribution, and the procedure for keeping rules current.
Two policies apply:
- Pseudonymize — value is mapped to a stable token and remembered in the
mapping store.
unmaskcan restore it. - Destroy — value is replaced with
[REDACTED_<TYPE>]and not stored.unmaskcannot recover it.
The detectors you will see most often:
| Type | Policy | Matches |
|---|---|---|
ip4 |
Pseudonymize | Dotted-quad IPv4 with each octet 0–255. |
ip6 |
Pseudonymize | Full or ::-compressed IPv6. Three-group strings like log timestamps 16:23:37 are excluded. |
email |
Pseudonymize | Standard local@domain.tld shape. |
hostname |
Pseudonymize | RFC-1123-ish hostnames/FQDNs whose suffix is recognized by the Public Suffix List or configured as internal. |
name |
Pseudonymize | Quoted values of configured resource-field keys in JSON-ish or Python dict-repr payloads. Defaults: name, description. |
k8snamespace, k8spod, k8snode, … |
Pseudonymize | Kubernetes resource references with nearby resource nouns. |
fernet |
Pseudonymize | Long gAAAA... base64url Fernet/Keystone-token-shaped blobs. |
jwt |
Destroy | JWT-shaped strings with valid header (alg/typ) and a common payload claim. |
pem_private_key |
Destroy | -----BEGIN ... PRIVATE KEY----- blocks. |
All other built-in detectors (cloud, SaaS, generic IDs)
| Type | Policy | Matches |
|---|---|---|
mac |
Pseudonymize | Six colon-separated hex bytes. |
uuid |
Pseudonymize | RFC 4122 v1–v5 with hyphens. |
hex_id |
Pseudonymize | Plain hex run of 32–128 chars (MD5/SHA/long IDs). |
password_url |
Destroy | URLs with embedded user:pass@host credentials. |
auth_header |
Destroy | Credential in an Authorization/Proxy-Authorization header (Bearer, Token, Basic, Negotiate, NTLM, ApiKey). Catches opaque tokens no vendor-format rule recognizes; the header name and scheme stay visible. |
aws_key |
Destroy | AWS access keys (AKIA…, ASIA…, ABIA…, ACCA…, A3T…). |
github_token |
Destroy | GitHub PATs and tokens (ghp_, gho_, ghu_, ghs_, ghr_, github_pat_). |
gitlab_token |
Destroy | GitLab token prefixes (glpat-, glrt-, glcbt-). |
slack_token |
Destroy | Slack app, bot, user, legacy, config, refresh, and webhook shapes. |
openai_key |
Destroy | OpenAI keys containing the T3BlbkFJ marker. |
anthropic_key |
Destroy | Anthropic sk-ant-api03-… and sk-ant-admin01-… keys. |
stripe_key, stripe_webhook_secret, stripe_publishable_key |
Destroy | Stripe secret/restricted, webhook signing, and publishable keys. |
stripe_id |
Pseudonymize | Stripe resource IDs (ch_, cus_, pi_, sub_, evt_, pm_, prod_, price_, seti_, ba_, card_, src_, tok_, txn_). |
gcp_api_key |
Destroy | Google/GCP API keys beginning with AIza. |
gcp_sa |
Destroy | JSON "type": "service_account" discriminator. |
twilio_key |
Destroy | Twilio API keys (SK…). |
npm_token |
Destroy | npm registry tokens (npm_). |
pypi_token |
Destroy | PyPI Macaroon upload tokens. |
sendgrid_key |
Destroy | SendGrid API keys (SG.<id>.<secret>). |
digitalocean_token |
Destroy | DigitalOcean PAT/OAuth/refresh (dop_v1_, doo_v1_, dor_v1_). |
linear_token |
Destroy | Linear API keys (lin_api_). |
postman_key |
Destroy | Postman access keys (PMAK-). |
Hostname/FQDN detection uses the Public Suffix List for registered ICANN and
private suffixes, plus a small default internal-TLD set (local, internal,
lan, home, localhost, arpa, corp, intranet, test).
Kubernetes-resource detectors are also precision-biased. For project-specific
shapes (user_…, order_…, tenant_…, etc.), add trusted project rules —
see docs/CUSTOM_DETECTORS.md.
Project config lives at .opsmask/config.yaml. Built-in detectors run
without it. A project config is only required to:
- Add custom
regex_rulesfor application-specific IDs. - Extend hostname masking with
detectors.hostname.internal_tlds. - Replace or disable resource-field masking with
detectors.resource_fields.keys. - Enable the
execfollow-up surface.
The config file is ignored until trusted:
opsmask config trustTrust is bound to the file's resolved path plus a SHA-256 of its contents.
Any edit requires a fresh config trust. Passing --config <other-path>
cannot satisfy the trust gate; security-critical settings (notably exec:)
are silently ignored when loaded from an override path.
Example:
# .opsmask/config.yaml
literals: []
regex_rules:
- name: app-user-id
type: app_user
pattern: '\buser_\d+\b'
policy: pseudonymize
deny_list: []
exec:
enabled: false
scope: read-only
default_timeout: 30s
allow: []
env_allow: []
env_deny: []
detectors:
hostname:
internal_tlds: [acme]
resource_fields:
keys: [name, description]The deny_list is an audit canary, not an enforcement boundary — a hit
after masking signals the rule set missed something it should have destroyed.
detectors.hostname.internal_tlds extends hostname masking for trusted
self-hosted suffixes such as db-1.prod.acme. It is additive only and is
honored only from a trusted project .opsmask/config.yaml; user-wide config
and --config overrides cannot widen hostname masking.
detectors.resource_fields.keys controls which quoted payload fields have
their string values replaced with [[opsmask:name:<hash>]] /
⟪opsmask:name:<hash>⟫. The built-in default is name and description.
Setting the list replaces the defaults; keys: [] disables this detector for
projects where resource names are intentionally safe to share. Like hostname
detector settings, this is honored only from a trusted project config.
When investigating a masked entity, opsmask exec runs a read-only
follow-up such as kubectl describe, dig, or nslookup while keeping
plaintext out of the agent's context. The wrapper resolves sentinels in
argv locally, runs the child, and re-masks stdout/stderr before they return.
opsmask exec -- kubectl describe pod '⟪opsmask:k8spod:0123456789abcdef⟫'
opsmask exec -- nslookup '[[opsmask:hostname:0123456789abcdef]]'exec is disabled by default and only enabled by a trusted project
config. Three scope tiers are available:
read-only(default) — curated baseline:kubectl get|describe|logs(with secret and follow-mode carve-outs), DNS tools, stdin-onlyjq,echo,date, bareenv.investigate— adds broader read-only SRE commands and arbitrary-path file readers:cat,tail,grep,awk,sed.freeform— explicit escape hatch. Any non-denied command can run unless the project allow-list constrains it. Shells, debuggers, REPLs, schedulers, remote-exec helpers, and known dispatch flags remain denied unless a named freeform deny opt-out is configured.
Project allow-list entries are per-argv-element regex sets:
exec:
enabled: true
scope: investigate
allow:
- name: "curl-internal-prom"
elements:
- "^curl$"
- "^-fsSL$"
- "^https://prom\\.internal/.*$"curl and wget are not in any baseline; allow them explicitly when needed.
exec writes JSON-lines audit records to the user-config directory's exec.log (~/.config/opsmask/exec.log on Linux; the platform-equivalent path elsewhere)
(mode 0600). Each invocation writes two records: a "starting" record
before the child process runs (with argv, scope, policy match, env-shaping
counts) and a final record afterward (with exit_code, duration_ms,
error_class). The pre-execution record is the load-bearing forensic
anchor — if the audit log becomes unwritable between Preflight and
the final write, the subprocess refuses to run. If a write fails after
the subprocess has already executed (e.g. disk filled mid-Run), the
"starting" record on disk preserves reconstruction.
Children do not inherit the full parent environment. exec builds a
tier-specific allow-list (PATH, HOME, locale vars, kube/cloud config
paths, proxy vars, tier-specific SRE vars) and strips interpreter startup and
command-dispatch variables (BASH_ENV, LD_PRELOAD, PYTHONPATH,
NODE_OPTIONS, GIT_SSH_COMMAND, GIT_CONFIG_*, BASH_FUNC_*, …).
Project exec.env_allow adds tool-specific variables; exec.env_deny always
wins.
opsmask unmask exits with unmask refuses to write to non-TTY stdout.
Run unmask interactively. Redirecting to a file or pipe is intentionally
blocked — there is no flag to override it. If you need plaintext in a file,
copy from your terminal scrollback after the fact.
config trust keeps rejecting the project. Trust binds to the resolved
path and SHA-256 of the file. Re-running config trust after every edit is
expected. If you opened the project through a symlink, run config trust
from the resolved real path.
Custom regex_rules are ignored. Confirm two things: (1) the config is
at .opsmask/config.yaml (not passed via --config), and (2) you ran
opsmask config trust after the most recent edit. --config overrides
cannot satisfy the trust gate.
The Claude Code hook is not firing. Check .claude/settings.local.json
exists and contains an opsmask PreToolUse entry; reinstall with
opsmask install claude-code if missing. Pass-through events are logged to
pass_through.log in the user-config directory. If you moved or renamed the
opsmask binary after running install, re-run opsmask install claude-code
to refresh the binary path baked into .claude/opsmask-hook.sh.
Mappings appear out of sync between machines. user_secret is per
machine. Copy the user_secret file from one machine's user-config
directory to the other (mode 0600) before any masking happens there.
Removing OpsMask state. To wipe everything: remove the user-config
directory (~/.config/opsmask/ on Linux, ~/Library/Application Support/opsmask/
on macOS, %AppData%\opsmask\ on Windows) and any per-project .opsmask/
directories. To uninstall the Claude Code hook for one project:
opsmask uninstall claude-code. The binary itself can be deleted from
wherever you installed it on PATH.
OpsMask reduces leakage on the CLI pipe path. It does not protect:
- Screenshots, clipboard, or copy-paste that bypasses the CLI.
- Uploads to a chat UI's file picker that skip the masking pipeline.
- An agent that ignores the companion skill and rewrites or paraphrases sentinel tokens.
- Detection gaps. New secret formats appear constantly; the deny-list canary helps you notice misses, but it cannot substitute for review.
Pseudonymization is deterministic per user_secret. That property is
intentional — it lets correlated lines stay correlated — but it means an LLM
that echoes a sentinel back in plaintext form leaks information. Treat token
reflection as an expected failure mode, and keep the user-config directory
and project .opsmask/ directories out of cloud-sync and shared-backup paths.
exec runs a real child process after resolving sentinels in argv. Scope
tiers and the hard deny-list reduce that surface, but they do not provide
filesystem sandboxing. On shared bastions or jump hosts, resolved argv may be
briefly visible to other local users via /proc/<pid>/cmdline unless the
host hides other users' processes (e.g. Linux hidepid=2). Do not enable
exec on multi-user hosts unless that exposure is acceptable.
For a full list of follow-up risks and detector-sourcing caveats, see docs/REMAINING_RISKS.md.
End-user documentation:
- docs/DETECTOR_RULES.md — detector sourcing, Gitleaks attribution, and rule-update procedure.
- docs/CUSTOM_DETECTORS.md — project-specific
regex_rulescookbook for application IDs. - docs/REMAINING_RISKS.md — known limitations and follow-up risks.
- docs/THIRD_PARTY_NOTICES.md — license attributions for derived rules.
- docs/examples/kubernetes-safe-followup.md — worked Kubernetes triage example.
- BENCHMARKS.md — local benchmark numbers.
- CHANGELOG.md — release notes.
- testdata/corpus/README.md — how to guard
a detection bug fix against regression with the corpus harness
(
opsmask corpus add | accept | list).
MIT.