Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ app: build
mkdir -p "$(APP)/Contents/Resources/scripts"
cp -R bridge integrations "$(APP)/Contents/Resources/"
cp scripts/install.sh scripts/uninstall.sh scripts/lib.sh "$(APP)/Contents/Resources/scripts/"
# Vercel is off unless the release supplies the Worker URL: nothing is committed, so a dev build stays inert.
@if [ -n "$$VERCEL_WORKER_URL" ]; then plutil -replace VercelWorkerURL -string "$$VERCEL_WORKER_URL" "$(APP)/Contents/Info.plist"; fi
@if [ -n "$$VERCEL_INTEGRATION_SLUG" ]; then plutil -replace VercelIntegrationSlug -string "$$VERCEL_INTEGRATION_SLUG" "$(APP)/Contents/Info.plist"; fi
codesign --force -s - "$(APP)"
@echo "Built $(APP)"

Expand Down
2 changes: 1 addition & 1 deletion docs/vercel-deployment-monitoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,6 @@ Show production deployments from every project available to a Vercel integration

## Current status

An unreleased Worker and desktop prototype exist, with local pairing, polling, UI, notification code, and mocked tests. The Worker URL is unset and the Vercel menu is hidden. The integration form is not submitted. Security review, real installation, deployment, and end-to-end verification remain required; see `integrations/vercel-worker/README.md` before any deployment.
A Worker and desktop prototype exist, with local pairing, polling, UI, notification code, and mocked tests. The Worker is deployed at `agent-notch-vercel.onilabs-dev.workers.dev` with its three secrets set, the Vercel integration is registered but not submitted for public listing, and the pairing flow completed end to end once on 2026-11-04 (team installation). A review of the pairing flow is in `docs/vercel-security-review.md` (not independent), the privacy wording to ship with it is drafted in `docs/vercel-privacy.md`, and the Worker URL is injected at packaging time with `VERCEL_WORKER_URL`. Still required: an independent review, a personal-account run, revocation on disconnect, and a real deployment rendered end to end; see `integrations/vercel-worker/README.md` before any redeployment.

