From 48eadf0bf6de9a28c5e5efacdc3e49fb9b65f9c1 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Tue, 13 Oct 2026 23:53:46 -0400 Subject: [PATCH] Preserve explicitly public environment values in cloud uploads --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- .cursor-plugin/plugin.json | 2 +- AGENTS.md | 2 +- CHANGELOG.md | 4 ++++ docs/cloud-protocol.md | 2 +- docs/cloud.md | 25 ++++++++++++++++++++----- docs/operations.md | 1 + package-lock.json | 4 ++-- package.json | 2 +- schema/skillhook.schema.json | 7 +++++++ src/cloud/commands.ts | 4 ++-- src/cloud/config.test.ts | 2 +- src/cloud/link.test.ts | 23 ++++++++++++++++++++++- src/cloud/link.ts | 6 +++--- src/cloud/redact.test.ts | 13 +++++++++++++ src/cloud/redact.ts | 19 +++++++++++++++---- src/cloud/report.ts | 8 ++++---- src/cloud/snapshot.ts | 2 +- src/config.test.ts | 10 ++++++++++ src/config.ts | 3 +++ 21 files changed, 114 insertions(+), 29 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index cff84ad..38ecf3f 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "skillhook", "displayName": "skillhook", - "version": "0.10.1", + "version": "0.10.2", "description": "Turn this machine into a permanent, secure webhook endpoint that runs Agent Skills with Claude Code or Codex, and run your whole Skillhook Cloud organisation from the agent. Three skills teach the agent to install and expose skillhook, to write good webhook skills and to operate the fleet; two MCP servers manage this machine (skills, secrets, jobs, exposure) and, with an organisation API key, every machine as the Skillhook Cloud dashboard does (what needs a person, failures, stats, answers, replays, runs, hosted URLs).", "author": { "name": "Meter", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 21cf425..398ea87 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "skillhook", - "version": "0.10.1", + "version": "0.10.2", "description": "Turn this machine into a permanent, secure webhook endpoint that runs Agent Skills with Claude Code or Codex, and run your whole Skillhook Cloud organisation from the agent. Three skills teach the agent to install and expose skillhook, to write good webhook skills and to operate the fleet; two MCP servers manage this machine (skills, secrets, jobs, exposure) and, with an organisation API key, every machine as the Skillhook Cloud dashboard does (what needs a person, failures, stats, answers, replays, runs, hosted URLs).", "author": { "name": "Meter", diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 1db8d9a..0b12558 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "skillhook", - "version": "0.10.1", + "version": "0.10.2", "description": "Turn this machine into a permanent, secure webhook endpoint that runs Agent Skills with Claude Code or Codex, and run your whole Skillhook Cloud organisation from the agent. Three skills teach the agent to install and expose skillhook, to write good webhook skills and to operate the fleet; two MCP servers manage this machine (skills, secrets, jobs, exposure) and, with an organisation API key, every machine as the Skillhook Cloud dashboard does (what needs a person, failures, stats, answers, replays, runs, hosted URLs).", "author": { "name": "Meter", diff --git a/AGENTS.md b/AGENTS.md index 81abd66..23bdcb8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,7 +62,7 @@ Runtime state lives outside the repo in `~/.skillhook` (`SKILLHOOK_HOME`): - **Every CLI command supports `--json`** and returns non-zero on failure. Register new commands in `COMMANDS` (with the usage text) and `HELP` in `src/commands/main.ts`, then in the README table. `--help` / `-h` never reaches a command: `main` prints the usage from `COMMANDS` instead, so a command never checks for it; a new subcommand gets a line in the `--help` test of `src/cli.test.ts`. - **Third-party facts** (Granola, Sentry, GitHub, Tailscale) are stated in `docs/` and the examples with the exact header names; change them only with a source. - **Tests are hermetic**: `tempHome()` from `src/test-support/helpers.ts`, fake runners, ephemeral ports. Never touch `~/.skillhook`, the real `claude`/`codex`, `launchctl` or `tailscale` from a test. Never reach the real npm registry either: point `SKILLHOOK_NPM_REGISTRY` at a local `node:http` server or set `SKILLHOOK_NO_UPDATE_CHECK=1`. Nor Skillhook Cloud: `cloud.url` defaults to the live service (`DEFAULT_CLOUD_URL`), so a test gives a `FakeCloud` URL (`SKILLHOOK_CLOUD_URL`, `cloud.url`, `--url`), or answers what is sent to the default itself (a `fetchImpl`, or a stubbed `fetch` that hands it to a `FakeCloud`). -- **Outbound requests are opt-in and enumerated.** By default the CLI phones home at most once an hour, and only for updates (`src/update.ts`: the registry's `latest` dist-tag, cached 1 h, from a detached process so no command waits, never in CI or when `SKILLHOOK_NO_UPDATE_CHECK` / `update_check: false` say so); with `auto_update` (the default) that process also installs a newer version of skillhook itself, from the same registry, with the package manager that installed it, and nothing else. The one other outbound connection is the Skillhook Cloud link (`src/cloud/link.ts`), and only after `skillhook cloud connect` wrote `cloud.enabled` and `SKILLHOOK_CLOUD_TOKEN`: it talks to `cloud.url` over HTTPS only, sends only what `docs/cloud.md` lists (redacted, scrubbed of every `.env` value), obeys `cloud.mode` and the local allow/deny lists (which the cloud cannot change), and stops on `cloud.enabled: false`, `SKILLHOOK_NO_CLOUD=1` or `cloud disconnect`. Never enable it by default, from `init` or from a job. Besides the link, the person-invoked cloud commands send one request each, only when run, only to the machine's cloud URL (`cloud.url` or `SKILLHOOK_CLOUD_URL`, HTTPS only): `cloud connect` / `disconnect` (pairing, revocation), `cloud report` and the MCP tool `cloud_report_issue` (`src/cloud/report.ts`: the person's text plus the diagnostics `docs/cloud.md` lists, scrubbed like the link's uploads, with the machine token), and `cloud login|overview|machines|jobs|job|tools||secret` and the tools of `skillhook mcp --cloud` (`src/cloud/api.ts`, `tools.ts`, `bridge.ts`, `remote-secret.ts`: requests with the person's organisation API key `SKILLHOOK_CLOUD_API_KEY`, never with the machine token, only to the cloud the key was checked against at login (`SKILLHOOK_CLOUD_API_URL`), each one a command the person ran or a tool their agent called; `cloud login` without `--key`, and `skillhook_cloud_setup` without a working key, sign in with the browser instead (`src/cloud/login.ts`): `POST /api/auth/device/code` with this computer's name, then `POST /api/auth/device/token` with the device code every few seconds until a person approves it on the cloud, ten minutes at most, and the page opened with the system's opener (argv, no shell; not over SSH or without a display), the key it gets kept like a pasted one; `skillhook mcp --cloud` also reads the catalogue once when it starts; skillhook's own tools do not move them for an agent: the local MCP refuses `cloud.*` settings and `SKILLHOOK_CLOUD_*` secrets, and no skill's `secret_env` may name skillhook's own credentials; an agent that runs commands acts with the person's rights all the same); the last two groups refuse under `SKILLHOOK_NO_CLOUD=1`. Do not add other outbound requests the user did not ask for, and never install anything automatically but skillhook's own update. +- **Outbound requests are opt-in and enumerated.** By default the CLI phones home at most once an hour, and only for updates (`src/update.ts`: the registry's `latest` dist-tag, cached 1 h, from a detached process so no command waits, never in CI or when `SKILLHOOK_NO_UPDATE_CHECK` / `update_check: false` say so); with `auto_update` (the default) that process also installs a newer version of skillhook itself, from the same registry, with the package manager that installed it, and nothing else. The one other outbound connection is the Skillhook Cloud link (`src/cloud/link.ts`), and only after `skillhook cloud connect` wrote `cloud.enabled` and `SKILLHOOK_CLOUD_TOKEN`: it talks to `cloud.url` over HTTPS only, sends only what `docs/cloud.md` lists (redacted, scrubbed of private `.env` values), obeys `cloud.mode` and the local allow/deny lists (which the cloud cannot change), and stops on `cloud.enabled: false`, `SKILLHOOK_NO_CLOUD=1` or `cloud disconnect`. Never enable it by default, from `init` or from a job. Besides the link, the person-invoked cloud commands send one request each, only when run, only to the machine's cloud URL (`cloud.url` or `SKILLHOOK_CLOUD_URL`, HTTPS only): `cloud connect` / `disconnect` (pairing, revocation), `cloud report` and the MCP tool `cloud_report_issue` (`src/cloud/report.ts`: the person's text plus the diagnostics `docs/cloud.md` lists, scrubbed like the link's uploads, with the machine token), and `cloud login|overview|machines|jobs|job|tools||secret` and the tools of `skillhook mcp --cloud` (`src/cloud/api.ts`, `tools.ts`, `bridge.ts`, `remote-secret.ts`: requests with the person's organisation API key `SKILLHOOK_CLOUD_API_KEY`, never with the machine token, only to the cloud the key was checked against at login (`SKILLHOOK_CLOUD_API_URL`), each one a command the person ran or a tool their agent called; `cloud login` without `--key`, and `skillhook_cloud_setup` without a working key, sign in with the browser instead (`src/cloud/login.ts`): `POST /api/auth/device/code` with this computer's name, then `POST /api/auth/device/token` with the device code every few seconds until a person approves it on the cloud, ten minutes at most, and the page opened with the system's opener (argv, no shell; not over SSH or without a display), the key it gets kept like a pasted one; `skillhook mcp --cloud` also reads the catalogue once when it starts; skillhook's own tools do not move them for an agent: the local MCP refuses `cloud.*` settings and `SKILLHOOK_CLOUD_*` secrets, and no skill's `secret_env` may name skillhook's own credentials; an agent that runs commands acts with the person's rights all the same); the last two groups refuse under `SKILLHOOK_NO_CLOUD=1`. Do not add other outbound requests the user did not ask for, and never install anything automatically but skillhook's own update. ## Checks diff --git a/CHANGELOG.md b/CHANGELOG.md index 0763af6..e6d6694 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to skillhook, newest first. The format follows [Keep a Chang ## Unreleased +## 0.10.2 (2026-10-14) + +- Preserve public configuration in cloud uploads through the local `cloud.public_env` declaration, fixing GitHub repository slugs redacted inside job links. Unknown values and credential variables stay private; cloud commands cannot change this policy. + - Reuse identical in-flight replay work in HTTP and cloud recovery; alternate eligible fresh/replay queue slots and serialize shared Git worktrees by default, with `serialize_cwd` overrides for coordinated parallel jobs or non-Git directories. ## 0.10.1 (2026-10-13) diff --git a/docs/cloud-protocol.md b/docs/cloud-protocol.md index 32b2d8e..ffd1549 100644 --- a/docs/cloud-protocol.md +++ b/docs/cloud-protocol.md @@ -79,7 +79,7 @@ Events carry a per-machine `seq`; the cloud de-duplicates on `(machine_id, seq)` | `report_id?` | A client-generated idempotency key, 8 to 100 of `A-Z a-z 0-9 _ -`: a retry with the same `report_id` returns the original report instead of filing a second one. | | `diagnostics?` | `IssueDiagnostics`: `skillhook_version`, `node_version`, `os`, `arch`, `mode`, `link {state, reason?, last_error? (≤ 500)}`, `runners [{runner, ready}]` (≤ 3), `health {ok, summary {ok, warn, fail, skip}, failing? [{id (≤ 200), status, message? (≤ 500)}] (≤ 50)}`; every field optional, unknown fields kept (`.loose()`). | -The request is strict (unknown fields are refused). The machine scrubs every `.env` value from the title, the body and the diagnostics before sending, like everything the link uploads, and never attaches payloads, logs, prompts or job output. The answer is `IssueReportResponse` `{ok: true, issue_id, number, url, acknowledged}`: the issue's id, its number and URL on the dashboard, and whether a confirmation email went out; it is the same for a new report and for a retry of one. `skillhook cloud report` generates a `report_id` (a UUID) per report and sends it with every attempt: it retries network errors, timeouts and `5xx` (three attempts in all), a `429` only after the `retry_after_ms` it asked for (when that is at most 15 seconds), and never another `4xx`. Errors have the agent API's shape (`{ok: false, error, message, retry_after_ms?}`, as `SyncError`): `401 invalid_token`, `403 machine_disabled`, `400 invalid_request`, `413 payload_too_large`, `429 rate_limited` (with `retry_after_ms`), `500 server_error`. The endpoint and its schemas are additive, so `PROTOCOL_VERSION` stays 1; a cloud without the route answers `404`, which the CLI reports as such. +The request is strict (unknown fields are refused). The machine scrubs private `.env` values from the title, the body and the diagnostics before sending, like everything the link uploads, and never attaches payloads, logs, prompts or job output. The answer is `IssueReportResponse` `{ok: true, issue_id, number, url, acknowledged}`: the issue's id, its number and URL on the dashboard, and whether a confirmation email went out; it is the same for a new report and for a retry of one. `skillhook cloud report` generates a `report_id` (a UUID) per report and sends it with every attempt: it retries network errors, timeouts and `5xx` (three attempts in all), a `429` only after the `retry_after_ms` it asked for (when that is at most 15 seconds), and never another `4xx`. Errors have the agent API's shape (`{ok: false, error, message, retry_after_ms?}`, as `SyncError`): `401 invalid_token`, `403 machine_disabled`, `400 invalid_request`, `413 payload_too_large`, `429 rate_limited` (with `retry_after_ms`), `500 server_error`. The endpoint and its schemas are additive, so `PROTOCOL_VERSION` stays 1; a cloud without the route answers `404`, which the CLI reports as such. ## Sealed values diff --git a/docs/cloud.md b/docs/cloud.md index 44a485f..eb0b6b2 100644 --- a/docs/cloud.md +++ b/docs/cloud.md @@ -8,7 +8,7 @@ - **Opt-in, outbound only.** A machine talks to the cloud only after `skillhook cloud connect` pairs it (a code from the dashboard) and only by opening HTTPS requests to `cloud.url` (plain `http` only to a loopback address or with `SKILLHOOK_CLOUD_ALLOW_INSECURE=1`); the cloud never connects to the machine and never holds the admin token. It works behind NAT without Tailscale. Besides the link, [`cloud report`](#reporting-a-problem) and the [API-key commands and tools](#the-whole-organisation-with-an-api-key) send requests to the same URL only when a person runs one, or their agent calls one. - **Observe by default.** A freshly paired machine is in `mode: observe`: the cloud can read, not act. `--control` at pairing (what the dashboard's pairing page prints) or `cloud.mode: control` later lets it run skills, answer jobs, change the configuration and restart the server. `cloud.allow_commands` / `cloud.deny_commands` refine either mode per command type; the cloud cannot raise a machine's exposure, only the machine's own config can. -- **Payloads are data, secrets stay home.** Headers are redacted on the machine before anything is uploaded; every uploaded string is scrubbed against every value in `.env`; webhook bodies (a delivery's, and a job's `payload`, `event` and `prompt`, which quotes it) travel only when both `cloud.upload_payloads` and the organisation's policy allow, and never beyond 256 KiB. What an agent writes is its own output and can quote anything it read, the body included: its transcript goes only with `cloud.upload_artifacts`, its result with the job record (the first 8 KiB). `SKILLHOOK_CLOUD_*` variables never reach a run, even when a skill lists them in `env:`. A secret the cloud asks skillhook to generate is sealed to the requester's key; the cloud never stores it in the clear. +- **Payloads are data, secrets stay home.** Headers are redacted on the machine before anything is uploaded; every uploaded string is scrubbed against private values in `.env`; webhook bodies (a delivery's, and a job's `payload`, `event` and `prompt`, which quotes it) travel only when both `cloud.upload_payloads` and the organisation's policy allow, and never beyond 256 KiB. What an agent writes is its own output and can quote anything it read, the body included: its transcript goes only with `cloud.upload_artifacts`, its result with the job record (the first 8 KiB). `SKILLHOOK_CLOUD_*` variables never reach a run, even when a skill lists them in `env:`. A secret the cloud asks skillhook to generate is sealed to the requester's key; the cloud never stores it in the clear. - **Kill switches.** `cloud.enabled: false`, `SKILLHOOK_NO_CLOUD=1` in the server's environment, or `skillhook cloud disconnect` stop all traffic; the link never starts from `init`, from a job, or on its own. With `SKILLHOOK_NO_CLOUD=1` in its environment, `cloud report` and the API-key commands refuse to send anything too. ## Settings @@ -49,6 +49,21 @@ skillhook cloud disconnect # cloud.enabled: false, token removed from .env an `disconnect --keep-token` leaves the token in `.env`. `skillhook doctor` and `skillhook health` report a `cloud link` check: skipped when not connected, failing when `cloud.enabled` has no token, an `http` URL, or a revoked token or disabled machine, warning when no server runs, the link is degraded or events were dropped. +## Public environment configuration + +The upload scrubber treats unknown `.env` values as private. If that file also contains public configuration, such as a +repository slug, declare its variable name locally so GitHub links remain usable: + +```bash +skillhook config set cloud.public_env '["METER_REPO_SLUG"]' +``` + +Only list values you intend to make public. Credential names (token, secret, password, key, auth, cookie, credential, +signature) and `SKILLHOOK_*` names are refused. A public value that also appears under a private variable still gets +redacted. The cloud cannot change this setting. It applies live to subsequent uploads, artifact reads, diagnostics and +command results; already scrubbed cloud records need to be refreshed from the machine's original job, not guessed or +replayed (a replay runs the skill again). + ## Reporting a problem A person on a paired machine can tell the Skillhook team about a problem without leaving the terminal: @@ -68,7 +83,7 @@ It sends one request, `POST {cloud.url}/api/agent/issues` with the machine token - `report_id`: a UUID generated for this report, the same on every attempt, so the cloud files it once however often it is retried; - unless `--no-diagnostics`, `diagnostics`: `skillhook_version`, `node_version`, `os`, `arch`, the machine's `cloud.mode`, the link's `state`, `reason` and `last_error` (from the running server), whether each runner is ready (`{runner, ready}` only), and the health summary (how many checks are ok, warn, fail, skip) with the checks that fail or warn (`id`: the check's name, `status`, `message`: its detail line; at most 50). With a running server these are its cached answers; without one, a quick local check (the doctor's checks, no network probes) and the runners' readiness. -Every value in `.env` is replaced with `[redacted]` in the title, the body, the references and the diagnostics before anything leaves, as for everything the link sends (a contact address that is itself a value from `.env` is refused). Nothing else goes: never payloads, logs, prompts, job output, command lines, environments or `.env` itself. `--dry-run` prints the JSON that would be sent. The answer is `Reported as #N: ` and whether a confirmation email went out; `--json` prints the cloud's answer (`{ok, issue_id, number, url, acknowledged}`). Network errors, timeouts and `5xx` answers are retried (three attempts), a `429` only when the cloud asks for a pause of at most 15 seconds, another `4xx` never. +Every private value in `.env` is replaced with `[redacted]` in the title, the body, the references and the diagnostics before anything leaves, as for everything the link sends (a contact address that is itself a value from `.env` is refused). Nothing else goes: never payloads, logs, prompts, job output, command lines, environments or `.env` itself. `--dry-run` prints the JSON that would be sent. The answer is `Reported as #N: ` and whether a confirmation email went out; `--json` prints the cloud's answer (`{ok, issue_id, number, url, acknowledged}`). Network errors, timeouts and `5xx` answers are retried (three attempts), a `429` only when the cloud asks for a pause of at most 15 seconds, another `4xx` never. It needs a paired machine (`cloud.enabled` and `SKILLHOOK_CLOUD_TOKEN`); on one that is not, pair it first or report the problem on the dashboard or through its hosted MCP server. It refuses under `SKILLHOOK_NO_CLOUD=1` and to a `cloud.url` that is not https. The MCP tool `cloud_report_issue` sends the same report for an agent the person asked to file one ([mcp.md](mcp.md)). @@ -186,7 +201,7 @@ The running server opens HTTPS requests to `cloud.url` (`POST /api/agent/sync`); - **A deep health report** every `cloud.health_interval_seconds`. - **Command results** and **hosted-ingress acknowledgements** (below). -Every string is scrubbed of every value in `.env` before it leaves, `authorization`, cookie, signature and token headers never leave, and command lines, environments and `.env` itself never do. Events wait in `jobs/.cloud/outbox.jsonl` while the cloud is unreachable (at most `cloud.outbox_max_events`, oldest dropped first and counted); a server restart loses nothing that was spooled. On errors the link backs off exponentially up to a minute, is reported `degraded` after three failures, stops on a revoked token (`401`) or a disabled machine (`403`) until the configuration or the token changes, halves its batches on `413`, honours `429`'s retry delay and waits ten minutes on `426` (update skillhook). +Every string is scrubbed of private values in `.env` before it leaves, `authorization`, cookie, signature and token headers never leave, and command lines, environments and `.env` itself never do. Events wait in `jobs/.cloud/outbox.jsonl` while the cloud is unreachable (at most `cloud.outbox_max_events`, oldest dropped first and counted); a server restart loses nothing that was spooled. On errors the link backs off exponentially up to a minute, is reported `degraded` after three failures, stops on a revoked token (`401`) or a disabled machine (`403`) until the configuration or the token changes, halves its batches on `413`, honours `429`'s retry delay and waits ten minutes on `426` (update skillhook). ## Hosted URLs @@ -194,7 +209,7 @@ A skill can have a hosted webhook URL on the cloud (the dashboard creates it) in ## What never leaves the machine -`.env` and every value in it, the admin token, command lines and run environments, `authorization` / cookie / signature / token headers, job artifacts unless `cloud.upload_artifacts` allows them and a command asks, webhook bodies (deliveries, and a job's `payload`, `event` and `prompt`) unless `cloud.upload_payloads` allows them, and anything a command policy refuses. An agent's transcript and result are its own output: they can quote what it read, the body included. +`.env` and its private values, the admin token, command lines and run environments, `authorization` / cookie / signature / token headers, job artifacts unless `cloud.upload_artifacts` allows them and a command asks, webhook bodies (deliveries, and a job's `payload`, `event` and `prompt`) unless `cloud.upload_payloads` allows them, and anything a command policy refuses. An agent's transcript and result are its own output: they can quote what it read, the body included. ## Commands the cloud may send @@ -229,6 +244,6 @@ What each control command does, and the rules it adds on top of the policy: ## Live output and artifacts -`job.watch` streams a job's `stdout` (or `stderr`) to the cloud as `job.output` events: complete lines, at most 64 KiB per job every two seconds, for at most five jobs at once, until the job ends (`eof: true` with its status) or `ttl_s` runs out (`expired: true`). `job.artifact` returns an artifact inline up to 256 KiB (`max_inline_bytes`) and uploads a larger one (up to 32 MiB) in 1 MiB chunks. Both are scrubbed of `.env` values and need `cloud.upload_artifacts`. +`job.watch` streams a job's `stdout` (or `stderr`) to the cloud as `job.output` events: complete lines, at most 64 KiB per job every two seconds, for at most five jobs at once, until the job ends (`eof: true` with its status) or `ttl_s` runs out (`expired: true`). `job.artifact` returns an artifact inline up to 256 KiB (`max_inline_bytes`) and uploads a larger one (up to 32 MiB) in 1 MiB chunks. Both are scrubbed of private `.env` values and need `cloud.upload_artifacts`. Allow-list only: `secret.set` (a value sealed to this machine's key). diff --git a/docs/operations.md b/docs/operations.md index 1235fab..59e1626 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -223,6 +223,7 @@ Once the cause is fixed (a secret pasted, a filter corrected, a skill installed) | `deliveries.body_max_bytes` | `65536` | How much of such a body is kept. | | `env_passthrough` | `[]` | Extra env var names copied into every run. | | `projects` | `[]` | Linked repositories (absolute paths, `~` allowed; a directory holding `skillhook.yaml`, or the file itself). Written by `skillhook link` / `unlink`; re-read without a restart. See [projects.md](projects.md). | +| `cloud.public_env` | `[]` | Locally declared public `.env` variable names excluded from upload value scrubbing; credentials are refused and the cloud cannot change this policy. [cloud.md](cloud.md#public-environment-configuration). | | `cloud.*` | `enabled: false`, `mode: observe`, … | The opt-in link to Skillhook Cloud, written by `skillhook cloud connect`: [cloud.md](cloud.md). Nothing leaves the machine while `cloud.enabled` is false. | | `log_level` | `"info"` | `debug`, `info`, `warn`, `error`. | | `update_check` | `true` | Hourly background check of the npm registry for a newer skillhook (`SKILLHOOK_NO_UPDATE_CHECK=1` and `CI` disable it as well, and with it `auto_update`). | diff --git a/package-lock.json b/package-lock.json index 5269415..e5dac75 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@meterapp/skillhook", - "version": "0.10.1", + "version": "0.10.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@meterapp/skillhook", - "version": "0.10.1", + "version": "0.10.2", "license": "MIT", "dependencies": { "@modelcontextprotocol/server": "^2.2.0", diff --git a/package.json b/package.json index 930f445..3a36986 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@meterapp/skillhook", - "version": "0.10.1", + "version": "0.10.2", "description": "Make your Agent Skills reactive. A permanent, secure webhook endpoint on your own machine that runs SKILL.md files with Claude Code or Codex the moment something happens, plus version-controlled schedules for the work that has no trigger. Webhook in, agent out.", "license": "MIT", "author": "Meter (https://meterapp.co)", diff --git a/schema/skillhook.schema.json b/schema/skillhook.schema.json index 9fbf2ba..54941fc 100644 --- a/schema/skillhook.schema.json +++ b/schema/skillhook.schema.json @@ -390,6 +390,13 @@ "default": true, "type": "boolean" }, + "public_env": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, "upload_artifacts": { "default": true, "type": "boolean" diff --git a/src/cloud/commands.ts b/src/cloud/commands.ts index cfe5a35..a31469c 100644 --- a/src/cloud/commands.ts +++ b/src/cloud/commands.ts @@ -155,7 +155,7 @@ const readHandlers: Partial> = { if (!existsSync(file)) throw new CommandError("not_found", `job ${args.id} has no ${args.name}`); const size = statSync(file).size; const max = args.max_inline_bytes ?? ARTIFACT_INLINE_DEFAULT; - const values = secretValues(deps.fileSecrets()); + const values = secretValues(deps.fileSecrets(), deps.config.cloud.public_env); if (size <= max || !deps.upload) { const text = deps.store.readArtifact(job.id, args.name, max) ?? ""; return { result: { job_id: job.id, name: args.name, bytes: size, text: scrubSecrets(text, values), truncated: size > max } }; @@ -216,7 +216,7 @@ export interface CommandDispatcher { export function createCommandDispatcher(deps: CommandDeps): CommandDispatcher { const finish = (command: Command, startedAt: string, outcome: { ok: true; result: unknown; sensitive?: boolean; sealed?: Sealed } | { ok: false; error: { code: CommandErrorCode; message: string; hint?: string } }): CommandResult => { const finished = nowIso(); - const values = secretValues(deps.fileSecrets()); + const values = secretValues(deps.fileSecrets(), deps.config.cloud.public_env); const base = { command_id: command.id, started_at: startedAt, finished_at: finished, duration_ms: Math.max(0, Date.parse(finished) - Date.parse(startedAt)) }; // Even a sensitive result is scrubbed of .env values: what must travel does so sealed, never in `result`. const result: CommandResult = outcome.ok diff --git a/src/cloud/config.test.ts b/src/cloud/config.test.ts index d2bbfe1..d4edebf 100644 --- a/src/cloud/config.test.ts +++ b/src/cloud/config.test.ts @@ -8,7 +8,7 @@ import { assertSecureCloudUrl, CLOUD_TOKEN_ENV, cloudDisabledByEnv, commandAllow describe("cloud config", () => { it("has safe defaults and validates the cloud block", () => { const paths = tempHome("skillhook-cloud-"); - expect(loadConfig(paths).cloud).toEqual({ enabled: false, url: DEFAULT_CLOUD_URL, mode: "observe", allow_commands: [], deny_commands: [], upload_payloads: true, upload_artifacts: true, ingress: true, snapshot_interval_seconds: 60, health_interval_seconds: 600, outbox_max_events: 5000 }); + expect(loadConfig(paths).cloud).toEqual({ enabled: false, url: DEFAULT_CLOUD_URL, mode: "observe", allow_commands: [], deny_commands: [], upload_payloads: true, public_env: [], upload_artifacts: true, ingress: true, snapshot_interval_seconds: 60, health_interval_seconds: 600, outbox_max_events: 5000 }); writeConfigFile(paths, { cloud: { enabled: true, url: "https://cloud.example", machine_id: "m_1", mode: "control", allow_commands: ["secret.set"], deny_commands: ["update.*"] } }); expect(loadConfig(paths).cloud).toMatchObject({ enabled: true, url: "https://cloud.example", machine_id: "m_1", mode: "control", allow_commands: ["secret.set"], deny_commands: ["update.*"] }); writeConfigFile(paths, { cloud: { mode: "root" } }); diff --git a/src/cloud/link.test.ts b/src/cloud/link.test.ts index ae69808..76292cf 100644 --- a/src/cloud/link.test.ts +++ b/src/cloud/link.test.ts @@ -5,7 +5,7 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { ConfigRef, loadConfig, type Config } from "../config.js"; import { DeliveryLog } from "../delivery-log.js"; -import { loadSecrets, readEnvFile } from "../env.js"; +import { loadSecrets, readEnvFile, upsertEnvVar } from "../env.js"; import { Events } from "../events.js"; import { JobStore, type JobRecord } from "../jobs.js"; import { silentLogger } from "../logger.js"; @@ -113,6 +113,25 @@ function ingress(partial: Partial & { id: string }): IngressItem { } describe("CloudLink", () => { + it("uploads valid repo links in events and cloud job reads with locally declared public configuration", async () => { + const { fake, link, events, store, paths, config } = await setup(); + upsertEnvVar(paths.envFile, "METER_REPO_SLUG", "ExampleOrg/example-repo"); + config.cloud.public_env = ["METER_REPO_SLUG"]; + link.start(); + await fake.waitFor(() => fake.requests.length >= 1); + const job = newJob(store); + const url = "https://github.com/ExampleOrg/example-repo/pull/16223"; + const finished = store.update(job.id, { status: "succeeded", response: { outcome: "completed", links: [{ url, title: "Fix", kind: "pull_request" }], summary: SECRET } }); + events.emit("job.finished", { job: finished }); + await fake.waitFor(() => fake.requests.some((r) => r.events.some((e) => e.type === "job.finished"))); + const uploaded = fake.requests.flatMap((r) => r.events).find((e) => e.type === "job.finished")!; + expect(JSON.stringify(uploaded.data)).toContain(url); + expect(JSON.stringify(uploaded.data)).not.toContain(SECRET); + const results = await runCommands(fake, [cmd("public-read", "job.get", { id: job.id })]); + expect(JSON.stringify(results["public-read"])).toContain(url); + expect(JSON.stringify(results["public-read"])).not.toContain(SECRET); + }); + it("opens with link.started and a snapshot, then uploads events redacted and scrubbed of .env values", async () => { const { fake, link, events, store } = await setup(); link.start(); @@ -367,6 +386,7 @@ describe("CloudLink", () => { cmd("answer-again", "job.answer", { id: needy.id, answer: "main" }), cmd("patch", "config.patch", { set: { concurrency: 3 } }), cmd("patch-runner", "config.patch", { set: { "runners.claude.command": "/bin/sh" } }), + cmd("patch-public-env", "config.patch", { set: { "cloud.public_env": ["API_TOKEN"] } }), cmd("patch-cloud", "config.patch", { set: { "cloud.mode": "observe" } }), cmd("patch-bad", "config.patch", { set: { concurrency: "lots" } }), cmd("sched", "schedule.run", { name: "hello" }), @@ -380,6 +400,7 @@ describe("CloudLink", () => { expect(second.patch).toMatchObject({ ok: true, result: { applied: ["concurrency"] } }); expect(config.concurrency).toBe(3); expect(second["patch-runner"]).toMatchObject({ ok: false, error: { code: "denied_by_policy" } }); + expect(second["patch-public-env"]).toMatchObject({ ok: false, error: { code: "denied_by_policy" } }); expect(second["patch-cloud"]).toMatchObject({ ok: false, error: { code: "denied_by_policy" } }); expect(second["patch-bad"]).toMatchObject({ ok: false, error: { code: "invalid_args" } }); expect(config.cloud.mode).toBe("control"); diff --git a/src/cloud/link.ts b/src/cloud/link.ts index 54bdcc5..676bba8 100644 --- a/src/cloud/link.ts +++ b/src/cloud/link.ts @@ -308,7 +308,7 @@ export class CloudLink { private pumpWatches(): void { const machineId = this.deps.config.cloud.machine_id; if (!machineId) return; - const values = secretValues(this.deps.fileSecrets()); + const values = secretValues(this.deps.fileSecrets(), this.deps.config.cloud.public_env); for (const [jobId, watch] of [...this.watches]) { const job = this.deps.store.get(jobId); const finished = !job || isTerminal(job.status); @@ -433,7 +433,7 @@ export class CloudLink { } private prepare(event: SkillhookEvent): unknown { - const values = secretValues(this.deps.fileSecrets()); + const values = secretValues(this.deps.fileSecrets(), this.deps.config.cloud.public_env); const as = (_type: K) => event as SkillhookEvent; let data: unknown; switch (event.type) { @@ -623,7 +623,7 @@ export class CloudLink { this.lastHealthAt = now; void this.deps.health .get({ deep: true, network: false }) - .then(({ report }) => this.outbox.append(machineId, "health.report", scrubSecrets(report, secretValues(this.deps.fileSecrets())))) + .then(({ report }) => this.outbox.append(machineId, "health.report", scrubSecrets(report, secretValues(this.deps.fileSecrets(), this.deps.config.cloud.public_env)))) .catch((error: unknown) => this.deps.logger.warn("health report for the cloud failed", { error: errorMessage(error) })); } const events = this.outbox.pending(Math.min(this.maxBatch, this.hints.max_batch_events ?? LIMITS.max_events_per_sync), LIMITS.max_sync_bytes - 512 * 1024); diff --git a/src/cloud/redact.test.ts b/src/cloud/redact.test.ts index 8cc4bf1..8c899cb 100644 --- a/src/cloud/redact.test.ts +++ b/src/cloud/redact.test.ts @@ -31,6 +31,19 @@ describe("redaction for uploads", () => { expect(JSON.stringify(scrubSecrets(input, FAKE_ENV))).not.toContain("placeholder-hello-value"); }); + it("preserves explicitly public repo links while keeping unknown and overlapping secrets private", () => { + const env = { METER_REPO_SLUG: "ExampleOrg/example-repo", UNKNOWN_VALUE: "placeholder-private-value" }; + const url = "https://github.com/ExampleOrg/example-repo/pull/16223"; + expect(scrubSecrets(url, env)).toBe("https://github.com/[redacted]/pull/16223"); + const values = secretValues(env, ["METER_REPO_SLUG"]); + expect(scrubSecrets({ links: [{ url }], summary: env.UNKNOWN_VALUE }, values)) + .toEqual({ links: [{ url }], summary: REDACTED }); + expect(secretValues({ ...env, API_TOKEN: env.METER_REPO_SLUG }, ["METER_REPO_SLUG", "API_TOKEN"])) + .toContain(env.METER_REPO_SLUG); + expect(secretValues({ SKILLHOOK_SECRET_HELLO: "placeholder-credential" }, ["SKILLHOOK_SECRET_HELLO"])) + .toEqual(["placeholder-credential"]); + }); + it("drops command lines and environments and redacts headers wherever they appear", () => { const job = { id: "j1", diff --git a/src/cloud/redact.ts b/src/cloud/redact.ts index 2d60808..319faa2 100644 --- a/src/cloud/redact.ts +++ b/src/cloud/redact.ts @@ -1,4 +1,4 @@ -// Nothing secret leaves the machine: every string uploaded is scrubbed against every value in `.env`, headers are +// Nothing secret leaves the machine: uploads are scrubbed against private `.env` values; headers are // redacted like `event.json`, and command lines / environments never travel at all. import { redactHeaders } from "../payload.js"; @@ -7,10 +7,21 @@ export const REDACTED = "[redacted]"; /** Keys never uploaded, whatever object they sit in. */ const DROPPED_KEYS = new Set(["command", "env", "argv", "stdin", "resume_command"]); -/** The `.env` values worth scrubbing (short ones would blank ordinary words). */ -export function secretValues(secrets: Record): string[] { +/** Only explicitly public configuration names may bypass value scrubbing; credentials never may. */ +export function isPublicEnvName(name: string): boolean { + return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) + && !/^SKILLHOOK_/i.test(name) + && !/(TOKEN|SECRET|PASSWORD|PASSWD|KEY|AUTH|COOKIE|CREDENTIAL|SIGNATURE)/i.test(name); +} + +/** Private `.env` values worth scrubbing (short ones would blank ordinary words). */ +export function secretValues(secrets: Record, publicEnv: readonly string[] = []): string[] { const values = new Set(); - for (const value of Object.values(secrets)) if (typeof value === "string" && value.length >= MIN_SECRET_LENGTH) values.add(value); + const publicNames = new Set(publicEnv.filter(isPublicEnvName)); + for (const [name, value] of Object.entries(secrets)) { + // A value shared with a private variable is still a secret. + if (!publicNames.has(name) && typeof value === "string" && value.length >= MIN_SECRET_LENGTH) values.add(value); + } return [...values].sort((a, b) => b.length - a.length); } diff --git a/src/cloud/report.ts b/src/cloud/report.ts index 23a4ea7..bc72898 100644 --- a/src/cloud/report.ts +++ b/src/cloud/report.ts @@ -1,7 +1,7 @@ // `skillhook cloud report` and the MCP tool `cloud_report_issue`: a person on a paired machine tells the Skillhook team // about a problem. One request, `POST /api/agent/issues` with the machine token, and only when someone asks for it: the // person's own words and, unless they say no, what the machine already knows about itself (versions, platform, the -// link's state, runner readiness, the checks that fail), scrubbed of every `.env` value like everything the link sends. +// link's state, runner readiness, the checks that fail), scrubbed of private `.env` values like everything the link sends. // Never payloads, logs, prompts or job output. See docs/cloud.md. import { randomUUID } from "node:crypto"; import { z } from "zod"; @@ -44,7 +44,7 @@ export interface DiagnosticsOptions { /** * The versions, the platform and the cloud mode; then, from the running server's cached answers (or a quick local check * when none runs), the link's state, whether each runner is ready and the health summary with the checks that fail or - * warn. Scrubbed of every `.env` value, then cut to the protocol's bounds; what cannot be read is left out. + * warn. Scrubbed of private `.env` values, then cut to the protocol's bounds; what cannot be read is left out. */ export async function gatherDiagnostics(paths: Paths, env: NodeJS.ProcessEnv, options: DiagnosticsOptions = {}): Promise { const config = options.config ?? loadConfig(paths); @@ -67,7 +67,7 @@ export async function gatherDiagnostics(paths: Paths, env: NodeJS.ProcessEnv, op const link = running?.health.cloud ?? undefined; const failing = (report?.checks ?? []).filter((c) => c.status === "fail" || c.status === "warn").sort((a, b) => Number(b.status === "fail") - Number(a.status === "fail")); // Scrubbed before being cut: a replacement may make a line longer. - const facts = scrubSecrets({ last_error: link?.last_error ?? "", failing: failing.slice(0, MAX_FAILING).map((c) => ({ id: c.name, status: c.status, message: c.detail })) }, secretValues(fileSecrets)); + const facts = scrubSecrets({ last_error: link?.last_error ?? "", failing: failing.slice(0, MAX_FAILING).map((c) => ({ id: c.name, status: c.status, message: c.detail })) }, secretValues(fileSecrets, config.cloud.public_env)); const diagnostics: IssueDiagnostics = { ...basics, ...(link ? { link: { state: link.state, ...(link.reason ? { reason: link.reason } : {}), ...(facts.last_error ? { last_error: facts.last_error.slice(0, MAX_LINE) } : {}) } } : {}), @@ -94,7 +94,7 @@ export async function buildIssueReport(paths: Paths, env: NodeJS.ProcessEnv, inp const config = options.config ?? loadConfig(paths); // Every field is scrubbed like the diagnostics: whoever fills them (an agent, too) may have put anything there. const given = { title: input.title.trim(), body: input.body?.trim() ?? "", contact_email: input.contact_email?.trim() ?? "", job_id: input.job_id ?? "", delivery_id: input.delivery_id ?? "", skill: input.skill ?? "" }; - const text = scrubSecrets(given, secretValues(readEnvFile(paths.envFile))); + const text = scrubSecrets(given, secretValues(readEnvFile(paths.envFile), config.cloud.public_env)); if (!text.title) throw new IssueReportError("The report needs a title: one line that says what went wrong"); if (text.title.length > MAX_TITLE) throw new IssueReportError(`The title is ${text.title.length} characters; at most ${MAX_TITLE} (put the rest in the body)`); if (text.body.length > MAX_BODY) throw new IssueReportError(`The body is ${text.body.length} characters; at most ${MAX_BODY} (refer to the job instead of pasting its output)`); diff --git a/src/cloud/snapshot.ts b/src/cloud/snapshot.ts index 6ab00d0..7326718 100644 --- a/src/cloud/snapshot.ts +++ b/src/cloud/snapshot.ts @@ -50,5 +50,5 @@ export function buildSnapshot(deps: SnapshotDeps): Snapshot { ...(readiness.length ? { runners: readiness as unknown as Snapshot["runners"] } : {}), ...(stats ? { stats } : {}), }; - return scrubSecrets(snapshot, secretValues(deps.fileSecrets())); + return scrubSecrets(snapshot, secretValues(deps.fileSecrets(), deps.config.cloud.public_env)); } diff --git a/src/config.test.ts b/src/config.test.ts index a933834..6d738af 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -17,6 +17,16 @@ describe("config", () => { expect(defaultConfig()).toEqual(config); }); + it("accepts public environment names but refuses credential exemptions", () => { + expect(defaultConfig().cloud.public_env).toEqual([]); + const paths = tempHome(); + setConfigValue(paths, "cloud.public_env", ["METER_REPO_SLUG"]); + expect(loadConfig(paths).cloud.public_env).toEqual(["METER_REPO_SLUG"]); + for (const name of ["API_TOKEN", "PRIVATE_KEY", "PASSWORD", "SKILLHOOK_CLOUD_URL", "invalid-name"]) { + expect(() => setConfigValue(paths, "cloud.public_env", [name])).toThrow(ConfigError); + } + }); + it("rejects unknown keys with a readable message", () => { const paths = tempHome(); writeFileSync(paths.configFile, JSON.stringify({ prot: 1 })); diff --git a/src/config.ts b/src/config.ts index d594ff3..61f6906 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,7 @@ import { statSync } from "node:fs"; import { z } from "zod"; import type { Events } from "./events.js"; +import { isPublicEnvName } from "./cloud/redact.js"; import { DEFAULT_CLOUD_URL } from "./cloud/config.js"; import { FallbackSchema } from "./runners/failure.js"; import { readFileSync } from "node:fs"; @@ -132,6 +133,8 @@ export const ConfigSchema = z deny_commands: z.array(z.string().max(100)).default([]), /** Upload webhook payloads (redacted headers, bodies at most 256 KiB) with deliveries. */ upload_payloads: z.boolean().default(true), + /** Local declaration of non-sensitive .env configuration; unknown names stay private. */ + public_env: z.array(z.string().refine(isPublicEnvName, "Expected a public environment name, never a credential or SKILLHOOK_* variable")).default([]), /** Let the cloud ask for job artifacts (transcripts, results) and live output. */ upload_artifacts: z.boolean().default(true), /** Accept hosted-ingress deliveries (webhooks the cloud received for this machine while it was asleep). */