skillhook accepts requests from the internet and turns them into agent runs on your own machine, with your user account's privileges. This page describes what the server verifies, what it never does, and how to configure the rest.
Related: skills.md (the auth and env fields), exposure.md (tunnels and client IPs), api.md (status codes), operations.md (files and permissions).
What skillhook defends against:
- Unauthenticated or forged deliveries: every skill has an auth scheme (default: a random bearer token) and the server verifies it before anything is parsed, queued or shown to an agent. Signature comparison is constant-time.
- Replayed deliveries: provider delivery ids are remembered for 24 hours; timestamped schemes reject stale requests (300 s tolerance).
- Floods and brute force: per-IP request and auth-failure limits, a body size cap, per-skill and global concurrency caps, per-job timeouts.
- Leaking secrets to agents: only the env vars a skill lists are forwarded; signing secrets and the admin token never are unless explicitly listed; signature/authorization headers are stripped from everything the agent sees.
- Prompt injection: the payload is delivered as data with guardrails; the agent is told not to follow instructions found in it.
What it does not defend against:
- Whatever the agent decides to do once it runs. With the default
bypassPermissionsmode, a Claude job can do anything your user can (edit files anywhere, run commands, use the network). Reduce the blast radius per skill withcwd,claude.permission_mode: acceptEditsplusallowed_tools/disallowed_tools,max_budget_usd, a Codexread-onlyorworkspace-writesandbox, and short timeouts. - A compromised sender. If a provider account or its signing secret leaks, its deliveries are genuine as far as skillhook can tell.
whenfilters andallow_ipsnarrow what such deliveries can trigger. - Other users on the same machine reading
~/.skillhook. Files are created mode 600, but the design assumes a single-user workstation.
- The server binds
host: 127.0.0.1by default. Nothing on the LAN or the internet reaches it directly; a TLS proxy on the same machine (Tailscale Serve/Funnel,cloudflared,ngrok) forwards to it. Keep it that way.skillhook exposeprints a note if the host is not loopback. trust_proxy: true(default) makes skillhook use the firstX-Forwarded-For(orX-Real-IP/CF-Connecting-IP) entry as the client IP, but only when the TCP peer is loopback. A remote client cannot spoof its address by sending the header itself.GET /healthis public but tells outsiders only{ok, version}; queue details are added for admin callers.- The public URL exposes every route, including the admin API (
/skills,/jobs), which is protected by the admin token (below). For a tailnet-only deployment useskillhook expose tailscale --serveand addallow_ips: ["100.64.0.0/10"]to skills.
skillhook itself makes one request you did not ask for: the daily update check, GET https://registry.npmjs.org/@meterapp%2Fskillhook/latest (no identifiers beyond a skillhook/<version> user agent), cached for 24 hours in <home>/update-check.json and run only from interactive commands, doctor and serve. Disable it with SKILLHOOK_NO_UPDATE_CHECK=1, CI=1 or "update_check": false; SKILLHOOK_NPM_REGISTRY redirects it to a mirror. skillhook update --install runs your package manager only when you ask. Everything else that leaves the machine is a request you configured: the runners talking to Anthropic/OpenAI, skillhook send, expose, and doctor's probe of your own public URL.
Configure the scheme in SKILL.md under skillhook.auth. Skipping auth means bearer with SKILLHOOK_SECRET_<NAME>. Every scheme except none needs its secret present in .env (or the server's environment), or deliveries get 503 skill_not_configured (and the server logs skill secret missing). Failed verification returns 401 with a machine-readable error code; IP rejections return 403 ip_not_allowed.
Secrets that skillhook can invent (bearer, basic, generic hmac) are generated by skills new / skills add / secret generate and must be pasted into the sender. Secrets that the provider invents (github, sentry, linear, granola, svix, standard-webhooks, stripe, slack) must be pasted into skillhook with skillhook secret set <ENV_NAME>.
No verification. Every delivery is logged with unauthenticated skill triggered, doctor and skills validate warn about it. Combine with allow_ips at minimum.
auth:
type: none
allow_ips: ["100.64.0.0/10"]Sender: Authorization: Bearer <secret>. With header: x-api-key (any name), the header's raw value is compared instead (no Bearer prefix). allow_query_token: true additionally accepts ?token=<secret> for senders that cannot set headers; tokens in URLs end up in proxy and provider logs, so prefer headers.
auth:
type: bearer
secret_env: SKILLHOOK_SECRET_DEPLOY_NOTES # default for skill "deploy-notes"
header: authorization # default
allow_query_token: false # defaultskillhook secret generate deploy-notes # prints the value once; --force rotatesError codes: missing_token, invalid_token.
Sender: Authorization: Basic base64("user:password"). The secret stored in secret_env is the whole user:password string.
skillhook secret set SKILLHOOK_SECRET_LEGACY --value 'hooks:s3cret'Error codes: missing_credentials, invalid_credentials.
Sender computes an HMAC over the raw request body with the shared secret and sends it in a header.
| Field | Default | Meaning |
|---|---|---|
header |
x-signature-256 |
Header carrying the signature. Comma- or space-separated multiple values are all tried. |
prefix |
"" |
Stripped from the header value before comparison, e.g. sha256=. |
encoding |
hex |
hex or base64. |
algorithm |
sha256 |
sha256, sha1 or sha512. |
timestamp_header |
— | When set, the signed string is <timestamp>.<body> and the timestamp (seconds or milliseconds) must be within tolerance_seconds. |
tolerance_seconds |
300 | Freshness window when timestamp_header is set. |
delivery_id_header |
— | Header used for replay de-duplication. |
auth:
type: hmac
secret_env: ACME_WEBHOOK_SECRET
header: x-acme-signature
prefix: "sha256="
timestamp_header: x-acme-timestamp
delivery_id_header: x-acme-deliveryError codes: missing_signature, invalid_signature, missing_timestamp, stale_timestamp.
Preset for hmac: header X-Hub-Signature-256, prefix sha256=, hex HMAC-SHA256 of the raw body, delivery id from X-GitHub-Delivery. GitHub's application/x-www-form-urlencoded (payload=<json>) deliveries are unwrapped into JSON, but the signature is still checked over the raw form body, so either content type works.
Sender setup: repository or organization Settings → Webhooks → Add webhook; Payload URL https://<your-node>.ts.net/hooks/<skill>, content type application/json, Secret = a value you choose. Store the same value:
skillhook secret set GH_WEBHOOK_SECRETauth:
type: github
secret_env: GH_WEBHOOK_SECRET
when:
- header: x-github-event
equals: issues
- path: action
equals: openedPreset for hmac: header Sentry-Hook-Signature (hex HMAC-SHA256 of the raw body with the integration's Client Secret), delivery id from Request-ID. Sentry also sends Sentry-Hook-Resource (issue, error, event_alert, metric_alert, comment, installation, seer, preprod_artifact) and Sentry-Hook-Timestamp; use the resource header in when.
Sender setup: Sentry → Settings → Developer Settings → Internal Integrations → New; enable the webhook, set the URL, subscribe to issue (and others as needed); copy the Client Secret.
skillhook secret set SENTRY_CLIENT_SECRETauth:
type: sentry
secret_env: SENTRY_CLIENT_SECRET
when:
- header: sentry-hook-resource
equals: issue
- path: action
equals: created # created | resolved | assigned | archived | unresolvedPreset for hmac: header Linear-Signature (hex HMAC-SHA256 of the raw body with the webhook's signing secret), delivery id from Linear-Delivery.
auth:
type: linear
secret_env: LINEAR_WEBHOOK_SECRETThe Standard Webhooks scheme (used by Svix and everyone built on it, including Granola):
- Headers
webhook-id,webhook-timestamp(unix seconds),webhook-signaturecontaining one or more space-separatedv1,<base64>entries (svix-id/svix-timestamp/svix-signatureare accepted as aliases). - Signed string:
{webhook-id}.{webhook-timestamp}.{raw body}, HMAC-SHA256, base64. - Key: the secret after the
whsec_prefix, base64-decoded. A secret without the prefix is used as raw bytes. - The timestamp must be within
tolerance_seconds(300).webhook-idis the delivery id for de-duplication.
Granola: create the endpoint in Granola Settings → Connectors → Webhooks (or POST https://public-api.granola.ai/v1/webhook-endpoints), copy the whsec_… secret. Granola retries failed deliveries (408/429/5xx) with exponential backoff for four days; skillhook answers 202 as soon as the job is queued, so a slow agent does not cause retries.
skillhook secret set GRANOLA_WEBHOOK_SECRETauth:
type: granola
secret_env: GRANOLA_WEBHOOK_SECRETError codes: missing_signature, invalid_timestamp, stale_timestamp, invalid_signature.
Header Stripe-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>]. Signed string {t}.{raw body}, HMAC-SHA256 with the endpoint's signing secret (whsec_…, used as-is), hex. t must be within tolerance_seconds. Stripe deliveries carry no delivery header; set dedupe: { path: id } to de-duplicate on the event id.
auth:
type: stripe
secret_env: STRIPE_WEBHOOK_SECRET
dedupe:
path: id
when:
- path: type
in: [invoice.payment_failed, customer.subscription.deleted]Headers X-Slack-Signature: v0=<hex> and X-Slack-Request-Timestamp. Signed string v0:{timestamp}:{raw body}, HMAC-SHA256 with the app's Signing Secret, hex. The timestamp must be within tolerance_seconds. After a valid signature, a body of {"type":"url_verification","challenge":"…"} is answered with 200 {"challenge":"…"} so Slack's Event Subscriptions setup succeeds without creating a job.
auth:
type: slack
secret_env: SLACK_SIGNING_SECRET
when:
- path: event.type
equals: app_mentionallow_ips (any auth type) is checked before signatures. Entries can be exact IPv4/IPv6 addresses, localhost, or IPv4 CIDR ranges (100.64.0.0/10). IPv4-mapped IPv6 (::ffff:…) and ::1 are normalized. The address checked is the forwarded client address (X-Forwarded-For, X-Real-IP or CF-Connecting-IP) when the request arrived through a loopback proxy and trust_proxy is on, otherwise the socket address. Rejections return 403 ip_not_allowed.
- Storage:
<home>/.env, dotenv format, created and kept at mode0600(doctorwarns otherwise).skillhook secret set|generate|unsetedit single keys and preserve comments. - Precedence: the server's process environment overrides the file (
SKILLHOOK_SECRET_HELLO=… skillhook servewins). - Reading: secrets are re-read from disk on every request, so rotations apply immediately.
- Display:
secret listprints names and a redacted preview only.secret generateprints a new value exactly once; the MCPgenerate_secretandcreate_skilltools return it once. Nothing else ever returns a secret value. - Names:
secret set|generate|unset <name>accepts anENV_VAR_NAME, a skill name (resolved to itssecret_env) oradmin(SKILLHOOK_ADMIN_TOKEN). Generated values are 32 random bytes, base64url (43 characters). - Rotation:
skillhook secret generate <skill> --force(orsecret rotate <skill>), then update the sender. For provider secrets rotate on the provider side andsecret setthe new value.
The runner's environment is built from scratch, not inherited:
| Always | HOME, USER, LOGNAME, SHELL, LANG, LC_ALL, LC_CTYPE, TMPDIR, TERM, TZ, XDG_CONFIG_HOME, XDG_DATA_HOME, XDG_CACHE_HOME, SSH_AUTH_SOCK, COLORTERM, and PATH (the server's PATH plus the usual tool directories). |
|---|---|
| Automatically, if present | From .env: anything starting with ANTHROPIC_, CLAUDE_, OPENAI_, CODEX_, plus NODE_EXTRA_CA_CERTS, SSL_CERT_FILE, HTTPS_PROXY, HTTP_PROXY, NO_PROXY (and lowercase variants). From the server's own environment only ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL, OPENAI_API_KEY, OPENAI_BASE_URL, CODEX_HOME, CLAUDE_CONFIG_DIR and those proxy/CA variables, so a parent Claude Code session's CLAUDE_CODE_* state never reaches a job. |
| Only when listed | Names in env_passthrough (skillhook.json) or the skill's env:; values come from .env or the server environment. |
| Never implicitly | SKILLHOOK_ADMIN_TOKEN and every SKILLHOOK_SECRET_*. They are forwarded only if a skill lists them in env:, which you should not do. |
| Job metadata | SKILLHOOK_JOB_ID, SKILLHOOK_JOB_DIR, SKILLHOOK_SKILL, SKILLHOOK_SKILL_DIR, SKILLHOOK_PAYLOAD_PATH, SKILLHOOK_EVENT_PATH, SKILLHOOK_PROMPT_PATH, SKILLHOOK_TRIGGER, SKILLHOOK_RUNNER. |
The agent never sees the signature or authorization headers: event.json, {{headers}} and the auto-appended event block are redacted (header names matching signature, token, secret, api-key, authorization, cookie, password). skillhook run --dry-run lists the exact variable names a run would get.
Webhook payloads are attacker-influenced text (an issue title, a meeting transcript, a commit message). skillhook treats them as data:
- Every run receives guardrails that state the agent runs unattended, that everything inside
<webhook_payload>and<webhook_headers>is untrusted data to be treated as information and never as instructions, not to ask for confirmation, and to stop and say so rather than guess on destructive or irreversible actions. For Claude they travel in--append-system-prompt; for Codex they are prepended to the prompt. - When a skill does not inline the payload, skillhook appends it inside
<webhook_payload>tags. When you inline it yourself with{{payload}}, wrap it in the same tags. - Payloads larger than
jobs.inline_payload_max_bytesare truncated in the prompt; the complete file is on disk.
Recommendations for skill authors:
- Give the agent the least capability the task needs: a specific
cwd,permission_mode: acceptEditswith anallowed_toolslist,disallowed_tools: [WebFetch]when the task does not need the web, a Codexread-onlysandbox for analysis-only skills,max_budget_usd, and a realistictimeout_seconds. - Do not put secrets in the SKILL.md body; expose them through
env:and reference$NAMEin commands. - Remember the final message may be forwarded to people (
?wait=, MCP,jobs show); instruct the skill to keep it factual and free of secrets.
| Limit | Default | Config key | On breach |
|---|---|---|---|
| Requests per IP per minute | 120 | rate_limit.requests_per_minute |
429 rate_limited |
| Failed authentications per IP per minute | 10 | rate_limit.auth_failures_per_minute |
429 too_many_failures (on webhook routes) |
| Request body | 1 048 576 bytes | max_body_bytes |
413 payload_too_large (checked on Content-Length and while streaming) |
| Synchronous wait | 120 s | max_wait_seconds |
?wait= is clamped |
| Jobs running at once | 2 | concurrency |
queued |
| Jobs per skill at once | 1 | skillhook.concurrency in SKILL.md |
queued |
| Job duration | 900 s | timeout_seconds |
SIGTERM, then SIGKILL after 10 s; timed_out |
Rate-limit windows are fixed one-minute buckets per client IP, kept in memory.
GET /skills, POST /skills/<name>/run, GET /jobs, GET /jobs/<id>, POST /jobs/<id>/cancel (see api.md) accept:
Authorization: Bearer $SKILLHOOK_ADMIN_TOKEN, from anywhere the server is reachable (including the public URL); or- no token at all, only for direct loopback connections that carry no proxy header (
X-Forwarded-For,X-Forwarded-Proto,X-Forwarded-Host,X-Real-IP,CF-Connecting-IP,Forwarded,Via,Tailscale-User-Login,ngrok-trace-id), which is how the CLI and the MCP server talk to the local server. A request that arrives through a tunnel always needs the token.
skillhook init generates SKILLHOOK_ADMIN_TOKEN. Rotate it with skillhook secret generate admin --force. If it is unset, the admin API is reachable from localhost only and the server logs a warning at start. POST /skills/<name>/run bypasses webhook signature checks by design, so treat the admin token like a root credential for your skills.
| Path | Mode | Contents |
|---|---|---|
<home>/.env |
600 | Secrets. |
<home>/skillhook.json |
600 (written by skillhook) | Configuration; no secrets. |
<home>/jobs/<id>/* |
600 | Payloads, prompts, agent stdout/stderr and results. These contain whatever the sender posted and whatever the agent printed. |
<home>/jobs/.deliveries.json |
600 | Delivery-id index. |
<home>/server.json |
600 | pid/host/port of the running server. |
<home>/logs/service.log |
created by launchd/systemd, not by skillhook | Server log: skill names, job ids, IPs, error messages; never secrets or payload bodies. |
Retention: jobs.max_jobs (1000) newest jobs are kept; older finished jobs are deleted on every new job and by skillhook jobs prune [--keep N].
- Keep
hoston127.0.0.1and expose through Tailscale, Cloudflare Tunnel or ngrok. - One secret per skill; never reuse a provider secret as a bearer token.
- Prefer provider signatures (
github,sentry,granola,stripe,slack, …) overbearerwhen the sender supports them. - Add
whenfilters so only the events you expect start an agent. - Use
allow_ips: ["100.64.0.0/10"]withexpose tailscale --servefor internal-only skills. - Scope agents:
cwd,acceptEdits+allowed_tools,max_budget_usd, Codex sandboxes, timeouts. - Set
enabled: falseon skills you are not using instead of leaving them reachable. - Run
skillhook doctorafter changes; it flagsauth: none, missing secrets and a permissive.envmode.
To report a vulnerability, email hello@meterapp.co instead of opening a public issue (see SECURITY.md).