References: [Vercel integration authorization](https://vercel.com/docs/integrations/create-integration/vercel-api-integrations), [Vercel integration registration](https://vercel.com/docs/integrations/create-integration/submit-integration), [Cloudflare Worker secrets](https://developers.cloudflare.com/workers/configuration/secrets/).
57 changes: 57 additions & 0 deletions docs/vercel-privacy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Vercel privacy wording (proposal)

The app currently promises, in all three READMEs and in `CONTRIBUTING.md`, that it never sends
anything anywhere. Connecting Vercel necessarily breaks that promise for users who opt in, so the
wording has to change *with* the feature, not after it. This is the proposed change; it is not
applied yet because it changes a public promise.

## README.md

Replace the opening sentence of the "Privacy" section:

> It runs locally, listens only on `127.0.0.1`, and never sends anything anywhere.

with:

> It runs locally and listens only on `127.0.0.1`. It never sends your agent activity anywhere.
> The only outbound request the app can make is the optional Vercel connection described below,
> and only after you start it yourself.

Then add, after the Vercel feature section:

> ### Vercel (optional)
>
> Agent Notch can show production deployments from the Vercel projects your integration can see.
> It is off until you choose **Connect Vercel…**: before that, the app makes no network requests
> and the menu item is hidden.
>
> When you connect, the app keeps a device credential in the Keychain and talks only to the
> Agent Notch Worker. The Worker holds the integration's client secret and exchanges it for a
> Vercel token scoped to reading deployments; that token never reaches your Mac. The app asks for
> deployment read access only, and sends no prompts, code, session data or file paths.
>
> **Disconnect** removes the local credential. To revoke the integration's access as well,
> remove it in Vercel; the confirmation dialog says so.
>
> Deployment notifications are off by default and, when enabled, arrive silently (no sound, no
> banner that interrupts).

## CONTRIBUTING.md

Replace:

> The server must stay loopback-only and the app must not make network requests.

with:

> The server must stay loopback-only. The app makes no network requests except the optional
> Vercel connection, which must stay off by default, hidden until configured, started only by an
> explicit user action, and documented in the README of every language.

## Notes

- The Spanish and German READMEs must carry the same wording; translate after the English text is
approved so the promise does not drift between languages.
- If the integration is never finished, this change should not land: the current promise is true
today and should stay that way.
- `SECURITY.md` should point at `docs/vercel-security-review.md` once the review is independent.
51 changes: 51 additions & 0 deletions docs/vercel-security-review.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Vercel integration: security review

Scope: the pairing flow in `integrations/vercel-worker/index.mjs` and its client in
`Sources/AgentNotch/Infrastructure/VercelMonitor.swift`. Reviewed against the design in
`docs/vercel-deployment-monitoring.md` on 2026-11-04. This is a code review by the author of the
change, not an independent audit; treat the "residual risk" items as decisions to confirm, not as
cleared findings.

## Design summary

1. The desktop app generates a 256-bit `state` and a 256-bit `verifier`, sends only
`SHA-256(verifier)` to the Worker, and opens Vercel's integration page with `state`.
2. Vercel redirects to the Worker with an authorization `code`; the Worker exchanges it using
`VERCEL_CLIENT_SECRET`, which never leaves Cloudflare, and stores the resulting token in the
Durable Object for that `state`.
3. The app proves possession of `verifier` on every later call. The Worker compares
`SHA-256(verifier)` with the stored challenge; the token is never returned to the app.
4. The app keeps `state` + `verifier` in the Keychain
(`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`) and calls `/deployments` with the bearer.

## Findings

| # | Severity | Finding | Status |
|---|----------|---------|--------|
| F1 | Medium | **Disconnect deletes the local session but does not revoke the Vercel token.** Access remains until the user removes the integration in Vercel; the confirmation dialog says so, but a user who disconnects and forgets leaves a live token. | Open: `POST https://api.vercel.com/v2/oauth/revoke` answers `415` instead of `404` to an empty request, so the endpoint likely exists, but its contract is not in the pages reviewed. Confirm the parameters before calling it; until then keep the current copy and make it louder. |
| F2 | Medium | **An expired Vercel token was reported as a generic failure.** A `401` from Vercel became `502` to the client, so the app showed "Could not connect to Vercel" forever instead of asking to reconnect. | Fixed: Vercel `401` now maps to `401`, which the client already treats as expired and clears. |
| F3 | Low | The callback response carried no `X-Content-Type-Options`. | Fixed: `nosniff` added. |
| F4 | Low | **The callback is bound to Vercel only by the `code` and the secrecy of `state`.** Someone who learns a live `state` (it travels in the Vercel redirect URL, so it can appear in logs) could attach their own authorization to that session; they still cannot read deployments or disconnect, which require `verifier`. | Accepted: standard OAuth-with-state trade-off. Shorten the 10-minute window if that ever matters. |
| F5 | Low | Rate limiting is per `CF-Connecting-IP` and shared across all routes; clients behind one NAT share 120 requests/minute. | Accepted. The limit exists to stop brute force, not to meter legitimate use. |
| F6 | Info | The digest comparison is not constant-time. | Accepted: both values are 256-bit secrets; a timing attack needs an infeasible number of samples. |
| F7 | Info | `state` also selects the Durable Object, and any 64-hex value creates one. | Accepted: creations are rate-limited and unclaimed sessions delete themselves after 10 minutes. |
| F8 | Info | Token retention: a claimed session keeps the token until `/disconnect`; an unclaimed one is deleted by the alarm at 10 minutes. Cloudflare encrypts Durable Object storage at rest. | Documented. There is no periodic rotation; reconnecting is the rotation. |
| F9 | Info | The verifier never leaves the device, and the token never reaches it. A stolen `state` alone is not enough to read deployments. | Design property; keep it. |
| F10 | Info | The Worker returns only `uid`, `name`, `state` and git metadata for production deployments of the stored team. No project list, env vars or logs. | Design property; keep it. |
| F11 | Medium | **Personal-account installations failed.** Vercel answers the token exchange with `team_id: null` when the integration is installed on a personal account, and the Worker demanded a string, so the callback returned `502` after a successful authorization. | Fixed: a null or absent team is stored as empty and `teamId` is omitted from Vercel requests. Covered by a test. |
| F12 | Low | The Worker called `https://api.vercel.com/v7/deployments`, which is not the endpoint in Vercel's current reference. | Fixed: uses the documented `/v6/deployments`. |
| F13 | High | **The callback threw an unhandled exception on the edge.** `fetch(..., { redirect: 'error' })` is accepted by Node but rejected by the Workers runtime ("must be one of follow or manual"), so the token exchange failed with a Cloudflare `1101` page after a successful authorization. The Node test suite could not catch it. | Fixed: `redirect: 'manual'` plus an explicit 3xx check, a `try/catch` that answers `502` with a reason, and logs that state whether each binding is present. Add `wrangler dev` to the test pass so runtime-only rejections surface before deploy. |

## Residual risk before a public release

1. **Independent review.** This document is not one. Someone who did not write the flow should
read it against the Vercel integration contract before real users connect.
2. **Real end-to-end run.** The pairing flow has now completed once against production on
2026-11-04 (team installation: `/start` → Vercel authorization → callback → `/status` connected),
which is how F13 was found. Still unproven: a personal-account installation, deployment
rendering after a real build, disconnect with token revocation, and a run on a clean Mac. Repeat
the checklist in `integrations/vercel-worker/README.md` after any change to the Worker.
3. **Secret handling in Cloudflare.** `VERCEL_CLIENT_SECRET` must be a Worker secret
(`wrangler secret put`), never a `var` or a committed file; rotation must be documented.
4. **Privacy promise.** The app currently states it never sends anything anywhere. Shipping this
requires the wording change drafted in `docs/vercel-privacy.md` to land with it.
104 changes: 101 additions & 3 deletions integrations/vercel-worker/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,105 @@
# Vercel connection prototype

This Worker is an **unreleased local prototype**, not a deployable production service. It is not connected to Agent Notch. Do not deploy it or use real credentials until the authentication flow, token lifecycle, abuse controls, revocation and storage retention have been security-reviewed and integration-tested.
This Worker is an **unreleased prototype** and is not connected to Agent Notch. Do not deploy it or
use real credentials until the security review in `docs/vercel-security-review.md` has an
independent pass and the flow has run end to end.

Run the isolated tests with `node --test integrations/vercel-worker/index.test.mjs`. The tests use mocked Vercel responses and no credentials.
Run the isolated tests with `node --test integrations/vercel-worker/index.test.mjs`. The tests use
mocked Vercel responses and no credentials.

The intended deployment would require `VERCEL_CLIENT_ID`, `VERCEL_CLIENT_SECRET`, and `VERCEL_REDIRECT_URI` bindings and a Durable Object namespace. Use Cloudflare secret bindings for credentials, not `vars` or committed files.
## Where the credentials come from

There are three values, and only two are secrets:

| Value | Where it comes from |
|-------|---------------------|
| `VERCEL_CLIENT_ID` | Vercel assigns it when you create the integration (bottom of its settings page) |
| `VERCEL_CLIENT_SECRET` | Same page; rotatable with **Rotate Secret** |
| `VERCEL_REDIRECT_URI` | Not a credential: the callback URL you type in the integration form, exactly |

None of them live in a `.env` you generate. Vercel creates a client ID and secret per integration,
so the integration has to exist before the Worker can hold them. That forces the order below: the
Worker is deployed first so its URL can be used as the redirect URL.

## 1. Deploy the Worker (no secrets yet)

Run from the repository root. If `cd` fails, you are in the wrong directory: `wrangler deploy`
without a `wrangler.toml` looks for a Pages project and fails with "Could not detect a directory
containing static files", deploying nothing.

```bash
cd integrations/vercel-worker
npx wrangler login
npx wrangler whoami # confirms the login
npx wrangler deploy
```

`wrangler deploy` prints the URL, normally
`https://agent-notch-vercel.<your-subdomain>.workers.dev`. Smoke-test it with any 64-hex state; a
`401` means the Worker and its Durable Object are up and the session is unauthenticated:

```bash
curl -s -o /dev/null -w '%{http_code}\n' \
"https://<worker-host>/status?state=$(openssl rand -hex 32)" # expect 401
```

Use that URL (or a custom domain) as the
callback host everywhere below. The Worker deploys fine without secrets because they are only read
at request time; `wrangler secret put` would also refuse to run before the Worker exists.

The Durable Object namespace, its SQLite migration and the rate limit are declared in
`wrangler.toml`. Workers Free supports SQLite-backed Durable Objects; if `wrangler deploy` objects
to the `[[ratelimits]]` binding, that is the one piece that may need a paid plan — remove the binding
and the check in `index.mjs` to stay on Free.

## 2. Create the Vercel integration (this is where the credentials are born)

1. Open the Vercel dashboard, pick your team, then **Integrations → Integrations Console → Create**.
2. Fill in the required profile fields (name, URL slug, developer, contact emails, logo, category,
website, documentation, EULA and privacy URLs, overview).
3. Set **Redirect URL** to `https://<worker-host>/callback` and keep the API scopes to
**deployments read** only.
4. Save. The **client ID and client secret** are at the bottom of the integration's settings page,
under **Credentials**.
5. The **URL slug** you chose is `VERCEL_INTEGRATION_SLUG`; it is also the install URL
`https://vercel.com/integrations/<slug>/new`.

You do not need to submit the integration for review to use it yourself; the review is for listing
it publicly.

## 3. Store the values as Worker secrets

```bash
npx wrangler secret put VERCEL_CLIENT_ID
npx wrangler secret put VERCEL_CLIENT_SECRET
npx wrangler secret put VERCEL_REDIRECT_URI # https://<worker-host>/callback
```

Secrets take effect immediately; no redeploy needed. Never put the secret in `wrangler.toml`, a
committed file, or a `var`. Rotating the client secret is `wrangler secret put` again with the new
value from **Rotate Secret**: devices keep working because they never see it.

## 4. Build a release that can reach the Worker

The URL is injected at packaging time, so it is not committed:

```bash
VERCEL_WORKER_URL=https://<worker-host> VERCEL_INTEGRATION_SLUG=<slug> scripts/release.sh
```

Without those variables the app ships with the Vercel UI hidden and makes no Vercel requests.

## 5. Verify it end to end

The pairing flow completed once against production on 2026-11-04 (a team installation on
`agent-notch-vercel.onilabs-dev.workers.dev`), which is how the `redirect: 'error'` rejection in F13
was found. Repeat this list after any Worker change:

- Connect from a build that has the Worker URL, complete the Vercel flow, and confirm the app says
connected.
- Start a real production deployment from a throwaway repository; watch the badge appear when idle
and the row in the expanded list.
- Disconnect, then confirm the Worker session is gone (`/status` returns 401) and the Vercel
integration's access was revoked or flagged for manual removal.
- Restart the app while a deployment runs; the deployment must not notify for work that finished
while it was closed.
Loading
Loading