From faf3e5a12c8d8ca3b12f31fd5e4fc7650c378e3b Mon Sep 17 00:00:00 2001 From: Daniel Vataj <153308333+sent-dm@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:22:51 -0400 Subject: [PATCH] Resolve Claude directory privacy and ambient credential warnings --- .claude-plugin/marketplace.json | 2 +- .github/workflows/validate.yml | 2 +- README.md | 2 ++ adapter-sources/shared/README.md | 2 ++ .../sent/.claude-plugin/plugin.json | 6 +++- claude-plugins/sent/README.md | 2 ++ .../migrate-to-sent/scripts/inventory_scan.py | 2 +- .../skills/sent-integration-starter/SKILL.md | 16 +++++----- .../references/sdk-and-frameworks.md | 32 ++++++++----------- .../skills/sent-webhook-engineer/SKILL.md | 2 ++ .../references/receiver-recipes.md | 28 ++++++++-------- .../scripts/verify_signature.py | 24 ++++++++------ docs/PUBLIC_RELEASE.md | 2 ++ packages/sent/README.md | 2 ++ packages/sent/plugin.json | 2 +- .../migrate-to-sent/scripts/inventory_scan.py | 2 +- .../skills/sent-integration-starter/SKILL.md | 16 +++++----- .../references/sdk-and-frameworks.md | 32 ++++++++----------- .../skills/sent-webhook-engineer/SKILL.md | 2 ++ .../references/receiver-recipes.md | 28 ++++++++-------- .../scripts/verify_signature.py | 24 ++++++++------ plugin.json | 2 +- plugins/sent/.codex-plugin/plugin.json | 2 +- plugins/sent/README.md | 2 ++ .../migrate-to-sent/scripts/inventory_scan.py | 2 +- .../skills/sent-integration-starter/SKILL.md | 16 +++++----- .../references/sdk-and-frameworks.md | 32 ++++++++----------- .../skills/sent-webhook-engineer/SKILL.md | 2 ++ .../references/receiver-recipes.md | 28 ++++++++-------- .../scripts/verify_signature.py | 24 ++++++++------ scripts/generate_adapters.py | 4 +++ scripts/test_fixtures.py | 30 +++++++++++++++++ scripts/test_validation_gates.py | 14 ++++++++ scripts/validate.py | 9 ++++++ .../migrate-to-sent/scripts/inventory_scan.py | 2 +- skills/sent-integration-starter/SKILL.md | 16 +++++----- .../references/sdk-and-frameworks.md | 32 ++++++++----------- skills/sent-webhook-engineer/SKILL.md | 2 ++ .../references/receiver-recipes.md | 28 ++++++++-------- .../scripts/verify_signature.py | 24 ++++++++------ 40 files changed, 287 insertions(+), 214 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 624758f..3b6e9c2 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,7 +6,7 @@ }, "metadata": { "description": "Official Sent skills and remote MCP integration for messaging engineering, operations, migration, and specialist channel workflows.", - "version": "0.2.0" + "version": "0.2.1" }, "plugins": [ { diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index a3fbfb7..d8783c0 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -30,7 +30,7 @@ jobs: python -m pip install "skills-ref @ git+https://github.com/agentskills/agentskills.git@217be548739f21d6008915c29aefe320ea1a90af#subdirectory=skills-ref" - name: Install Claude Code validator - run: npm install --global @anthropic-ai/claude-code@2.1.226 + run: npm install --global @anthropic-ai/claude-code@2.1.287 - name: Reject generated adapter drift run: python scripts/generate_adapters.py --check diff --git a/README.md b/README.md index bca9c09..8df8b01 100644 --- a/README.md +++ b/README.md @@ -171,3 +171,5 @@ Dashboard volume counts delivered/read outbound SMS and WhatsApp over supported Use the connected server's tool schemas for argument names and availability. The [public MCP landing page](https://mcp.sent.dm) lists the current surface; some documentation pages may describe an earlier catalog. The bundled migration inventory scanner reads local regular files in the selected repository and makes no network requests. It skips symbolic links and environment files and omits source excerpts from reports. Other bundled utilities validate supplied local payloads or analyze supplied local exports. Remote account reads and authorized writes use the declared Sent MCP endpoint. + +The webhook signature utility runs locally with an explicitly supplied signing secret on standard input (`--secret-stdin`); it makes no network requests and does not read installer credentials or environment variables. Application integration examples require explicitly supplied API keys and signing secrets. Plugin authentication remains client-managed OAuth. diff --git a/adapter-sources/shared/README.md b/adapter-sources/shared/README.md index b00fdb7..42076da 100644 --- a/adapter-sources/shared/README.md +++ b/adapter-sources/shared/README.md @@ -71,3 +71,5 @@ Dashboard volume counts delivered/read outbound SMS and WhatsApp over supported Use the connected server's tool schemas for argument names and availability. The [public MCP landing page](https://mcp.sent.dm) lists the current surface; some documentation pages may describe an earlier catalog. The bundled migration inventory scanner reads local regular files in the selected repository and makes no network requests. It skips symbolic links and environment files and omits source excerpts from reports. Other bundled utilities validate supplied local payloads or analyze supplied local exports. Remote account reads and authorized writes use the declared Sent MCP endpoint. + +The webhook signature utility runs locally with an explicitly supplied signing secret on standard input (`--secret-stdin`); it makes no network requests and does not read installer credentials or environment variables. Application integration examples require explicitly supplied API keys and signing secrets. Plugin authentication remains client-managed OAuth. diff --git a/claude-plugins/sent/.claude-plugin/plugin.json b/claude-plugins/sent/.claude-plugin/plugin.json index 675d36c..85a4a89 100644 --- a/claude-plugins/sent/.claude-plugin/plugin.json +++ b/claude-plugins/sent/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "sent", - "version": "0.2.0", + "version": "0.2.1", "description": "Official Sent plugin for SMS, WhatsApp, RCS, scheduled template sends, Sender Profiles, SMS compliance, authorized feedback, contacts, analytics, API integration, webhooks, routing, and migration.", "author": { "name": "Sent", @@ -36,6 +36,10 @@ "feedback", "scheduled-messaging" ], + "documentationUrl": "https://github.com/sentdm/sent-plugin#readme", + "supportUrl": "https://www.sent.dm/en/company/contact-support", + "privacyPolicyUrl": "https://www.sent.dm/en/legal/privacy-policy", + "termsOfServiceUrl": "https://www.sent.dm/en/legal/terms-of-service", "commands": "./.claude/commands", "skills": "./skills", "mcpServers": "./.mcp.json" diff --git a/claude-plugins/sent/README.md b/claude-plugins/sent/README.md index b00fdb7..42076da 100644 --- a/claude-plugins/sent/README.md +++ b/claude-plugins/sent/README.md @@ -71,3 +71,5 @@ Dashboard volume counts delivered/read outbound SMS and WhatsApp over supported Use the connected server's tool schemas for argument names and availability. The [public MCP landing page](https://mcp.sent.dm) lists the current surface; some documentation pages may describe an earlier catalog. The bundled migration inventory scanner reads local regular files in the selected repository and makes no network requests. It skips symbolic links and environment files and omits source excerpts from reports. Other bundled utilities validate supplied local payloads or analyze supplied local exports. Remote account reads and authorized writes use the declared Sent MCP endpoint. + +The webhook signature utility runs locally with an explicitly supplied signing secret on standard input (`--secret-stdin`); it makes no network requests and does not read installer credentials or environment variables. Application integration examples require explicitly supplied API keys and signing secrets. Plugin authentication remains client-managed OAuth. diff --git a/claude-plugins/sent/skills/migrate-to-sent/scripts/inventory_scan.py b/claude-plugins/sent/skills/migrate-to-sent/scripts/inventory_scan.py index 10fcad4..49f6f35 100644 --- a/claude-plugins/sent/skills/migrate-to-sent/scripts/inventory_scan.py +++ b/claude-plugins/sent/skills/migrate-to-sent/scripts/inventory_scan.py @@ -57,7 +57,7 @@ class Rule: ), Rule( "twilio-sdk", "twilio", r"\b(?:from\s+twilio|require\(['\"]twilio|com\.twilio|Twilio\.Rest|twilio-go)\b", - "rewrite", "Replace the Twilio client with a Sent SDK client reading SENT_DM_API_KEY.", + "rewrite", "Replace the Twilio client with a Sent SDK client with an explicitly supplied application API key.", ), Rule( "twilio-messages-endpoint", "twilio", r"api\.twilio\.com/2010-04-01/Accounts/[^/]*/Messages", diff --git a/claude-plugins/sent/skills/sent-integration-starter/SKILL.md b/claude-plugins/sent/skills/sent-integration-starter/SKILL.md index 1f16fb3..f137c40 100644 --- a/claude-plugins/sent/skills/sent-integration-starter/SKILL.md +++ b/claude-plugins/sent/skills/sent-integration-starter/SKILL.md @@ -13,15 +13,15 @@ Direct Sent v3 REST requests authenticate with the `x-api-key` header. An applic | Language | Package | Client | | --- | --- | --- | -| TypeScript | `@sentdm/sentdm` | `new SentDm()` | -| Python | `sentdm` (imports `sent_dm`) | `Sent()` or `AsyncSent()` | -| Go | `github.com/sentdm/sent-dm-go` | `sentdm.NewClient()` | -| Java | `dm.sent:sent-java` | `SentOkHttpClient.fromEnv()` | -| C# | `Sentdm` | `new SentClient()` | +| TypeScript | `@sentdm/sentdm` | `new SentDm({ apiKey })` | +| Python | `sentdm` (imports `sent_dm`) | `Sent(api_key=api_key)` or `AsyncSent(api_key=api_key)` | +| Go | `github.com/sentdm/sent-dm-go` | `sentdm.NewClient(option.WithAPIKey(apiKey))` | +| Java | `dm.sent:sent-java` | `SentOkHttpClient.builder().apiKey(apiKey).build()` | +| C# | `Sentdm` | `new SentClient { ApiKey = apiKey }` | | PHP | `sentdm/sent-dm-php` | `new SentDm\Client($apiKey)` | -| Ruby | `sentdm` | `Sentdm::Client.new` | +| Ruby | `sentdm` | `Sentdm::Client.new(api_key: api_key)` | -Every SDK except PHP reads `SENT_DM_API_KEY` automatically. Single-endpoint receiver samples read `SENT_DM_WEBHOOK_SECRET`; multi-tenant production receivers need a secret registry keyed by webhook id instead of one process-wide secret. Older documentation uses `SENT_API_KEY` and `SENT_WEBHOOK_SECRET` — treat those as aliases and standardize on the `SENT_DM_` names. +For generated application code, require an explicit, non-empty API key supplied by the application owner and pass it through the SDK's credential option. Inject the signing secret resolved for each webhook id into receiver helpers. Never inspect the plugin installer's environment, credential store, or MCP tokens; the plugin itself authenticates through client-managed OAuth. Multi-tenant production receivers need a secret registry keyed by webhook id. Choose the client lifecycle from the credential model. A single-account service with one server-managed key should reuse a long-lived client and its connection pool. A multi-tenant proxy that resolves a caller or profile credential per request should construct the client for that request and discard it, so tenant credentials cannot leak through shared state. Framework-specific wiring, the Ruby `messages.send_` naming quirk, and per-ecosystem background-work choices are in [references/sdk-and-frameworks.md](references/sdk-and-frameworks.md). @@ -81,7 +81,7 @@ Log `meta.request_id` on every response, success or failure — it is the correl ### Launch checklist -- [ ] Credentials load from the environment; nothing is committed, and separate keys exist per environment. +- [ ] Credentials are explicitly supplied from application-owned configuration; nothing is committed, and separate keys exist per environment. - [ ] Client lifecycle matches credential scope: shared for one server-managed key, per request for tenant-supplied credentials. - [ ] `Idempotency-Key` on every mutating call, derived deterministically. - [ ] Retry policy distinguishes retryable from terminal by error family. diff --git a/claude-plugins/sent/skills/sent-integration-starter/references/sdk-and-frameworks.md b/claude-plugins/sent/skills/sent-integration-starter/references/sdk-and-frameworks.md index 713b428..895de51 100644 --- a/claude-plugins/sent/skills/sent-integration-starter/references/sdk-and-frameworks.md +++ b/claude-plugins/sent/skills/sent-integration-starter/references/sdk-and-frameworks.md @@ -4,7 +4,7 @@ - [Package matrix](#package-matrix) - [Client construction per language](#client-construction-per-language) -- [Configuration and environment variables](#configuration-and-environment-variables) +- [Explicit application configuration](#explicit-application-configuration) - [Framework wiring](#framework-wiring) - [Background processing per ecosystem](#background-processing-per-ecosystem) - [Multi-tenant credential patterns](#multi-tenant-credential-patterns) @@ -29,11 +29,13 @@ No SDK ships a webhook signature verifier in any language. That code is always a ## Client construction per language +These are application examples, not plugin authentication code. Each `apiKey` or `api_key` below is a non-empty value explicitly supplied by the application owner for the intended account. Validate it before construction so an absent value cannot trigger an SDK environment fallback. Never read the plugin installer's environment, credential store, or MCP tokens. Plugin operations use client-managed OAuth instead. + ```typescript import SentDm from '@sentdm/sentdm'; -// Reads SENT_DM_API_KEY. Options: apiKey, baseUrl, maxRetries, timeout, logLevel. -export const sent = new SentDm({ maxRetries: 3, timeout: 30_000 }); +// apiKey is explicitly supplied for the authorized application account. +export const sent = new SentDm({ apiKey, maxRetries: 3, timeout: 30_000 }); const response = await sent.messages.send({ to: ['+14155551234'], @@ -44,8 +46,8 @@ const response = await sent.messages.send({ ```python from sent_dm import Sent, AsyncSent -client = Sent(max_retries=2, timeout=60.0) # reads SENT_DM_API_KEY -async_client = AsyncSent() +client = Sent(api_key=api_key, max_retries=2, timeout=60.0) +async_client = AsyncSent(api_key=api_key) response = client.messages.send( to=["+14155551234"], @@ -54,46 +56,40 @@ response = client.messages.send( ``` ```go -client := sentdm.NewClient() // or option.WithAPIKey(...) +client := sentdm.NewClient(option.WithAPIKey(apiKey)) response, err := client.Messages.Send(ctx, sentdm.MessageSendParams{ To: []string{"+14155551234"}, }) ``` ```java -SentClient client = SentOkHttpClient.fromEnv(); // SENT_DM_API_KEY or sent.dmApiKey +SentClient client = SentOkHttpClient.builder().apiKey(apiKey).build(); MessageSendResponse response = client.messages().send(params); ``` ```csharp using Sentdm; -SentClient client = new(); // reads SENT_DM_API_KEY +SentClient client = new() { ApiKey = apiKey }; var response = await client.Messages.Send(body); ``` ```php use SentDm\Client; -$client = new Client($_ENV['SENT_DM_API_KEY']); // key is an explicit constructor argument +$client = new Client(apiKey: $apiKey); $result = $client->messages->send(to: ['+14155551234'], template: ['name' => 'order_confirmation']); ``` ```ruby require "sentdm" -client = Sentdm::Client.new # reads SENT_DM_API_KEY +client = Sentdm::Client.new(api_key: api_key) client.messages.send_(to: ["+14155551234"], template: { name: "order_confirmation" }) ``` Java and C# expose both synchronous and asynchronous clients; Python offers `Sent` and `AsyncSent`; TypeScript and C# are promise- or task-based only; Go and PHP and Ruby are synchronous, with Go carrying a `context.Context` on every call. -## Configuration and environment variables - -| Variable | Purpose | Read automatically | -| --- | --- | --- | -| `SENT_DM_API_KEY` | REST credential sent as `x-api-key` | Yes, in every SDK except PHP | -| `SENT_DM_WEBHOOK_SECRET` | `whsec_`-prefixed webhook signing secret | No; application code reads it | -| `SENT_BASE_URL` | Override the API base URL | Java and C# read it; others take a constructor option | +## Explicit application configuration -Older documentation pages use `SENT_API_KEY` and `SENT_WEBHOOK_SECRET`. Both name sets appear in official material; standardize new code on the `SENT_DM_` names because the SDK defaults use them, and accept the shorter names as aliases when adopting existing code. +Pass the authorized application's API key as the SDK's explicit credential option. Pass a webhook signing secret to verification helpers as a separate argument, resolved for the received webhook id. Keep both values in application-owned secret storage and do not print them, put them in prompts, or reuse installer credentials. This plugin does not declare user-config API keys because its MCP connection uses client-managed OAuth. For a single-account service, validate the server-managed key at startup with the ecosystem's schema tooling — `zod` in Node, `pydantic-settings` in Python, `@nestjs/config`, `IOptions` with `[Required]` in .NET — so a missing key fails the deployment rather than the first customer send. For a multi-tenant proxy, validate non-secret configuration at startup and reject each request whose resolved credential is absent or malformed. diff --git a/claude-plugins/sent/skills/sent-webhook-engineer/SKILL.md b/claude-plugins/sent/skills/sent-webhook-engineer/SKILL.md index 4dbf860..de8c208 100644 --- a/claude-plugins/sent/skills/sent-webhook-engineer/SKILL.md +++ b/claude-plugins/sent/skills/sent-webhook-engineer/SKILL.md @@ -28,6 +28,8 @@ Verification procedure, in order: The scheme is Svix-compatible. No Sent SDK ships a verification helper in any language, so this code is always hand-written — use [scripts/verify_signature.py](scripts/verify_signature.py) as the reference implementation and oracle. +The local utility accepts a signing secret explicitly on standard input with `--secret-stdin` and makes no network requests. It never reads environment variables or installer credentials. Supply only the secret authorized for the delivery being checked; do not put it in command arguments or chat prompts. + **`x-webhook-id` is not an event id.** It identifies the endpoint and repeats forever. Using it as a dedupe key silently collapses every event into one. Read [references/webhook-signature-and-dedupe.md](references/webhook-signature-and-dedupe.md) for the dedupe keys to derive per event type. ## Failure triage order diff --git a/claude-plugins/sent/skills/sent-webhook-engineer/references/receiver-recipes.md b/claude-plugins/sent/skills/sent-webhook-engineer/references/receiver-recipes.md index 0609e5a..88bd6e0 100644 --- a/claude-plugins/sent/skills/sent-webhook-engineer/references/receiver-recipes.md +++ b/claude-plugins/sent/skills/sent-webhook-engineer/references/receiver-recipes.md @@ -33,7 +33,7 @@ Every recipe follows the same four steps: read the raw body, verify the signatur | Sinatra | `request.body.read` then `request.body.rewind` | Same | | ASP.NET Core | `new StreamReader(request.Body).ReadToEndAsync()` | Read before model binding touches the stream | -Environment variables: the SDKs read `SENT_DM_API_KEY` by default, and the receiver samples use `SENT_DM_WEBHOOK_SECRET`. Older documentation pages use `SENT_API_KEY` and `SENT_WEBHOOK_SECRET`; treat those as aliases and standardize on the `SENT_DM_` names in new code. +These are receiver application examples, not plugin authentication code. Supply the signing secret explicitly from application-owned configuration, resolved for the received webhook id. The `signingSecret` and `signing_secret` variables below represent that authorized, injected value. Reject missing values; never inspect the plugin installer's environment, credential store, or MCP tokens. The plugin's MCP connection uses client-managed OAuth. ## Node and TypeScript @@ -42,8 +42,7 @@ import crypto from "node:crypto"; const TOLERANCE_SECONDS = 300; -export function verify(rawBody: string, webhookId: string, timestamp: string, header: string): boolean { - const secret = process.env.SENT_DM_WEBHOOK_SECRET ?? ""; +export function verify(rawBody: string, webhookId: string, timestamp: string, header: string, secret: string): boolean { if (!secret || !header?.startsWith("v1,")) return false; if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > TOLERANCE_SECONDS) return false; @@ -67,6 +66,7 @@ export async function POST(request: Request): Promise { request.headers.get("x-webhook-id") ?? "", request.headers.get("x-webhook-timestamp") ?? "", request.headers.get("x-webhook-signature") ?? "", + signingSecret, ); if (!ok) return new Response("invalid signature", { status: 401 }); @@ -80,7 +80,7 @@ Express, scoping the raw parser to the webhook path only: ```js app.post("/webhooks/sent", express.raw({ type: "application/json" }), (req, res) => { const rawBody = req.body.toString("utf8"); - if (!verify(rawBody, req.get("x-webhook-id"), req.get("x-webhook-timestamp"), req.get("x-webhook-signature"))) { + if (!verify(rawBody, req.get("x-webhook-id"), req.get("x-webhook-timestamp"), req.get("x-webhook-signature"), signingSecret)) { return res.status(401).send("invalid signature"); } res.status(200).end(); @@ -93,13 +93,12 @@ Mount `express.json()` on other routers rather than globally with `app.use`. In ## Python ```python -import base64, hashlib, hmac, os, time +import base64, hashlib, hmac, time TOLERANCE_SECONDS = 300 -def verify(raw_body: bytes, webhook_id: str, timestamp: str, header: str) -> bool: - secret = os.environ.get("SENT_DM_WEBHOOK_SECRET", "") +def verify(raw_body: bytes, webhook_id: str, timestamp: str, header: str, secret: str) -> bool: if not secret or not header.startswith("v1,"): return False try: @@ -121,7 +120,7 @@ async def receive(request: Request, background: BackgroundTasks): raw = await request.body() if not verify(raw, request.headers.get("x-webhook-id", ""), request.headers.get("x-webhook-timestamp", ""), - request.headers.get("x-webhook-signature", "")): + request.headers.get("x-webhook-signature", ""), signing_secret): raise HTTPException(status_code=401, detail="invalid signature") background.add_task(process_event, json.loads(raw)) return {"received": True} @@ -132,8 +131,7 @@ Django reads `request.body` in the view and must exempt the route from CSRF. Fla ## Go ```go -func Verify(rawBody []byte, webhookID, timestamp, header string) bool { - secret := os.Getenv("SENT_DM_WEBHOOK_SECRET") +func Verify(rawBody []byte, webhookID, timestamp, header, secret string) bool { if secret == "" || !strings.HasPrefix(header, "v1,") { return false } @@ -165,7 +163,7 @@ public ResponseEntity receive( @RequestHeader("x-webhook-timestamp") String timestamp, @RequestHeader("x-webhook-signature") String signature) throws Exception { - if (!WebhookSignature.verify(payload, webhookId, timestamp, signature)) { + if (!WebhookSignature.verify(payload, webhookId, timestamp, signature, signingSecret)) { return ResponseEntity.status(401).build(); } events.submit(payload); // @Async executor @@ -182,7 +180,8 @@ Laravel middleware runs before the controller and reads `$request->getContent()` ```php public function handle(Request $request, Closure $next) { - $secret = env('SENT_DM_WEBHOOK_SECRET', ''); + $secret = $this->signingSecret; // Injected for this webhook endpoint. + if (!$secret) abort(401); $signed = $request->header('x-webhook-id') . '.' . $request->header('x-webhook-timestamp') . '.' . $request->getContent(); $key = base64_decode(preg_replace('/^whsec_/', '', $secret)); $expected = 'v1,' . base64_encode(hash_hmac('sha256', $signed, $key, true)); @@ -200,10 +199,9 @@ Dispatch a `ShouldQueue` job from the controller. Symfony follows the same patte ## Ruby ```ruby -def verified?(request) +def verified?(request, secret) raw = request.body.read request.body.rewind - secret = ENV.fetch("SENT_DM_WEBHOOK_SECRET", "") timestamp = request.get_header("HTTP_X_WEBHOOK_TIMESTAMP").to_s return false if secret.empty? || (Time.now.to_i - timestamp.to_i).abs > 300 @@ -229,7 +227,7 @@ app.MapPost("/webhooks/sent", async (HttpRequest request) => request.Headers["x-webhook-id"], request.Headers["x-webhook-timestamp"], request.Headers["x-webhook-signature"], - Environment.GetEnvironmentVariable("SENT_DM_WEBHOOK_SECRET"))) + signingSecret)) { return Results.Unauthorized(); } diff --git a/claude-plugins/sent/skills/sent-webhook-engineer/scripts/verify_signature.py b/claude-plugins/sent/skills/sent-webhook-engineer/scripts/verify_signature.py index e79391f..7c46e76 100644 --- a/claude-plugins/sent/skills/sent-webhook-engineer/scripts/verify_signature.py +++ b/claude-plugins/sent/skills/sent-webhook-engineer/scripts/verify_signature.py @@ -17,13 +17,17 @@ --webhook-id 0f8fad5b-d9cb-469f-a165-70867728950e \ --timestamp 1767225600 \ --signature 'v1,Base64Signature==' \ - --secret-env SENT_DM_WEBHOOK_SECRET + --secret-stdin Sign a synthetic delivery so a local receiver can be exercised:: python3 verify_signature.py --sign --body-file event.json \ --webhook-id 0f8fad5b-d9cb-469f-a165-70867728950e \ - --secret-env SENT_DM_WEBHOOK_SECRET + --secret-stdin + +Supply only the signing secret chosen for this delivery on standard input. +This utility never reads installer credentials or environment variables and +makes no network requests. Do not put the secret in arguments or chat prompts. Exit codes: 0 valid, 1 invalid signature, 2 replay window exceeded, 3 usage or configuration error. @@ -36,7 +40,6 @@ import hashlib import hmac import json -import os import sys import time @@ -149,12 +152,12 @@ def _self_test() -> int: def _resolve_secret(args: argparse.Namespace) -> str: - if args.secret_env: - secret = os.environ.get(args.secret_env, "") - if not secret: - raise ValueError(f"environment variable {args.secret_env} is unset or empty") - return secret - raise ValueError("provide --secret-env naming the environment variable that holds the signing secret") + if not args.secret_stdin: + raise ValueError("provide --secret-stdin to supply the signing secret explicitly") + secret = sys.stdin.read(4097).strip() + if not secret or len(secret) > 4096: + raise ValueError("standard input must contain one signing secret of at most 4096 characters") + return secret def main(argv: list[str] | None = None) -> int: @@ -165,7 +168,7 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--webhook-id", help="value of the x-webhook-id header") parser.add_argument("--timestamp", help="value of the x-webhook-timestamp header") parser.add_argument("--signature", help="value of the x-webhook-signature header") - parser.add_argument("--secret-env", help="environment variable holding the whsec_ signing secret") + parser.add_argument("--secret-stdin", action="store_true", help="read only the explicitly supplied signing secret from standard input") parser.add_argument( "--skip-replay-check", action="store_true", @@ -182,6 +185,7 @@ def main(argv: list[str] | None = None) -> int: try: raw_body = open(args.body_file, "rb").read() secret = _resolve_secret(args) + decode_secret(secret) except (OSError, ValueError) as exc: print(f"error: {exc}", file=sys.stderr) return EXIT_USAGE diff --git a/docs/PUBLIC_RELEASE.md b/docs/PUBLIC_RELEASE.md index 68001ef..99fa71c 100644 --- a/docs/PUBLIC_RELEASE.md +++ b/docs/PUBLIC_RELEASE.md @@ -42,3 +42,5 @@ CI must pass on the exact commit being submitted. Resolve warnings as well as er Submit the public repository through the current Claude plugin submission form only after the exact public commit passes the gates above. Anthropic runs `claude plugin validate` and automated safety screening during review, so local strict validation should match the submitted layout. OpenAI listings include website, support, privacy policy, and terms URLs in both the portable extension and Codex adapter. Release validation checks their consistency and HTTPS endpoints. Confirmation requirements for sends, feedback, contact mutations, template deletion, and Sender Profile mutations are enforced independently of public-surface metadata. Scanner regression checks cover credential omission and repository containment. + +The Claude manifest carries `documentationUrl`, `supportUrl`, `privacyPolicyUrl`, and `termsOfServiceUrl` from the shared listing source. Validate with Claude Code 2.1.287 or later, which recognizes these directory fields. Packaged scripts and application examples must not read ambient credentials; the webhook utility accepts only an explicitly supplied secret on standard input. diff --git a/packages/sent/README.md b/packages/sent/README.md index 2919d9b..7c507ff 100644 --- a/packages/sent/README.md +++ b/packages/sent/README.md @@ -119,3 +119,5 @@ Dashboard volume counts delivered/read outbound SMS and WhatsApp over supported Use the connected server's tool schemas for argument names and availability. The [public MCP landing page](https://mcp.sent.dm) lists the current surface; some documentation pages may describe an earlier catalog. The bundled migration inventory scanner reads local regular files in the selected repository and makes no network requests. It skips symbolic links and environment files and omits source excerpts from reports. Other bundled utilities validate supplied local payloads or analyze supplied local exports. Remote account reads and authorized writes use the declared Sent MCP endpoint. + +The webhook signature utility runs locally with an explicitly supplied signing secret on standard input (`--secret-stdin`); it makes no network requests and does not read installer credentials or environment variables. Application integration examples require explicitly supplied API keys and signing secrets. Plugin authentication remains client-managed OAuth. diff --git a/packages/sent/plugin.json b/packages/sent/plugin.json index 8d8ad3e..6f13906 100644 --- a/packages/sent/plugin.json +++ b/packages/sent/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "sent", - "version": "0.2.0", + "version": "0.2.1", "description": "Official Sent plugin for SMS, WhatsApp, RCS, scheduled template sends, Sender Profiles, SMS compliance, authorized feedback, contacts, analytics, API integration, webhooks, routing, and migration.", "author": { "name": "Sent", diff --git a/packages/sent/skills/migrate-to-sent/scripts/inventory_scan.py b/packages/sent/skills/migrate-to-sent/scripts/inventory_scan.py index 10fcad4..49f6f35 100644 --- a/packages/sent/skills/migrate-to-sent/scripts/inventory_scan.py +++ b/packages/sent/skills/migrate-to-sent/scripts/inventory_scan.py @@ -57,7 +57,7 @@ class Rule: ), Rule( "twilio-sdk", "twilio", r"\b(?:from\s+twilio|require\(['\"]twilio|com\.twilio|Twilio\.Rest|twilio-go)\b", - "rewrite", "Replace the Twilio client with a Sent SDK client reading SENT_DM_API_KEY.", + "rewrite", "Replace the Twilio client with a Sent SDK client with an explicitly supplied application API key.", ), Rule( "twilio-messages-endpoint", "twilio", r"api\.twilio\.com/2010-04-01/Accounts/[^/]*/Messages", diff --git a/packages/sent/skills/sent-integration-starter/SKILL.md b/packages/sent/skills/sent-integration-starter/SKILL.md index 1f16fb3..f137c40 100644 --- a/packages/sent/skills/sent-integration-starter/SKILL.md +++ b/packages/sent/skills/sent-integration-starter/SKILL.md @@ -13,15 +13,15 @@ Direct Sent v3 REST requests authenticate with the `x-api-key` header. An applic | Language | Package | Client | | --- | --- | --- | -| TypeScript | `@sentdm/sentdm` | `new SentDm()` | -| Python | `sentdm` (imports `sent_dm`) | `Sent()` or `AsyncSent()` | -| Go | `github.com/sentdm/sent-dm-go` | `sentdm.NewClient()` | -| Java | `dm.sent:sent-java` | `SentOkHttpClient.fromEnv()` | -| C# | `Sentdm` | `new SentClient()` | +| TypeScript | `@sentdm/sentdm` | `new SentDm({ apiKey })` | +| Python | `sentdm` (imports `sent_dm`) | `Sent(api_key=api_key)` or `AsyncSent(api_key=api_key)` | +| Go | `github.com/sentdm/sent-dm-go` | `sentdm.NewClient(option.WithAPIKey(apiKey))` | +| Java | `dm.sent:sent-java` | `SentOkHttpClient.builder().apiKey(apiKey).build()` | +| C# | `Sentdm` | `new SentClient { ApiKey = apiKey }` | | PHP | `sentdm/sent-dm-php` | `new SentDm\Client($apiKey)` | -| Ruby | `sentdm` | `Sentdm::Client.new` | +| Ruby | `sentdm` | `Sentdm::Client.new(api_key: api_key)` | -Every SDK except PHP reads `SENT_DM_API_KEY` automatically. Single-endpoint receiver samples read `SENT_DM_WEBHOOK_SECRET`; multi-tenant production receivers need a secret registry keyed by webhook id instead of one process-wide secret. Older documentation uses `SENT_API_KEY` and `SENT_WEBHOOK_SECRET` — treat those as aliases and standardize on the `SENT_DM_` names. +For generated application code, require an explicit, non-empty API key supplied by the application owner and pass it through the SDK's credential option. Inject the signing secret resolved for each webhook id into receiver helpers. Never inspect the plugin installer's environment, credential store, or MCP tokens; the plugin itself authenticates through client-managed OAuth. Multi-tenant production receivers need a secret registry keyed by webhook id. Choose the client lifecycle from the credential model. A single-account service with one server-managed key should reuse a long-lived client and its connection pool. A multi-tenant proxy that resolves a caller or profile credential per request should construct the client for that request and discard it, so tenant credentials cannot leak through shared state. Framework-specific wiring, the Ruby `messages.send_` naming quirk, and per-ecosystem background-work choices are in [references/sdk-and-frameworks.md](references/sdk-and-frameworks.md). @@ -81,7 +81,7 @@ Log `meta.request_id` on every response, success or failure — it is the correl ### Launch checklist -- [ ] Credentials load from the environment; nothing is committed, and separate keys exist per environment. +- [ ] Credentials are explicitly supplied from application-owned configuration; nothing is committed, and separate keys exist per environment. - [ ] Client lifecycle matches credential scope: shared for one server-managed key, per request for tenant-supplied credentials. - [ ] `Idempotency-Key` on every mutating call, derived deterministically. - [ ] Retry policy distinguishes retryable from terminal by error family. diff --git a/packages/sent/skills/sent-integration-starter/references/sdk-and-frameworks.md b/packages/sent/skills/sent-integration-starter/references/sdk-and-frameworks.md index 713b428..895de51 100644 --- a/packages/sent/skills/sent-integration-starter/references/sdk-and-frameworks.md +++ b/packages/sent/skills/sent-integration-starter/references/sdk-and-frameworks.md @@ -4,7 +4,7 @@ - [Package matrix](#package-matrix) - [Client construction per language](#client-construction-per-language) -- [Configuration and environment variables](#configuration-and-environment-variables) +- [Explicit application configuration](#explicit-application-configuration) - [Framework wiring](#framework-wiring) - [Background processing per ecosystem](#background-processing-per-ecosystem) - [Multi-tenant credential patterns](#multi-tenant-credential-patterns) @@ -29,11 +29,13 @@ No SDK ships a webhook signature verifier in any language. That code is always a ## Client construction per language +These are application examples, not plugin authentication code. Each `apiKey` or `api_key` below is a non-empty value explicitly supplied by the application owner for the intended account. Validate it before construction so an absent value cannot trigger an SDK environment fallback. Never read the plugin installer's environment, credential store, or MCP tokens. Plugin operations use client-managed OAuth instead. + ```typescript import SentDm from '@sentdm/sentdm'; -// Reads SENT_DM_API_KEY. Options: apiKey, baseUrl, maxRetries, timeout, logLevel. -export const sent = new SentDm({ maxRetries: 3, timeout: 30_000 }); +// apiKey is explicitly supplied for the authorized application account. +export const sent = new SentDm({ apiKey, maxRetries: 3, timeout: 30_000 }); const response = await sent.messages.send({ to: ['+14155551234'], @@ -44,8 +46,8 @@ const response = await sent.messages.send({ ```python from sent_dm import Sent, AsyncSent -client = Sent(max_retries=2, timeout=60.0) # reads SENT_DM_API_KEY -async_client = AsyncSent() +client = Sent(api_key=api_key, max_retries=2, timeout=60.0) +async_client = AsyncSent(api_key=api_key) response = client.messages.send( to=["+14155551234"], @@ -54,46 +56,40 @@ response = client.messages.send( ``` ```go -client := sentdm.NewClient() // or option.WithAPIKey(...) +client := sentdm.NewClient(option.WithAPIKey(apiKey)) response, err := client.Messages.Send(ctx, sentdm.MessageSendParams{ To: []string{"+14155551234"}, }) ``` ```java -SentClient client = SentOkHttpClient.fromEnv(); // SENT_DM_API_KEY or sent.dmApiKey +SentClient client = SentOkHttpClient.builder().apiKey(apiKey).build(); MessageSendResponse response = client.messages().send(params); ``` ```csharp using Sentdm; -SentClient client = new(); // reads SENT_DM_API_KEY +SentClient client = new() { ApiKey = apiKey }; var response = await client.Messages.Send(body); ``` ```php use SentDm\Client; -$client = new Client($_ENV['SENT_DM_API_KEY']); // key is an explicit constructor argument +$client = new Client(apiKey: $apiKey); $result = $client->messages->send(to: ['+14155551234'], template: ['name' => 'order_confirmation']); ``` ```ruby require "sentdm" -client = Sentdm::Client.new # reads SENT_DM_API_KEY +client = Sentdm::Client.new(api_key: api_key) client.messages.send_(to: ["+14155551234"], template: { name: "order_confirmation" }) ``` Java and C# expose both synchronous and asynchronous clients; Python offers `Sent` and `AsyncSent`; TypeScript and C# are promise- or task-based only; Go and PHP and Ruby are synchronous, with Go carrying a `context.Context` on every call. -## Configuration and environment variables - -| Variable | Purpose | Read automatically | -| --- | --- | --- | -| `SENT_DM_API_KEY` | REST credential sent as `x-api-key` | Yes, in every SDK except PHP | -| `SENT_DM_WEBHOOK_SECRET` | `whsec_`-prefixed webhook signing secret | No; application code reads it | -| `SENT_BASE_URL` | Override the API base URL | Java and C# read it; others take a constructor option | +## Explicit application configuration -Older documentation pages use `SENT_API_KEY` and `SENT_WEBHOOK_SECRET`. Both name sets appear in official material; standardize new code on the `SENT_DM_` names because the SDK defaults use them, and accept the shorter names as aliases when adopting existing code. +Pass the authorized application's API key as the SDK's explicit credential option. Pass a webhook signing secret to verification helpers as a separate argument, resolved for the received webhook id. Keep both values in application-owned secret storage and do not print them, put them in prompts, or reuse installer credentials. This plugin does not declare user-config API keys because its MCP connection uses client-managed OAuth. For a single-account service, validate the server-managed key at startup with the ecosystem's schema tooling — `zod` in Node, `pydantic-settings` in Python, `@nestjs/config`, `IOptions` with `[Required]` in .NET — so a missing key fails the deployment rather than the first customer send. For a multi-tenant proxy, validate non-secret configuration at startup and reject each request whose resolved credential is absent or malformed. diff --git a/packages/sent/skills/sent-webhook-engineer/SKILL.md b/packages/sent/skills/sent-webhook-engineer/SKILL.md index 4dbf860..de8c208 100644 --- a/packages/sent/skills/sent-webhook-engineer/SKILL.md +++ b/packages/sent/skills/sent-webhook-engineer/SKILL.md @@ -28,6 +28,8 @@ Verification procedure, in order: The scheme is Svix-compatible. No Sent SDK ships a verification helper in any language, so this code is always hand-written — use [scripts/verify_signature.py](scripts/verify_signature.py) as the reference implementation and oracle. +The local utility accepts a signing secret explicitly on standard input with `--secret-stdin` and makes no network requests. It never reads environment variables or installer credentials. Supply only the secret authorized for the delivery being checked; do not put it in command arguments or chat prompts. + **`x-webhook-id` is not an event id.** It identifies the endpoint and repeats forever. Using it as a dedupe key silently collapses every event into one. Read [references/webhook-signature-and-dedupe.md](references/webhook-signature-and-dedupe.md) for the dedupe keys to derive per event type. ## Failure triage order diff --git a/packages/sent/skills/sent-webhook-engineer/references/receiver-recipes.md b/packages/sent/skills/sent-webhook-engineer/references/receiver-recipes.md index 0609e5a..88bd6e0 100644 --- a/packages/sent/skills/sent-webhook-engineer/references/receiver-recipes.md +++ b/packages/sent/skills/sent-webhook-engineer/references/receiver-recipes.md @@ -33,7 +33,7 @@ Every recipe follows the same four steps: read the raw body, verify the signatur | Sinatra | `request.body.read` then `request.body.rewind` | Same | | ASP.NET Core | `new StreamReader(request.Body).ReadToEndAsync()` | Read before model binding touches the stream | -Environment variables: the SDKs read `SENT_DM_API_KEY` by default, and the receiver samples use `SENT_DM_WEBHOOK_SECRET`. Older documentation pages use `SENT_API_KEY` and `SENT_WEBHOOK_SECRET`; treat those as aliases and standardize on the `SENT_DM_` names in new code. +These are receiver application examples, not plugin authentication code. Supply the signing secret explicitly from application-owned configuration, resolved for the received webhook id. The `signingSecret` and `signing_secret` variables below represent that authorized, injected value. Reject missing values; never inspect the plugin installer's environment, credential store, or MCP tokens. The plugin's MCP connection uses client-managed OAuth. ## Node and TypeScript @@ -42,8 +42,7 @@ import crypto from "node:crypto"; const TOLERANCE_SECONDS = 300; -export function verify(rawBody: string, webhookId: string, timestamp: string, header: string): boolean { - const secret = process.env.SENT_DM_WEBHOOK_SECRET ?? ""; +export function verify(rawBody: string, webhookId: string, timestamp: string, header: string, secret: string): boolean { if (!secret || !header?.startsWith("v1,")) return false; if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > TOLERANCE_SECONDS) return false; @@ -67,6 +66,7 @@ export async function POST(request: Request): Promise { request.headers.get("x-webhook-id") ?? "", request.headers.get("x-webhook-timestamp") ?? "", request.headers.get("x-webhook-signature") ?? "", + signingSecret, ); if (!ok) return new Response("invalid signature", { status: 401 }); @@ -80,7 +80,7 @@ Express, scoping the raw parser to the webhook path only: ```js app.post("/webhooks/sent", express.raw({ type: "application/json" }), (req, res) => { const rawBody = req.body.toString("utf8"); - if (!verify(rawBody, req.get("x-webhook-id"), req.get("x-webhook-timestamp"), req.get("x-webhook-signature"))) { + if (!verify(rawBody, req.get("x-webhook-id"), req.get("x-webhook-timestamp"), req.get("x-webhook-signature"), signingSecret)) { return res.status(401).send("invalid signature"); } res.status(200).end(); @@ -93,13 +93,12 @@ Mount `express.json()` on other routers rather than globally with `app.use`. In ## Python ```python -import base64, hashlib, hmac, os, time +import base64, hashlib, hmac, time TOLERANCE_SECONDS = 300 -def verify(raw_body: bytes, webhook_id: str, timestamp: str, header: str) -> bool: - secret = os.environ.get("SENT_DM_WEBHOOK_SECRET", "") +def verify(raw_body: bytes, webhook_id: str, timestamp: str, header: str, secret: str) -> bool: if not secret or not header.startswith("v1,"): return False try: @@ -121,7 +120,7 @@ async def receive(request: Request, background: BackgroundTasks): raw = await request.body() if not verify(raw, request.headers.get("x-webhook-id", ""), request.headers.get("x-webhook-timestamp", ""), - request.headers.get("x-webhook-signature", "")): + request.headers.get("x-webhook-signature", ""), signing_secret): raise HTTPException(status_code=401, detail="invalid signature") background.add_task(process_event, json.loads(raw)) return {"received": True} @@ -132,8 +131,7 @@ Django reads `request.body` in the view and must exempt the route from CSRF. Fla ## Go ```go -func Verify(rawBody []byte, webhookID, timestamp, header string) bool { - secret := os.Getenv("SENT_DM_WEBHOOK_SECRET") +func Verify(rawBody []byte, webhookID, timestamp, header, secret string) bool { if secret == "" || !strings.HasPrefix(header, "v1,") { return false } @@ -165,7 +163,7 @@ public ResponseEntity receive( @RequestHeader("x-webhook-timestamp") String timestamp, @RequestHeader("x-webhook-signature") String signature) throws Exception { - if (!WebhookSignature.verify(payload, webhookId, timestamp, signature)) { + if (!WebhookSignature.verify(payload, webhookId, timestamp, signature, signingSecret)) { return ResponseEntity.status(401).build(); } events.submit(payload); // @Async executor @@ -182,7 +180,8 @@ Laravel middleware runs before the controller and reads `$request->getContent()` ```php public function handle(Request $request, Closure $next) { - $secret = env('SENT_DM_WEBHOOK_SECRET', ''); + $secret = $this->signingSecret; // Injected for this webhook endpoint. + if (!$secret) abort(401); $signed = $request->header('x-webhook-id') . '.' . $request->header('x-webhook-timestamp') . '.' . $request->getContent(); $key = base64_decode(preg_replace('/^whsec_/', '', $secret)); $expected = 'v1,' . base64_encode(hash_hmac('sha256', $signed, $key, true)); @@ -200,10 +199,9 @@ Dispatch a `ShouldQueue` job from the controller. Symfony follows the same patte ## Ruby ```ruby -def verified?(request) +def verified?(request, secret) raw = request.body.read request.body.rewind - secret = ENV.fetch("SENT_DM_WEBHOOK_SECRET", "") timestamp = request.get_header("HTTP_X_WEBHOOK_TIMESTAMP").to_s return false if secret.empty? || (Time.now.to_i - timestamp.to_i).abs > 300 @@ -229,7 +227,7 @@ app.MapPost("/webhooks/sent", async (HttpRequest request) => request.Headers["x-webhook-id"], request.Headers["x-webhook-timestamp"], request.Headers["x-webhook-signature"], - Environment.GetEnvironmentVariable("SENT_DM_WEBHOOK_SECRET"))) + signingSecret)) { return Results.Unauthorized(); } diff --git a/packages/sent/skills/sent-webhook-engineer/scripts/verify_signature.py b/packages/sent/skills/sent-webhook-engineer/scripts/verify_signature.py index e79391f..7c46e76 100644 --- a/packages/sent/skills/sent-webhook-engineer/scripts/verify_signature.py +++ b/packages/sent/skills/sent-webhook-engineer/scripts/verify_signature.py @@ -17,13 +17,17 @@ --webhook-id 0f8fad5b-d9cb-469f-a165-70867728950e \ --timestamp 1767225600 \ --signature 'v1,Base64Signature==' \ - --secret-env SENT_DM_WEBHOOK_SECRET + --secret-stdin Sign a synthetic delivery so a local receiver can be exercised:: python3 verify_signature.py --sign --body-file event.json \ --webhook-id 0f8fad5b-d9cb-469f-a165-70867728950e \ - --secret-env SENT_DM_WEBHOOK_SECRET + --secret-stdin + +Supply only the signing secret chosen for this delivery on standard input. +This utility never reads installer credentials or environment variables and +makes no network requests. Do not put the secret in arguments or chat prompts. Exit codes: 0 valid, 1 invalid signature, 2 replay window exceeded, 3 usage or configuration error. @@ -36,7 +40,6 @@ import hashlib import hmac import json -import os import sys import time @@ -149,12 +152,12 @@ def _self_test() -> int: def _resolve_secret(args: argparse.Namespace) -> str: - if args.secret_env: - secret = os.environ.get(args.secret_env, "") - if not secret: - raise ValueError(f"environment variable {args.secret_env} is unset or empty") - return secret - raise ValueError("provide --secret-env naming the environment variable that holds the signing secret") + if not args.secret_stdin: + raise ValueError("provide --secret-stdin to supply the signing secret explicitly") + secret = sys.stdin.read(4097).strip() + if not secret or len(secret) > 4096: + raise ValueError("standard input must contain one signing secret of at most 4096 characters") + return secret def main(argv: list[str] | None = None) -> int: @@ -165,7 +168,7 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--webhook-id", help="value of the x-webhook-id header") parser.add_argument("--timestamp", help="value of the x-webhook-timestamp header") parser.add_argument("--signature", help="value of the x-webhook-signature header") - parser.add_argument("--secret-env", help="environment variable holding the whsec_ signing secret") + parser.add_argument("--secret-stdin", action="store_true", help="read only the explicitly supplied signing secret from standard input") parser.add_argument( "--skip-replay-check", action="store_true", @@ -182,6 +185,7 @@ def main(argv: list[str] | None = None) -> int: try: raw_body = open(args.body_file, "rb").read() secret = _resolve_secret(args) + decode_secret(secret) except (OSError, ValueError) as exc: print(f"error: {exc}", file=sys.stderr) return EXIT_USAGE diff --git a/plugin.json b/plugin.json index 8d8ad3e..6f13906 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "sent", - "version": "0.2.0", + "version": "0.2.1", "description": "Official Sent plugin for SMS, WhatsApp, RCS, scheduled template sends, Sender Profiles, SMS compliance, authorized feedback, contacts, analytics, API integration, webhooks, routing, and migration.", "author": { "name": "Sent", diff --git a/plugins/sent/.codex-plugin/plugin.json b/plugins/sent/.codex-plugin/plugin.json index 6723ef8..9878882 100644 --- a/plugins/sent/.codex-plugin/plugin.json +++ b/plugins/sent/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "sent", - "version": "0.2.0", + "version": "0.2.1", "description": "Official Sent plugin for SMS, WhatsApp, RCS, scheduled template sends, Sender Profiles, SMS compliance, authorized feedback, contacts, analytics, API integration, webhooks, routing, and migration.", "author": { "name": "Sent", diff --git a/plugins/sent/README.md b/plugins/sent/README.md index b00fdb7..42076da 100644 --- a/plugins/sent/README.md +++ b/plugins/sent/README.md @@ -71,3 +71,5 @@ Dashboard volume counts delivered/read outbound SMS and WhatsApp over supported Use the connected server's tool schemas for argument names and availability. The [public MCP landing page](https://mcp.sent.dm) lists the current surface; some documentation pages may describe an earlier catalog. The bundled migration inventory scanner reads local regular files in the selected repository and makes no network requests. It skips symbolic links and environment files and omits source excerpts from reports. Other bundled utilities validate supplied local payloads or analyze supplied local exports. Remote account reads and authorized writes use the declared Sent MCP endpoint. + +The webhook signature utility runs locally with an explicitly supplied signing secret on standard input (`--secret-stdin`); it makes no network requests and does not read installer credentials or environment variables. Application integration examples require explicitly supplied API keys and signing secrets. Plugin authentication remains client-managed OAuth. diff --git a/plugins/sent/skills/migrate-to-sent/scripts/inventory_scan.py b/plugins/sent/skills/migrate-to-sent/scripts/inventory_scan.py index 10fcad4..49f6f35 100644 --- a/plugins/sent/skills/migrate-to-sent/scripts/inventory_scan.py +++ b/plugins/sent/skills/migrate-to-sent/scripts/inventory_scan.py @@ -57,7 +57,7 @@ class Rule: ), Rule( "twilio-sdk", "twilio", r"\b(?:from\s+twilio|require\(['\"]twilio|com\.twilio|Twilio\.Rest|twilio-go)\b", - "rewrite", "Replace the Twilio client with a Sent SDK client reading SENT_DM_API_KEY.", + "rewrite", "Replace the Twilio client with a Sent SDK client with an explicitly supplied application API key.", ), Rule( "twilio-messages-endpoint", "twilio", r"api\.twilio\.com/2010-04-01/Accounts/[^/]*/Messages", diff --git a/plugins/sent/skills/sent-integration-starter/SKILL.md b/plugins/sent/skills/sent-integration-starter/SKILL.md index 1f16fb3..f137c40 100644 --- a/plugins/sent/skills/sent-integration-starter/SKILL.md +++ b/plugins/sent/skills/sent-integration-starter/SKILL.md @@ -13,15 +13,15 @@ Direct Sent v3 REST requests authenticate with the `x-api-key` header. An applic | Language | Package | Client | | --- | --- | --- | -| TypeScript | `@sentdm/sentdm` | `new SentDm()` | -| Python | `sentdm` (imports `sent_dm`) | `Sent()` or `AsyncSent()` | -| Go | `github.com/sentdm/sent-dm-go` | `sentdm.NewClient()` | -| Java | `dm.sent:sent-java` | `SentOkHttpClient.fromEnv()` | -| C# | `Sentdm` | `new SentClient()` | +| TypeScript | `@sentdm/sentdm` | `new SentDm({ apiKey })` | +| Python | `sentdm` (imports `sent_dm`) | `Sent(api_key=api_key)` or `AsyncSent(api_key=api_key)` | +| Go | `github.com/sentdm/sent-dm-go` | `sentdm.NewClient(option.WithAPIKey(apiKey))` | +| Java | `dm.sent:sent-java` | `SentOkHttpClient.builder().apiKey(apiKey).build()` | +| C# | `Sentdm` | `new SentClient { ApiKey = apiKey }` | | PHP | `sentdm/sent-dm-php` | `new SentDm\Client($apiKey)` | -| Ruby | `sentdm` | `Sentdm::Client.new` | +| Ruby | `sentdm` | `Sentdm::Client.new(api_key: api_key)` | -Every SDK except PHP reads `SENT_DM_API_KEY` automatically. Single-endpoint receiver samples read `SENT_DM_WEBHOOK_SECRET`; multi-tenant production receivers need a secret registry keyed by webhook id instead of one process-wide secret. Older documentation uses `SENT_API_KEY` and `SENT_WEBHOOK_SECRET` — treat those as aliases and standardize on the `SENT_DM_` names. +For generated application code, require an explicit, non-empty API key supplied by the application owner and pass it through the SDK's credential option. Inject the signing secret resolved for each webhook id into receiver helpers. Never inspect the plugin installer's environment, credential store, or MCP tokens; the plugin itself authenticates through client-managed OAuth. Multi-tenant production receivers need a secret registry keyed by webhook id. Choose the client lifecycle from the credential model. A single-account service with one server-managed key should reuse a long-lived client and its connection pool. A multi-tenant proxy that resolves a caller or profile credential per request should construct the client for that request and discard it, so tenant credentials cannot leak through shared state. Framework-specific wiring, the Ruby `messages.send_` naming quirk, and per-ecosystem background-work choices are in [references/sdk-and-frameworks.md](references/sdk-and-frameworks.md). @@ -81,7 +81,7 @@ Log `meta.request_id` on every response, success or failure — it is the correl ### Launch checklist -- [ ] Credentials load from the environment; nothing is committed, and separate keys exist per environment. +- [ ] Credentials are explicitly supplied from application-owned configuration; nothing is committed, and separate keys exist per environment. - [ ] Client lifecycle matches credential scope: shared for one server-managed key, per request for tenant-supplied credentials. - [ ] `Idempotency-Key` on every mutating call, derived deterministically. - [ ] Retry policy distinguishes retryable from terminal by error family. diff --git a/plugins/sent/skills/sent-integration-starter/references/sdk-and-frameworks.md b/plugins/sent/skills/sent-integration-starter/references/sdk-and-frameworks.md index 713b428..895de51 100644 --- a/plugins/sent/skills/sent-integration-starter/references/sdk-and-frameworks.md +++ b/plugins/sent/skills/sent-integration-starter/references/sdk-and-frameworks.md @@ -4,7 +4,7 @@ - [Package matrix](#package-matrix) - [Client construction per language](#client-construction-per-language) -- [Configuration and environment variables](#configuration-and-environment-variables) +- [Explicit application configuration](#explicit-application-configuration) - [Framework wiring](#framework-wiring) - [Background processing per ecosystem](#background-processing-per-ecosystem) - [Multi-tenant credential patterns](#multi-tenant-credential-patterns) @@ -29,11 +29,13 @@ No SDK ships a webhook signature verifier in any language. That code is always a ## Client construction per language +These are application examples, not plugin authentication code. Each `apiKey` or `api_key` below is a non-empty value explicitly supplied by the application owner for the intended account. Validate it before construction so an absent value cannot trigger an SDK environment fallback. Never read the plugin installer's environment, credential store, or MCP tokens. Plugin operations use client-managed OAuth instead. + ```typescript import SentDm from '@sentdm/sentdm'; -// Reads SENT_DM_API_KEY. Options: apiKey, baseUrl, maxRetries, timeout, logLevel. -export const sent = new SentDm({ maxRetries: 3, timeout: 30_000 }); +// apiKey is explicitly supplied for the authorized application account. +export const sent = new SentDm({ apiKey, maxRetries: 3, timeout: 30_000 }); const response = await sent.messages.send({ to: ['+14155551234'], @@ -44,8 +46,8 @@ const response = await sent.messages.send({ ```python from sent_dm import Sent, AsyncSent -client = Sent(max_retries=2, timeout=60.0) # reads SENT_DM_API_KEY -async_client = AsyncSent() +client = Sent(api_key=api_key, max_retries=2, timeout=60.0) +async_client = AsyncSent(api_key=api_key) response = client.messages.send( to=["+14155551234"], @@ -54,46 +56,40 @@ response = client.messages.send( ``` ```go -client := sentdm.NewClient() // or option.WithAPIKey(...) +client := sentdm.NewClient(option.WithAPIKey(apiKey)) response, err := client.Messages.Send(ctx, sentdm.MessageSendParams{ To: []string{"+14155551234"}, }) ``` ```java -SentClient client = SentOkHttpClient.fromEnv(); // SENT_DM_API_KEY or sent.dmApiKey +SentClient client = SentOkHttpClient.builder().apiKey(apiKey).build(); MessageSendResponse response = client.messages().send(params); ``` ```csharp using Sentdm; -SentClient client = new(); // reads SENT_DM_API_KEY +SentClient client = new() { ApiKey = apiKey }; var response = await client.Messages.Send(body); ``` ```php use SentDm\Client; -$client = new Client($_ENV['SENT_DM_API_KEY']); // key is an explicit constructor argument +$client = new Client(apiKey: $apiKey); $result = $client->messages->send(to: ['+14155551234'], template: ['name' => 'order_confirmation']); ``` ```ruby require "sentdm" -client = Sentdm::Client.new # reads SENT_DM_API_KEY +client = Sentdm::Client.new(api_key: api_key) client.messages.send_(to: ["+14155551234"], template: { name: "order_confirmation" }) ``` Java and C# expose both synchronous and asynchronous clients; Python offers `Sent` and `AsyncSent`; TypeScript and C# are promise- or task-based only; Go and PHP and Ruby are synchronous, with Go carrying a `context.Context` on every call. -## Configuration and environment variables - -| Variable | Purpose | Read automatically | -| --- | --- | --- | -| `SENT_DM_API_KEY` | REST credential sent as `x-api-key` | Yes, in every SDK except PHP | -| `SENT_DM_WEBHOOK_SECRET` | `whsec_`-prefixed webhook signing secret | No; application code reads it | -| `SENT_BASE_URL` | Override the API base URL | Java and C# read it; others take a constructor option | +## Explicit application configuration -Older documentation pages use `SENT_API_KEY` and `SENT_WEBHOOK_SECRET`. Both name sets appear in official material; standardize new code on the `SENT_DM_` names because the SDK defaults use them, and accept the shorter names as aliases when adopting existing code. +Pass the authorized application's API key as the SDK's explicit credential option. Pass a webhook signing secret to verification helpers as a separate argument, resolved for the received webhook id. Keep both values in application-owned secret storage and do not print them, put them in prompts, or reuse installer credentials. This plugin does not declare user-config API keys because its MCP connection uses client-managed OAuth. For a single-account service, validate the server-managed key at startup with the ecosystem's schema tooling — `zod` in Node, `pydantic-settings` in Python, `@nestjs/config`, `IOptions` with `[Required]` in .NET — so a missing key fails the deployment rather than the first customer send. For a multi-tenant proxy, validate non-secret configuration at startup and reject each request whose resolved credential is absent or malformed. diff --git a/plugins/sent/skills/sent-webhook-engineer/SKILL.md b/plugins/sent/skills/sent-webhook-engineer/SKILL.md index 4dbf860..de8c208 100644 --- a/plugins/sent/skills/sent-webhook-engineer/SKILL.md +++ b/plugins/sent/skills/sent-webhook-engineer/SKILL.md @@ -28,6 +28,8 @@ Verification procedure, in order: The scheme is Svix-compatible. No Sent SDK ships a verification helper in any language, so this code is always hand-written — use [scripts/verify_signature.py](scripts/verify_signature.py) as the reference implementation and oracle. +The local utility accepts a signing secret explicitly on standard input with `--secret-stdin` and makes no network requests. It never reads environment variables or installer credentials. Supply only the secret authorized for the delivery being checked; do not put it in command arguments or chat prompts. + **`x-webhook-id` is not an event id.** It identifies the endpoint and repeats forever. Using it as a dedupe key silently collapses every event into one. Read [references/webhook-signature-and-dedupe.md](references/webhook-signature-and-dedupe.md) for the dedupe keys to derive per event type. ## Failure triage order diff --git a/plugins/sent/skills/sent-webhook-engineer/references/receiver-recipes.md b/plugins/sent/skills/sent-webhook-engineer/references/receiver-recipes.md index 0609e5a..88bd6e0 100644 --- a/plugins/sent/skills/sent-webhook-engineer/references/receiver-recipes.md +++ b/plugins/sent/skills/sent-webhook-engineer/references/receiver-recipes.md @@ -33,7 +33,7 @@ Every recipe follows the same four steps: read the raw body, verify the signatur | Sinatra | `request.body.read` then `request.body.rewind` | Same | | ASP.NET Core | `new StreamReader(request.Body).ReadToEndAsync()` | Read before model binding touches the stream | -Environment variables: the SDKs read `SENT_DM_API_KEY` by default, and the receiver samples use `SENT_DM_WEBHOOK_SECRET`. Older documentation pages use `SENT_API_KEY` and `SENT_WEBHOOK_SECRET`; treat those as aliases and standardize on the `SENT_DM_` names in new code. +These are receiver application examples, not plugin authentication code. Supply the signing secret explicitly from application-owned configuration, resolved for the received webhook id. The `signingSecret` and `signing_secret` variables below represent that authorized, injected value. Reject missing values; never inspect the plugin installer's environment, credential store, or MCP tokens. The plugin's MCP connection uses client-managed OAuth. ## Node and TypeScript @@ -42,8 +42,7 @@ import crypto from "node:crypto"; const TOLERANCE_SECONDS = 300; -export function verify(rawBody: string, webhookId: string, timestamp: string, header: string): boolean { - const secret = process.env.SENT_DM_WEBHOOK_SECRET ?? ""; +export function verify(rawBody: string, webhookId: string, timestamp: string, header: string, secret: string): boolean { if (!secret || !header?.startsWith("v1,")) return false; if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > TOLERANCE_SECONDS) return false; @@ -67,6 +66,7 @@ export async function POST(request: Request): Promise { request.headers.get("x-webhook-id") ?? "", request.headers.get("x-webhook-timestamp") ?? "", request.headers.get("x-webhook-signature") ?? "", + signingSecret, ); if (!ok) return new Response("invalid signature", { status: 401 }); @@ -80,7 +80,7 @@ Express, scoping the raw parser to the webhook path only: ```js app.post("/webhooks/sent", express.raw({ type: "application/json" }), (req, res) => { const rawBody = req.body.toString("utf8"); - if (!verify(rawBody, req.get("x-webhook-id"), req.get("x-webhook-timestamp"), req.get("x-webhook-signature"))) { + if (!verify(rawBody, req.get("x-webhook-id"), req.get("x-webhook-timestamp"), req.get("x-webhook-signature"), signingSecret)) { return res.status(401).send("invalid signature"); } res.status(200).end(); @@ -93,13 +93,12 @@ Mount `express.json()` on other routers rather than globally with `app.use`. In ## Python ```python -import base64, hashlib, hmac, os, time +import base64, hashlib, hmac, time TOLERANCE_SECONDS = 300 -def verify(raw_body: bytes, webhook_id: str, timestamp: str, header: str) -> bool: - secret = os.environ.get("SENT_DM_WEBHOOK_SECRET", "") +def verify(raw_body: bytes, webhook_id: str, timestamp: str, header: str, secret: str) -> bool: if not secret or not header.startswith("v1,"): return False try: @@ -121,7 +120,7 @@ async def receive(request: Request, background: BackgroundTasks): raw = await request.body() if not verify(raw, request.headers.get("x-webhook-id", ""), request.headers.get("x-webhook-timestamp", ""), - request.headers.get("x-webhook-signature", "")): + request.headers.get("x-webhook-signature", ""), signing_secret): raise HTTPException(status_code=401, detail="invalid signature") background.add_task(process_event, json.loads(raw)) return {"received": True} @@ -132,8 +131,7 @@ Django reads `request.body` in the view and must exempt the route from CSRF. Fla ## Go ```go -func Verify(rawBody []byte, webhookID, timestamp, header string) bool { - secret := os.Getenv("SENT_DM_WEBHOOK_SECRET") +func Verify(rawBody []byte, webhookID, timestamp, header, secret string) bool { if secret == "" || !strings.HasPrefix(header, "v1,") { return false } @@ -165,7 +163,7 @@ public ResponseEntity receive( @RequestHeader("x-webhook-timestamp") String timestamp, @RequestHeader("x-webhook-signature") String signature) throws Exception { - if (!WebhookSignature.verify(payload, webhookId, timestamp, signature)) { + if (!WebhookSignature.verify(payload, webhookId, timestamp, signature, signingSecret)) { return ResponseEntity.status(401).build(); } events.submit(payload); // @Async executor @@ -182,7 +180,8 @@ Laravel middleware runs before the controller and reads `$request->getContent()` ```php public function handle(Request $request, Closure $next) { - $secret = env('SENT_DM_WEBHOOK_SECRET', ''); + $secret = $this->signingSecret; // Injected for this webhook endpoint. + if (!$secret) abort(401); $signed = $request->header('x-webhook-id') . '.' . $request->header('x-webhook-timestamp') . '.' . $request->getContent(); $key = base64_decode(preg_replace('/^whsec_/', '', $secret)); $expected = 'v1,' . base64_encode(hash_hmac('sha256', $signed, $key, true)); @@ -200,10 +199,9 @@ Dispatch a `ShouldQueue` job from the controller. Symfony follows the same patte ## Ruby ```ruby -def verified?(request) +def verified?(request, secret) raw = request.body.read request.body.rewind - secret = ENV.fetch("SENT_DM_WEBHOOK_SECRET", "") timestamp = request.get_header("HTTP_X_WEBHOOK_TIMESTAMP").to_s return false if secret.empty? || (Time.now.to_i - timestamp.to_i).abs > 300 @@ -229,7 +227,7 @@ app.MapPost("/webhooks/sent", async (HttpRequest request) => request.Headers["x-webhook-id"], request.Headers["x-webhook-timestamp"], request.Headers["x-webhook-signature"], - Environment.GetEnvironmentVariable("SENT_DM_WEBHOOK_SECRET"))) + signingSecret)) { return Results.Unauthorized(); } diff --git a/plugins/sent/skills/sent-webhook-engineer/scripts/verify_signature.py b/plugins/sent/skills/sent-webhook-engineer/scripts/verify_signature.py index e79391f..7c46e76 100644 --- a/plugins/sent/skills/sent-webhook-engineer/scripts/verify_signature.py +++ b/plugins/sent/skills/sent-webhook-engineer/scripts/verify_signature.py @@ -17,13 +17,17 @@ --webhook-id 0f8fad5b-d9cb-469f-a165-70867728950e \ --timestamp 1767225600 \ --signature 'v1,Base64Signature==' \ - --secret-env SENT_DM_WEBHOOK_SECRET + --secret-stdin Sign a synthetic delivery so a local receiver can be exercised:: python3 verify_signature.py --sign --body-file event.json \ --webhook-id 0f8fad5b-d9cb-469f-a165-70867728950e \ - --secret-env SENT_DM_WEBHOOK_SECRET + --secret-stdin + +Supply only the signing secret chosen for this delivery on standard input. +This utility never reads installer credentials or environment variables and +makes no network requests. Do not put the secret in arguments or chat prompts. Exit codes: 0 valid, 1 invalid signature, 2 replay window exceeded, 3 usage or configuration error. @@ -36,7 +40,6 @@ import hashlib import hmac import json -import os import sys import time @@ -149,12 +152,12 @@ def _self_test() -> int: def _resolve_secret(args: argparse.Namespace) -> str: - if args.secret_env: - secret = os.environ.get(args.secret_env, "") - if not secret: - raise ValueError(f"environment variable {args.secret_env} is unset or empty") - return secret - raise ValueError("provide --secret-env naming the environment variable that holds the signing secret") + if not args.secret_stdin: + raise ValueError("provide --secret-stdin to supply the signing secret explicitly") + secret = sys.stdin.read(4097).strip() + if not secret or len(secret) > 4096: + raise ValueError("standard input must contain one signing secret of at most 4096 characters") + return secret def main(argv: list[str] | None = None) -> int: @@ -165,7 +168,7 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--webhook-id", help="value of the x-webhook-id header") parser.add_argument("--timestamp", help="value of the x-webhook-timestamp header") parser.add_argument("--signature", help="value of the x-webhook-signature header") - parser.add_argument("--secret-env", help="environment variable holding the whsec_ signing secret") + parser.add_argument("--secret-stdin", action="store_true", help="read only the explicitly supplied signing secret from standard input") parser.add_argument( "--skip-replay-check", action="store_true", @@ -182,6 +185,7 @@ def main(argv: list[str] | None = None) -> int: try: raw_body = open(args.body_file, "rb").read() secret = _resolve_secret(args) + decode_secret(secret) except (OSError, ValueError) as exc: print(f"error: {exc}", file=sys.stderr) return EXIT_USAGE diff --git a/scripts/generate_adapters.py b/scripts/generate_adapters.py index 219d64d..f81264c 100644 --- a/scripts/generate_adapters.py +++ b/scripts/generate_adapters.py @@ -127,6 +127,10 @@ def build(output_root: Path, source_root: Path = ROOT) -> None: claude_manifest = { **common, + "documentationUrl": marketplace["website_url"], + "supportUrl": marketplace["support_url"], + "privacyPolicyUrl": marketplace["privacy_policy_url"], + "termsOfServiceUrl": marketplace["terms_of_service_url"], "commands": "./.claude/commands", "skills": "./skills", "mcpServers": "./.mcp.json", diff --git a/scripts/test_fixtures.py b/scripts/test_fixtures.py index 71c00c9..b34bd77 100644 --- a/scripts/test_fixtures.py +++ b/scripts/test_fixtures.py @@ -4,6 +4,7 @@ from __future__ import annotations import copy +import base64 import importlib.util import json import os @@ -11,6 +12,7 @@ import sys import tempfile import unittest +import time from pathlib import Path from types import ModuleType @@ -38,6 +40,8 @@ def load_module(name: str, path: Path) -> ModuleType: TEMPLATE = load_module("sent_template_linter", TEMPLATE_ROOT / "lint_waba_template.py") INVENTORY_SCRIPT = SKILLS / "migrate-to-sent" / "scripts" / "inventory_scan.py" INVENTORY = load_module("sent_inventory_scanner", INVENTORY_SCRIPT) +SIGNATURE_SCRIPT = SKILLS / "sent-webhook-engineer" / "scripts" / "verify_signature.py" +SIGNATURE = load_module("sent_signature_verifier", SIGNATURE_SCRIPT) def read_fixture(root: Path, name: str) -> object: @@ -492,6 +496,32 @@ def test_mdr_json_show_errors_has_stable_error_counts(self) -> None: self.assertEqual(report["error_codes"], {"ERR_ROUTE_DENIED": 1}) +class SignatureCredentialTests(unittest.TestCase): + def test_secret_requires_explicit_input_and_never_uses_environment(self) -> None: + secret = "whsec_" + base64.b64encode(b"synthetic-signing-key-0123456789").decode() + with tempfile.TemporaryDirectory() as directory: + body = Path(directory) / "event.json" + body.write_text('{}') + command = [sys.executable, str(SIGNATURE_SCRIPT), "--sign", "--body-file", str(body), + "--webhook-id", "synthetic-webhook", "--timestamp", str(int(time.time()))] + environment = {**os.environ, "SENT_DM_WEBHOOK_SECRET": secret} + absent = subprocess.run(command, env=environment, capture_output=True, text=True, timeout=10) + self.assertEqual(absent.returncode, SIGNATURE.EXIT_USAGE) + self.assertNotIn(secret, absent.stdout + absent.stderr) + supplied = subprocess.run(command + ["--secret-stdin"], input=secret, + env={**environment, "SENT_DM_WEBHOOK_SECRET": "invalid"}, + capture_output=True, text=True, timeout=10) + self.assertEqual(supplied.returncode, SIGNATURE.EXIT_VALID, supplied.stderr) + expected = SIGNATURE.compute_signature(secret, "synthetic-webhook", command[-1], b'{}') + self.assertIn(expected, supplied.stdout) + self.assertNotIn(secret, supplied.stdout + supplied.stderr) + for value in ("", "invalid!", "x" * 4097): + invalid = subprocess.run(command + ["--secret-stdin"], input=value, + capture_output=True, text=True, timeout=10) + self.assertEqual(invalid.returncode, SIGNATURE.EXIT_USAGE) + self.assertNotIn("Traceback", invalid.stderr) + + class InventorySecurityTests(unittest.TestCase): def test_credentials_never_appear_in_reports(self) -> None: source = '\n'.join(( diff --git a/scripts/test_validation_gates.py b/scripts/test_validation_gates.py index 4844a0d..e807a2b 100644 --- a/scripts/test_validation_gates.py +++ b/scripts/test_validation_gates.py @@ -262,7 +262,21 @@ def invalid_support_url(root: Path) -> None: check=True, capture_output=True, text=True) +def ambient_credential_read(root: Path) -> None: + path = root / "packages/sent/skills/sent-integration-starter/SKILL.md" + with path.open("a") as handle: + handle.write('\nclient = Sent(api_key=os.environ.get("SENT_DM_API_KEY"))\n') + + +def missing_claude_privacy_url(root: Path) -> None: + path = root / "scripts/generate_adapters.py" + path.write_text(path.read_text().replace(' "privacyPolicyUrl": marketplace["privacy_policy_url"],\n', '')) + subprocess.run([sys.executable, str(path)], cwd=root, check=True, capture_output=True, text=True) + + CASES: tuple[tuple[str, Mutation, str], ...] = ( + ("ambient credential reads", ambient_credential_read, "public gate rejected ambient credential read"), + ("Claude privacy policy listing", missing_claude_privacy_url, "Claude privacyPolicyUrl must match"), ("independent mutation confirmation policy", disabled_profile_confirmation, "independent safety policy"), ("profile confirmation instructions", missing_profile_confirmation_instruction, "must require explicit confirmation"), ("portable OpenAI listing", missing_portable_openai_metadata, "portable OpenAI interface must match"), diff --git a/scripts/validate.py b/scripts/validate.py index 73453e0..426848f 100644 --- a/scripts/validate.py +++ b/scripts/validate.py @@ -62,6 +62,11 @@ MCP_SKILLS = REPOSITORY_METADATA.tools_by_owner if REPOSITORY_METADATA else {} MUTATION_TOOLS = REPOSITORY_METADATA.confirmation_tools if REPOSITORY_METADATA else {} PUBLIC_FORBIDDEN = { + "ambient credential read": re.compile( + r"(?:os\.environ\b|os\.getenv\s*\(|process\.env\b|os\.Getenv\s*\(" + r"|Environment\.GetEnvironmentVariable\s*\(|\$_ENV\s*\[|\bENV(?:\.fetch\s*\(|\s*\[)" + r"|\benv\s*\(|\.fromEnv\s*\()" + ), "Linear URL": re.compile(r"https?://(?:www\.)?linear\.app", re.IGNORECASE), "Slack-derived content": re.compile(r"\bSlack(?:-derived| thread| message| channel)?\b", re.IGNORECASE), "local macOS path": re.compile(r"/Users/[^\s)`]+"), @@ -964,6 +969,10 @@ def validate_adapters(validation: Validation) -> None: validation.check(claude_manifest.get("skills") == "./skills", "Claude skills path mismatch") validation.check(claude_manifest.get("commands") == "./.claude/commands", "Claude command path mismatch") validation.check(claude_manifest.get("mcpServers") == "./.mcp.json", "Claude MCP path mismatch") + listing = load_json(MARKETPLACE_CONTENT) + for field, source in (("documentationUrl", "website_url"), ("supportUrl", "support_url"), + ("privacyPolicyUrl", "privacy_policy_url"), ("termsOfServiceUrl", "terms_of_service_url")): + validation.check(claude_manifest.get(field) == listing[source], f"Claude {field} must match canonical listing") for required_file in ("README.md", "LICENSE"): validation.check( (ROOT / "claude-plugins" / "sent" / required_file).is_file(), diff --git a/skills/migrate-to-sent/scripts/inventory_scan.py b/skills/migrate-to-sent/scripts/inventory_scan.py index 10fcad4..49f6f35 100644 --- a/skills/migrate-to-sent/scripts/inventory_scan.py +++ b/skills/migrate-to-sent/scripts/inventory_scan.py @@ -57,7 +57,7 @@ class Rule: ), Rule( "twilio-sdk", "twilio", r"\b(?:from\s+twilio|require\(['\"]twilio|com\.twilio|Twilio\.Rest|twilio-go)\b", - "rewrite", "Replace the Twilio client with a Sent SDK client reading SENT_DM_API_KEY.", + "rewrite", "Replace the Twilio client with a Sent SDK client with an explicitly supplied application API key.", ), Rule( "twilio-messages-endpoint", "twilio", r"api\.twilio\.com/2010-04-01/Accounts/[^/]*/Messages", diff --git a/skills/sent-integration-starter/SKILL.md b/skills/sent-integration-starter/SKILL.md index 1f16fb3..f137c40 100644 --- a/skills/sent-integration-starter/SKILL.md +++ b/skills/sent-integration-starter/SKILL.md @@ -13,15 +13,15 @@ Direct Sent v3 REST requests authenticate with the `x-api-key` header. An applic | Language | Package | Client | | --- | --- | --- | -| TypeScript | `@sentdm/sentdm` | `new SentDm()` | -| Python | `sentdm` (imports `sent_dm`) | `Sent()` or `AsyncSent()` | -| Go | `github.com/sentdm/sent-dm-go` | `sentdm.NewClient()` | -| Java | `dm.sent:sent-java` | `SentOkHttpClient.fromEnv()` | -| C# | `Sentdm` | `new SentClient()` | +| TypeScript | `@sentdm/sentdm` | `new SentDm({ apiKey })` | +| Python | `sentdm` (imports `sent_dm`) | `Sent(api_key=api_key)` or `AsyncSent(api_key=api_key)` | +| Go | `github.com/sentdm/sent-dm-go` | `sentdm.NewClient(option.WithAPIKey(apiKey))` | +| Java | `dm.sent:sent-java` | `SentOkHttpClient.builder().apiKey(apiKey).build()` | +| C# | `Sentdm` | `new SentClient { ApiKey = apiKey }` | | PHP | `sentdm/sent-dm-php` | `new SentDm\Client($apiKey)` | -| Ruby | `sentdm` | `Sentdm::Client.new` | +| Ruby | `sentdm` | `Sentdm::Client.new(api_key: api_key)` | -Every SDK except PHP reads `SENT_DM_API_KEY` automatically. Single-endpoint receiver samples read `SENT_DM_WEBHOOK_SECRET`; multi-tenant production receivers need a secret registry keyed by webhook id instead of one process-wide secret. Older documentation uses `SENT_API_KEY` and `SENT_WEBHOOK_SECRET` — treat those as aliases and standardize on the `SENT_DM_` names. +For generated application code, require an explicit, non-empty API key supplied by the application owner and pass it through the SDK's credential option. Inject the signing secret resolved for each webhook id into receiver helpers. Never inspect the plugin installer's environment, credential store, or MCP tokens; the plugin itself authenticates through client-managed OAuth. Multi-tenant production receivers need a secret registry keyed by webhook id. Choose the client lifecycle from the credential model. A single-account service with one server-managed key should reuse a long-lived client and its connection pool. A multi-tenant proxy that resolves a caller or profile credential per request should construct the client for that request and discard it, so tenant credentials cannot leak through shared state. Framework-specific wiring, the Ruby `messages.send_` naming quirk, and per-ecosystem background-work choices are in [references/sdk-and-frameworks.md](references/sdk-and-frameworks.md). @@ -81,7 +81,7 @@ Log `meta.request_id` on every response, success or failure — it is the correl ### Launch checklist -- [ ] Credentials load from the environment; nothing is committed, and separate keys exist per environment. +- [ ] Credentials are explicitly supplied from application-owned configuration; nothing is committed, and separate keys exist per environment. - [ ] Client lifecycle matches credential scope: shared for one server-managed key, per request for tenant-supplied credentials. - [ ] `Idempotency-Key` on every mutating call, derived deterministically. - [ ] Retry policy distinguishes retryable from terminal by error family. diff --git a/skills/sent-integration-starter/references/sdk-and-frameworks.md b/skills/sent-integration-starter/references/sdk-and-frameworks.md index 713b428..895de51 100644 --- a/skills/sent-integration-starter/references/sdk-and-frameworks.md +++ b/skills/sent-integration-starter/references/sdk-and-frameworks.md @@ -4,7 +4,7 @@ - [Package matrix](#package-matrix) - [Client construction per language](#client-construction-per-language) -- [Configuration and environment variables](#configuration-and-environment-variables) +- [Explicit application configuration](#explicit-application-configuration) - [Framework wiring](#framework-wiring) - [Background processing per ecosystem](#background-processing-per-ecosystem) - [Multi-tenant credential patterns](#multi-tenant-credential-patterns) @@ -29,11 +29,13 @@ No SDK ships a webhook signature verifier in any language. That code is always a ## Client construction per language +These are application examples, not plugin authentication code. Each `apiKey` or `api_key` below is a non-empty value explicitly supplied by the application owner for the intended account. Validate it before construction so an absent value cannot trigger an SDK environment fallback. Never read the plugin installer's environment, credential store, or MCP tokens. Plugin operations use client-managed OAuth instead. + ```typescript import SentDm from '@sentdm/sentdm'; -// Reads SENT_DM_API_KEY. Options: apiKey, baseUrl, maxRetries, timeout, logLevel. -export const sent = new SentDm({ maxRetries: 3, timeout: 30_000 }); +// apiKey is explicitly supplied for the authorized application account. +export const sent = new SentDm({ apiKey, maxRetries: 3, timeout: 30_000 }); const response = await sent.messages.send({ to: ['+14155551234'], @@ -44,8 +46,8 @@ const response = await sent.messages.send({ ```python from sent_dm import Sent, AsyncSent -client = Sent(max_retries=2, timeout=60.0) # reads SENT_DM_API_KEY -async_client = AsyncSent() +client = Sent(api_key=api_key, max_retries=2, timeout=60.0) +async_client = AsyncSent(api_key=api_key) response = client.messages.send( to=["+14155551234"], @@ -54,46 +56,40 @@ response = client.messages.send( ``` ```go -client := sentdm.NewClient() // or option.WithAPIKey(...) +client := sentdm.NewClient(option.WithAPIKey(apiKey)) response, err := client.Messages.Send(ctx, sentdm.MessageSendParams{ To: []string{"+14155551234"}, }) ``` ```java -SentClient client = SentOkHttpClient.fromEnv(); // SENT_DM_API_KEY or sent.dmApiKey +SentClient client = SentOkHttpClient.builder().apiKey(apiKey).build(); MessageSendResponse response = client.messages().send(params); ``` ```csharp using Sentdm; -SentClient client = new(); // reads SENT_DM_API_KEY +SentClient client = new() { ApiKey = apiKey }; var response = await client.Messages.Send(body); ``` ```php use SentDm\Client; -$client = new Client($_ENV['SENT_DM_API_KEY']); // key is an explicit constructor argument +$client = new Client(apiKey: $apiKey); $result = $client->messages->send(to: ['+14155551234'], template: ['name' => 'order_confirmation']); ``` ```ruby require "sentdm" -client = Sentdm::Client.new # reads SENT_DM_API_KEY +client = Sentdm::Client.new(api_key: api_key) client.messages.send_(to: ["+14155551234"], template: { name: "order_confirmation" }) ``` Java and C# expose both synchronous and asynchronous clients; Python offers `Sent` and `AsyncSent`; TypeScript and C# are promise- or task-based only; Go and PHP and Ruby are synchronous, with Go carrying a `context.Context` on every call. -## Configuration and environment variables - -| Variable | Purpose | Read automatically | -| --- | --- | --- | -| `SENT_DM_API_KEY` | REST credential sent as `x-api-key` | Yes, in every SDK except PHP | -| `SENT_DM_WEBHOOK_SECRET` | `whsec_`-prefixed webhook signing secret | No; application code reads it | -| `SENT_BASE_URL` | Override the API base URL | Java and C# read it; others take a constructor option | +## Explicit application configuration -Older documentation pages use `SENT_API_KEY` and `SENT_WEBHOOK_SECRET`. Both name sets appear in official material; standardize new code on the `SENT_DM_` names because the SDK defaults use them, and accept the shorter names as aliases when adopting existing code. +Pass the authorized application's API key as the SDK's explicit credential option. Pass a webhook signing secret to verification helpers as a separate argument, resolved for the received webhook id. Keep both values in application-owned secret storage and do not print them, put them in prompts, or reuse installer credentials. This plugin does not declare user-config API keys because its MCP connection uses client-managed OAuth. For a single-account service, validate the server-managed key at startup with the ecosystem's schema tooling — `zod` in Node, `pydantic-settings` in Python, `@nestjs/config`, `IOptions` with `[Required]` in .NET — so a missing key fails the deployment rather than the first customer send. For a multi-tenant proxy, validate non-secret configuration at startup and reject each request whose resolved credential is absent or malformed. diff --git a/skills/sent-webhook-engineer/SKILL.md b/skills/sent-webhook-engineer/SKILL.md index 4dbf860..de8c208 100644 --- a/skills/sent-webhook-engineer/SKILL.md +++ b/skills/sent-webhook-engineer/SKILL.md @@ -28,6 +28,8 @@ Verification procedure, in order: The scheme is Svix-compatible. No Sent SDK ships a verification helper in any language, so this code is always hand-written — use [scripts/verify_signature.py](scripts/verify_signature.py) as the reference implementation and oracle. +The local utility accepts a signing secret explicitly on standard input with `--secret-stdin` and makes no network requests. It never reads environment variables or installer credentials. Supply only the secret authorized for the delivery being checked; do not put it in command arguments or chat prompts. + **`x-webhook-id` is not an event id.** It identifies the endpoint and repeats forever. Using it as a dedupe key silently collapses every event into one. Read [references/webhook-signature-and-dedupe.md](references/webhook-signature-and-dedupe.md) for the dedupe keys to derive per event type. ## Failure triage order diff --git a/skills/sent-webhook-engineer/references/receiver-recipes.md b/skills/sent-webhook-engineer/references/receiver-recipes.md index 0609e5a..88bd6e0 100644 --- a/skills/sent-webhook-engineer/references/receiver-recipes.md +++ b/skills/sent-webhook-engineer/references/receiver-recipes.md @@ -33,7 +33,7 @@ Every recipe follows the same four steps: read the raw body, verify the signatur | Sinatra | `request.body.read` then `request.body.rewind` | Same | | ASP.NET Core | `new StreamReader(request.Body).ReadToEndAsync()` | Read before model binding touches the stream | -Environment variables: the SDKs read `SENT_DM_API_KEY` by default, and the receiver samples use `SENT_DM_WEBHOOK_SECRET`. Older documentation pages use `SENT_API_KEY` and `SENT_WEBHOOK_SECRET`; treat those as aliases and standardize on the `SENT_DM_` names in new code. +These are receiver application examples, not plugin authentication code. Supply the signing secret explicitly from application-owned configuration, resolved for the received webhook id. The `signingSecret` and `signing_secret` variables below represent that authorized, injected value. Reject missing values; never inspect the plugin installer's environment, credential store, or MCP tokens. The plugin's MCP connection uses client-managed OAuth. ## Node and TypeScript @@ -42,8 +42,7 @@ import crypto from "node:crypto"; const TOLERANCE_SECONDS = 300; -export function verify(rawBody: string, webhookId: string, timestamp: string, header: string): boolean { - const secret = process.env.SENT_DM_WEBHOOK_SECRET ?? ""; +export function verify(rawBody: string, webhookId: string, timestamp: string, header: string, secret: string): boolean { if (!secret || !header?.startsWith("v1,")) return false; if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > TOLERANCE_SECONDS) return false; @@ -67,6 +66,7 @@ export async function POST(request: Request): Promise { request.headers.get("x-webhook-id") ?? "", request.headers.get("x-webhook-timestamp") ?? "", request.headers.get("x-webhook-signature") ?? "", + signingSecret, ); if (!ok) return new Response("invalid signature", { status: 401 }); @@ -80,7 +80,7 @@ Express, scoping the raw parser to the webhook path only: ```js app.post("/webhooks/sent", express.raw({ type: "application/json" }), (req, res) => { const rawBody = req.body.toString("utf8"); - if (!verify(rawBody, req.get("x-webhook-id"), req.get("x-webhook-timestamp"), req.get("x-webhook-signature"))) { + if (!verify(rawBody, req.get("x-webhook-id"), req.get("x-webhook-timestamp"), req.get("x-webhook-signature"), signingSecret)) { return res.status(401).send("invalid signature"); } res.status(200).end(); @@ -93,13 +93,12 @@ Mount `express.json()` on other routers rather than globally with `app.use`. In ## Python ```python -import base64, hashlib, hmac, os, time +import base64, hashlib, hmac, time TOLERANCE_SECONDS = 300 -def verify(raw_body: bytes, webhook_id: str, timestamp: str, header: str) -> bool: - secret = os.environ.get("SENT_DM_WEBHOOK_SECRET", "") +def verify(raw_body: bytes, webhook_id: str, timestamp: str, header: str, secret: str) -> bool: if not secret or not header.startswith("v1,"): return False try: @@ -121,7 +120,7 @@ async def receive(request: Request, background: BackgroundTasks): raw = await request.body() if not verify(raw, request.headers.get("x-webhook-id", ""), request.headers.get("x-webhook-timestamp", ""), - request.headers.get("x-webhook-signature", "")): + request.headers.get("x-webhook-signature", ""), signing_secret): raise HTTPException(status_code=401, detail="invalid signature") background.add_task(process_event, json.loads(raw)) return {"received": True} @@ -132,8 +131,7 @@ Django reads `request.body` in the view and must exempt the route from CSRF. Fla ## Go ```go -func Verify(rawBody []byte, webhookID, timestamp, header string) bool { - secret := os.Getenv("SENT_DM_WEBHOOK_SECRET") +func Verify(rawBody []byte, webhookID, timestamp, header, secret string) bool { if secret == "" || !strings.HasPrefix(header, "v1,") { return false } @@ -165,7 +163,7 @@ public ResponseEntity receive( @RequestHeader("x-webhook-timestamp") String timestamp, @RequestHeader("x-webhook-signature") String signature) throws Exception { - if (!WebhookSignature.verify(payload, webhookId, timestamp, signature)) { + if (!WebhookSignature.verify(payload, webhookId, timestamp, signature, signingSecret)) { return ResponseEntity.status(401).build(); } events.submit(payload); // @Async executor @@ -182,7 +180,8 @@ Laravel middleware runs before the controller and reads `$request->getContent()` ```php public function handle(Request $request, Closure $next) { - $secret = env('SENT_DM_WEBHOOK_SECRET', ''); + $secret = $this->signingSecret; // Injected for this webhook endpoint. + if (!$secret) abort(401); $signed = $request->header('x-webhook-id') . '.' . $request->header('x-webhook-timestamp') . '.' . $request->getContent(); $key = base64_decode(preg_replace('/^whsec_/', '', $secret)); $expected = 'v1,' . base64_encode(hash_hmac('sha256', $signed, $key, true)); @@ -200,10 +199,9 @@ Dispatch a `ShouldQueue` job from the controller. Symfony follows the same patte ## Ruby ```ruby -def verified?(request) +def verified?(request, secret) raw = request.body.read request.body.rewind - secret = ENV.fetch("SENT_DM_WEBHOOK_SECRET", "") timestamp = request.get_header("HTTP_X_WEBHOOK_TIMESTAMP").to_s return false if secret.empty? || (Time.now.to_i - timestamp.to_i).abs > 300 @@ -229,7 +227,7 @@ app.MapPost("/webhooks/sent", async (HttpRequest request) => request.Headers["x-webhook-id"], request.Headers["x-webhook-timestamp"], request.Headers["x-webhook-signature"], - Environment.GetEnvironmentVariable("SENT_DM_WEBHOOK_SECRET"))) + signingSecret)) { return Results.Unauthorized(); } diff --git a/skills/sent-webhook-engineer/scripts/verify_signature.py b/skills/sent-webhook-engineer/scripts/verify_signature.py index e79391f..7c46e76 100644 --- a/skills/sent-webhook-engineer/scripts/verify_signature.py +++ b/skills/sent-webhook-engineer/scripts/verify_signature.py @@ -17,13 +17,17 @@ --webhook-id 0f8fad5b-d9cb-469f-a165-70867728950e \ --timestamp 1767225600 \ --signature 'v1,Base64Signature==' \ - --secret-env SENT_DM_WEBHOOK_SECRET + --secret-stdin Sign a synthetic delivery so a local receiver can be exercised:: python3 verify_signature.py --sign --body-file event.json \ --webhook-id 0f8fad5b-d9cb-469f-a165-70867728950e \ - --secret-env SENT_DM_WEBHOOK_SECRET + --secret-stdin + +Supply only the signing secret chosen for this delivery on standard input. +This utility never reads installer credentials or environment variables and +makes no network requests. Do not put the secret in arguments or chat prompts. Exit codes: 0 valid, 1 invalid signature, 2 replay window exceeded, 3 usage or configuration error. @@ -36,7 +40,6 @@ import hashlib import hmac import json -import os import sys import time @@ -149,12 +152,12 @@ def _self_test() -> int: def _resolve_secret(args: argparse.Namespace) -> str: - if args.secret_env: - secret = os.environ.get(args.secret_env, "") - if not secret: - raise ValueError(f"environment variable {args.secret_env} is unset or empty") - return secret - raise ValueError("provide --secret-env naming the environment variable that holds the signing secret") + if not args.secret_stdin: + raise ValueError("provide --secret-stdin to supply the signing secret explicitly") + secret = sys.stdin.read(4097).strip() + if not secret or len(secret) > 4096: + raise ValueError("standard input must contain one signing secret of at most 4096 characters") + return secret def main(argv: list[str] | None = None) -> int: @@ -165,7 +168,7 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--webhook-id", help="value of the x-webhook-id header") parser.add_argument("--timestamp", help="value of the x-webhook-timestamp header") parser.add_argument("--signature", help="value of the x-webhook-signature header") - parser.add_argument("--secret-env", help="environment variable holding the whsec_ signing secret") + parser.add_argument("--secret-stdin", action="store_true", help="read only the explicitly supplied signing secret from standard input") parser.add_argument( "--skip-replay-check", action="store_true", @@ -182,6 +185,7 @@ def main(argv: list[str] | None = None) -> int: try: raw_body = open(args.body_file, "rb").read() secret = _resolve_secret(args) + decode_secret(secret) except (OSError, ValueError) as exc: print(f"error: {exc}", file=sys.stderr) return EXIT_USAGE