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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions docs/HTTP_CLIENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,37 @@ SDK. Raw durable storage is deliberately absent. Workspace activity is the
recorded view, not an event-stream subscription. Contract tests do not prove a
particular live provider/forge configuration; installation and live lifecycle
receipts are separate acceptance evidence.

## Verifying webhook events

Ship's run webhook (`SHIP_NOTIFY_URL`) is signed as before:
`X-Teploy-Signature: sha256=hex(HMAC-SHA256(secret, timestamp + "." + body))`.
That is unchanged and is all a receiver needs. Delivery is at-least-once, so a
receiver should also dedupe on `X-Teploy-Delivery`.

If the operator sets `SHIP_EVENT_ENVELOPE=on` (default off) and a
`SHIP_NOTIFY_SECRET`, each delivery that carries an `event_seq` also gets two
additive headers; the body and every existing header stay byte-identical:

- `X-Teploy-Event`: a JSON envelope (`eventId`, `schemaVersion`, `type`,
`cursor`, `occurredAt`, `data`). `eventId` equals `X-Teploy-Delivery`, `cursor`
is `event_seq`, and `data.payloadSha256` is the SHA-256 of the exact body.
- `X-Teploy-Event-Signature`: `sha256=hex(HMAC-SHA256(secret, X-Teploy-Timestamp + "." + <the X-Teploy-Event value>))`.

`verifyEvent` and `EventDedupe` in `examples/http-client.mjs` check all of that
(signature, a 5 minute replay window, schema major, body binding) and report a
retried delivery as `duplicate: true`, which you acknowledge without acting on:

```js
import { EventDedupe, verifyEvent } from './examples/http-client.mjs';
const dedupe = new EventDedupe();
// `raw` is the unparsed request body string; headers are lower-cased (node:http)
const r = verifyEvent(req.headers, raw, process.env.SHIP_NOTIFY_SECRET, { dedupe });
if (!r.ok) return reply(401, r.reason); // never act on an unverified event
if (r.duplicate) return reply(200, 'duplicate'); // already handled
handle(JSON.parse(raw), r.event.cursor);
```

Verify against the raw bytes, not a re-serialised body. Keep the dedupe set
durable if your receiver restarts. Run webhooks carry no `requestId`: a run
notification is not the answer to one of your calls.
55 changes: 55 additions & 0 deletions examples/http-client.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';

/** A dependency-free Node 22 client for Ship's existing HTTP surfaces. */
export class ShipHTTPError extends Error {
constructor(status, operation, detail) {
Expand Down Expand Up @@ -79,3 +81,56 @@ export class ShipClient {
}, 'follow-up');
}
}

// ---- Event verification (webhook receivers) -------------------------------
// A port of verifyEvent/EventDedupe from src/tool-manifest.ts, kept dependency
// free. Only meaningful when the Ship operator set SHIP_EVENT_ENVELOPE=on and a
// notify secret: Ship then adds X-Teploy-Event and X-Teploy-Event-Signature to
// each run webhook (body and X-Teploy-Signature unchanged).

