Skip to content

Latest commit

 

History

History
294 lines (211 loc) · 18.7 KB

File metadata and controls

294 lines (211 loc) · 18.7 KB

Security

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).

Threat model

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 bypassPermissions mode, a Claude job can do anything your user can (edit files anywhere, run commands, use the network). Reduce the blast radius per skill with cwd, claude.permission_mode: acceptEdits plus allowed_tools/disallowed_tools, max_budget_usd, a Codex read-only or workspace-write sandbox, 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. when filters and allow_ips narrow 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.

Network posture

  • The server binds host: 127.0.0.1 by 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 expose prints a note if the host is not loopback.
  • trust_proxy: true (default) makes skillhook use the first X-Forwarded-For (or X-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 /health is 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 use skillhook expose tailscale --serve and add allow_ips: ["100.64.0.0/10"] to skills.

Outbound connections

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.

Authentication schemes

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>.

none

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"]

bearer (default)

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                     # default
skillhook secret generate deploy-notes          # prints the value once; --force rotates

Error codes: missing_token, invalid_token.

basic

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.

hmac (generic)

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-delivery

Error codes: missing_signature, invalid_signature, missing_timestamp, stale_timestamp.

github

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_SECRET
auth:
  type: github
  secret_env: GH_WEBHOOK_SECRET
when:
  - header: x-github-event
    equals: issues
  - path: action
    equals: opened

sentry

Preset 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_SECRET
auth:
  type: sentry
  secret_env: SENTRY_CLIENT_SECRET
when:
  - header: sentry-hook-resource
    equals: issue
  - path: action
    equals: created          # created | resolved | assigned | archived | unresolved

linear

Preset 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_SECRET

standard-webhooks, svix, granola

The Standard Webhooks scheme (used by Svix and everyone built on it, including Granola):

  • Headers webhook-id, webhook-timestamp (unix seconds), webhook-signature containing one or more space-separated v1,<base64> entries (svix-id / svix-timestamp / svix-signature are 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-id is 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_SECRET
auth:
  type: granola
  secret_env: GRANOLA_WEBHOOK_SECRET

Error codes: missing_signature, invalid_timestamp, stale_timestamp, invalid_signature.

stripe

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]

slack

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_mention

IP allow-lists

allow_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.

Secrets

  • Storage: <home>/.env, dotenv format, created and kept at mode 0600 (doctor warns otherwise). skillhook secret set|generate|unset edit single keys and preserve comments.
  • Precedence: the server's process environment overrides the file (SKILLHOOK_SECRET_HELLO=… skillhook serve wins).
  • Reading: secrets are re-read from disk on every request, so rotations apply immediately.
  • Display: secret list prints names and a redacted preview only. secret generate prints a new value exactly once; the MCP generate_secret and create_skill tools return it once. Nothing else ever returns a secret value.
  • Names: secret set|generate|unset <name> accepts an ENV_VAR_NAME, a skill name (resolved to its secret_env) or admin (SKILLHOOK_ADMIN_TOKEN). Generated values are 32 random bytes, base64url (43 characters).
  • Rotation: skillhook secret generate <skill> --force (or secret rotate <skill>), then update the sender. For provider secrets rotate on the provider side and secret set the new value.

What reaches the agent

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.

Prompt injection

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_bytes are 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: acceptEdits with an allowed_tools list, disallowed_tools: [WebFetch] when the task does not need the web, a Codex read-only sandbox for analysis-only skills, max_budget_usd, and a realistic timeout_seconds.
  • Do not put secrets in the SKILL.md body; expose them through env: and reference $NAME in 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.

Limits

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.

Admin API

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.

Files on disk

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].

Checklist

  • Keep host on 127.0.0.1 and 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, …) over bearer when the sender supports them.
  • Add when filters so only the events you expect start an agent.
  • Use allow_ips: ["100.64.0.0/10"] with expose tailscale --serve for internal-only skills.
  • Scope agents: cwd, acceptEdits + allowed_tools, max_budget_usd, Codex sandboxes, timeouts.
  • Set enabled: false on skills you are not using instead of leaving them reachable.
  • Run skillhook doctor after changes; it flags auth: none, missing secrets and a permissive .env mode.

To report a vulnerability, email hello@meterapp.co instead of opening a public issue (see SECURITY.md).