/** Bounded set of seen event ids (FIFO eviction). */
export class EventDedupe {
#seen = new Set();
#capacity;
constructor(capacity = 10_000) { this.#capacity = capacity; }
has(id) { return this.#seen.has(id); }
add(id) {
this.#seen.add(id);
if (this.#seen.size > this.#capacity) this.#seen.delete(this.#seen.values().next().value);
}
}

/**
* Verify one delivery. `headers` is a plain object with lower-cased names.
* Order: authenticate, freshness, parse, major version, body binding, dedupe.
* Only an event that passed everything is remembered, so a forged one cannot
* poison the dedupe set. A duplicate returns { ok: true, duplicate: true }:
* acknowledge it (2xx) and do nothing. Returns { ok: false, reason } otherwise.
*/
export function verifyEvent(headers, body, secret, { nowMs = Date.now(), replayWindowMs = 300_000, dedupe } = {}) {
const envelope = headers['x-teploy-event'];
const signature = headers['x-teploy-event-signature'];
const timestamp = headers['x-teploy-timestamp'];
if (typeof envelope !== 'string' || typeof signature !== 'string') return { ok: false, reason: 'no-envelope' };
const sig = /^sha256=([0-9a-f]{64})$/.exec(signature);
if (!sig) return { ok: false, reason: 'malformed-signature' };
if (!/^\d{1,12}$/.test(timestamp ?? '')) return { ok: false, reason: 'bad-timestamp' };
if (!secret) return { ok: false, reason: 'bad-signature' };
const expected = createHmac('sha256', secret).update(`${timestamp}.${envelope}`).digest();
const given = Buffer.from(sig[1], 'hex');
if (given.length !== expected.length || !timingSafeEqual(given, expected)) return { ok: false, reason: 'bad-signature' };
if (Math.abs(nowMs - Number(timestamp) * 1000) > replayWindowMs) return { ok: false, reason: 'expired' };
let event;
try { event = JSON.parse(envelope); } catch { return { ok: false, reason: 'malformed-body' }; }
if (!event || typeof event !== 'object' || typeof event.eventId !== 'string' || event.eventId === '' ||
typeof event.type !== 'string' || typeof event.schemaVersion !== 'string' || !Number.isInteger(event.cursor) ||
event.cursor < 0 || typeof event.occurredAt !== 'string') return { ok: false, reason: 'malformed-envelope' };
const version = /^(\d+)\.(\d+)/.exec(event.schemaVersion);
if (!version) return { ok: false, reason: 'malformed-envelope' };
if (Number(version[1]) !== 1) return { ok: false, reason: 'unsupported-major' };
// The envelope travels in headers; this ties it to the body actually received.
if (event.data?.payloadSha256 !== createHash('sha256').update(body).digest('hex')) return { ok: false, reason: 'body-mismatch' };
if (dedupe?.has(event.eventId)) return { ok: true, duplicate: true, eventId: event.eventId };
dedupe?.add(event.eventId);
return { ok: true, duplicate: false, event };
}
14 changes: 14 additions & 0 deletions src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { formatObservation, systemPrompt } from "./prompt.js";
import { scrub } from "./redact.js";
import { RecoveryTracker, defaultRecoveryConfig } from "./recovery.js";
import type { RecoveryConfig } from "./recovery.js";
import { observeToolCall } from "./tool-manifest-shadow.js";

export interface AgentStep {
index: number;
Expand Down Expand Up @@ -548,6 +549,19 @@ export async function executeAction(
timeoutMs?: number,
scriptSuffix?: string,
useKernel = true,
): Promise<ExecResult> {
const result = await runAction(executor, action, timeoutMs, scriptSuffix, useKernel);
// S24 shadow: report the call AFTER it ran; a no-op unless SHIP_TOOL_MANIFEST is set.
void observeToolCall(action);
return result;
}

async function runAction(
executor: AgentExecutor,
action: Extract<Action, { kind: "bash" | "python" | "edit" | "create" }>,
timeoutMs?: number,
scriptSuffix?: string,
useKernel = true,
): Promise<ExecResult> {
const opts = timeoutMs !== undefined ? { timeoutMs } : {};
const suffix = scriptSuffix ?? String(Date.now());
Expand Down
1 change: 1 addition & 0 deletions src/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export const COMMAND_FLAGS: Record<string, FlagSpec> = {
enqueue: { boolean: ["json", "plan", "critic", "settle", "tests"], value: ["repo", "model", "store", "nucleus-url"] },
evidence: { boolean: ["json"], value: ["test-command", "test-timeout-ms", "observe-service"] },
policy: { boolean: ["json"], value: ["roles", "users", "teams", "source", "days", "start", "end", "tz", "store", "nucleus-url"] },
tool: { boolean: ["json"], value: ["grant"] },
resume: { value: ["model", "sandbox", "sandbox-token", "sandbox-image", "sandbox-network", "max-steps"] },
approve: { boolean: ["handoff"], value: ["model", "sandbox", "sandbox-token", "sandbox-image", "sandbox-network"] },
deny: { boolean: ["handoff"], value: ["model", "sandbox", "sandbox-token", "sandbox-image", "sandbox-network"] },
Expand Down
33 changes: 33 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import type { RepoRef } from "./git.js";
import { assertRepoAllowed, credentialFor, policyFromEnv } from "./repo-policy.js";
import { placementShadowFile, placementShadowFromEnv, readPlacementSummary, renderPlacementReport } from "./placement-shadow.js";
import { readShadowSummary, renderShadowReport, shadowFile } from "./policy-shadow.js";
import { dryRunManifest, parseGrant, renderDryRun } from "./tool-manifest-shadow.js";
import { detectRepository } from "./stack-import.js";
import { renderProposal, stackDetectEnabled } from "./stack-propose.js";
import type { RepoPolicyConfig } from "./repo-policy.js";
Expand Down Expand Up @@ -138,6 +139,9 @@ Usage:
teploy-ship policy window remove [--source <s>] (no --source = the global window)
teploy-ship policy window check [--source <s>] is auto allowed right now?
teploy-ship policy reviewers set <repo> [--users a,b] [--teams t] (both empty = remove)
teploy-ship tool validate <manifest.json> [--grant <json|file>] [--json]
dry run: is a tool manifest well formed, and what would
the admin grant actually allow? Stores and enforces nothing
teploy-ship placement shadow-report [--json] where the placement shadow (SHIP_PLACEMENT=shadow) disagreed with the pool
teploy-ship audit export the run history (what ran, cost, PRs)
[--format csv|json] [--since <iso>] [--until <iso>]
Expand Down Expand Up @@ -1637,6 +1641,33 @@ async function projectCommand(rest: string[]): Promise<void> {
* The buyer half of P2-3 (governance.ts): per-user authority, auto windows,
* required reviewers. The dashboard's Policies page edits the same store.
*/
/**
* S24: `tool validate` is the read-only install dry run. It reads a manifest
* and an admin-entered grant, prints validation and the effective permissions
* (intersection, with the excess reported), and writes nothing. Exit 1 when the
* manifest is invalid so a script can gate on it.
*/
async function toolCommand(rest: string[]): Promise<void> {
const args = parseArgs(rest, COMMAND_FLAGS.tool);
const [sub, file] = args.positional;
if (sub !== "validate" || file === undefined) fail("usage: teploy-ship tool validate <manifest.json> [--grant <json|file>] [--json]");
let manifest: unknown;
try {
manifest = JSON.parse(readFileSync(file, "utf8"));
} catch (error) {
fail(`cannot read manifest ${file}: ${error instanceof Error ? error.message : String(error)}`);
}
let grant;
try {
grant = parseGrant(typeof args.flags.grant === "string" ? args.flags.grant : undefined);
} catch (error) {
fail(`bad --grant: ${error instanceof Error ? error.message : String(error)}`);
}
const report = dryRunManifest(manifest, grant);
process.stdout.write(args.flags.json === true ? `${JSON.stringify(report, null, 2)}\n` : `${renderDryRun(report)}\n`);
if (!report.validation.ok) process.exitCode = 1;
}

/** S26 placement shadow: `placement shadow-report [--json]`. Read-only. */
async function placementCommand(rest: string[]): Promise<void> {
const [sub] = rest;
Expand Down Expand Up @@ -2442,6 +2473,8 @@ async function main(): Promise<void> {
return projectCommand(rest);
case "policy":
return policyCommand(rest);
case "tool":
return toolCommand(rest);
case "placement":
return placementCommand(rest);
case "audit":
Expand Down
52 changes: 52 additions & 0 deletions src/notify.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type { ScanFinding } from "./findings.js";
import type { ChangeClass } from "./change-class.js";
import { rungsForWire, type Rung } from "./ladder.js";
import { canonicalJson, EVENT_SCHEMA_VERSION } from "./tool-manifest.js";
import type { EventEnvelope } from "./tool-manifest.js";
import { createHash, createHmac } from "node:crypto";

/**
* A4 — outbound notifications: one short Slack message when a run needs
Expand Down Expand Up @@ -351,9 +354,55 @@ export async function signWebhookBody(
return { "X-Teploy-Timestamp": ts, "X-Teploy-Signature": `sha256=${mac}` };
}

/**
* S24: the signed EventEnvelope as an ADDITIVE pair of headers, behind
* `SHIP_EVENT_ENVELOPE=on` (default off). The body and the existing
* X-Teploy-Timestamp / X-Teploy-Signature are untouched, so a receiver that
* has never heard of envelopes is byte-compatible whether the flag is on or off.
*
* X-Teploy-Event: the envelope as JSON (eventId, cursor, type, ...)
* X-Teploy-Event-Signature: sha256=hex(HMAC(secret, <same timestamp> + "." + that header value))
*
* The envelope rides in headers, not the body, because the body can be 64 KiB
* and headers cannot; `data.payloadSha256` binds the envelope to the exact body
* that was sent, so a receiver verifies the envelope with verifyEvent, then
* compares the hash. eventId is the delivery id (stable across retries) so the
* receiver's dedupe matches X-Teploy-Delivery; cursor is event_seq. An event
* with no event_seq has no honest cursor, so it carries NO envelope. There is no
* requestId: a run notification is not the answer to a client call.
*/
export function eventEnvelopeEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
return (env.SHIP_EVENT_ENVELOPE ?? "").trim().toLowerCase() === "on";
}

export function envelopeHeaders(
event: RunNotification,
deliveryId: string | undefined,
secret: string,
body: string,
timestamp: string,
): Record<string, string> {
if (secret === "" || event.eventSeq === undefined || !Number.isInteger(event.eventSeq) || event.eventSeq < 0) return {};
const eventId = deliveryId ?? `${event.runId}:${event.status}:${event.eventName ?? ""}:${event.eventSeq}`;
const envelope: EventEnvelope = {
eventId,
schemaVersion: EVENT_SCHEMA_VERSION,
type: `run.${event.status}`,
cursor: event.eventSeq,
occurredAt: event.eventAt ?? new Date(Number(timestamp) * 1000).toISOString(),
data: { runId: event.runId, status: event.status, payloadSha256: createHash("sha256").update(body).digest("hex") },
};
// Header values are ByteStrings: escape anything non-ASCII (the same JSON).
const value = canonicalJson(envelope).replace(/[^\x20-\x7e]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
const mac = createHmac("sha256", secret).update(`${timestamp}.${value}`).digest("hex");
return { "X-Teploy-Event": value, "X-Teploy-Event-Signature": `sha256=${mac}` };
}

export function webhookNotifier(options?: {
webhookUrl?: string;
secret?: string;
/** Add the signed-envelope headers (default: SHIP_EVENT_ENVELOPE=on). */
eventEnvelope?: boolean;
publicUrl?: string;
log?: (line: string) => void;
fetchImpl?: typeof fetch;
Expand All @@ -363,6 +412,7 @@ export function webhookNotifier(options?: {
const publicUrl = options?.publicUrl ?? process.env.SHIP_PUBLIC_URL ?? "";
const log = options?.log ?? ((line: string) => process.stderr.write(line + "\n"));
const fetchImpl = options?.fetchImpl ?? fetch;
const envelopeOn = options?.eventEnvelope ?? eventEnvelopeEnabled();
if (webhookUrl === "") return { enabled: false, runEvent: async () => true };
return {
enabled: true,
Expand All @@ -371,6 +421,7 @@ export function webhookNotifier(options?: {
const body = JSON.stringify(runWebhookPayload(event, publicUrl));
try {
const headers = await signWebhookBody(secret, body);
const envelope = envelopeOn ? envelopeHeaders(event, deliveryId, secret, body, headers["X-Teploy-Timestamp"] ?? "") : {};
const response = await fetchImpl(webhookUrl, {
method: "POST",
headers: {
Expand All @@ -379,6 +430,7 @@ export function webhookNotifier(options?: {
// recognise a repeat instead of acting on it twice.
...(deliveryId !== undefined ? { "X-Teploy-Delivery": deliveryId } : {}),
...headers,
...envelope,
},
body,
});
Expand Down
Loading
Loading