Skip to content

feat(check): fail closed on tools and servers in inline check payloads - #62

Merged
scott-lowe-vapi merged 1 commit into
mainfrom
feat/check-fail-closed-mocks
Oct 3, 2026
Merged

scott-lowe-vapi merged 1 commit into
mainfrom
feat/check-fail-closed-mocks

Conversation

@scott-lowe-vapi

Copy link
Copy Markdown
Contributor

Value

V.A.L.U.E. tier: project — PR 6 of 10 for inline simulation PR checks (TEST-141); this is the safety-critical PR, so it's kept separate for review. Still offline: nothing is sent until PR 7.

  • Problem: a PR check runs the branch's agents in a real org, with real LLM conversations that call tools. Without a policy, a check could send a real SMS, book a real appointment, transfer to a real number, or POST transcripts to a customer's webhook, on every push.
  • Who it affects: gitops users, who need checks safe by default; and the TEST-141 failure condition "any tool call reaching a real server under default settings".
  • What changes: src/check-mocks.ts runs as the last pass of checkPayloadBuild under toolMocks: strict (the default).
Class Types Treatment
No external side effect endCall, dtmf, voicemail, output; query (same org only) Sent as written
Knowledge base in model.toolIds by UUID Same org: kept, with a warning. Cross-org or inline: fails
Handoff handoff Only to a squad member by name, or an inline assistant (walked). dynamic, squad, non-members: fail
Mockable function by function.name; apiRequest by top-level name (url set to the dead host) Scenario mock if present, otherwise {"error":"vapi-gitops-ci: <tool> is not mocked in this scenario"}
Transfer transferCall Rewritten to a mocked dead-server function under its own name, so it can never connect
Everything else sms, sipRequest, code, mcp, bash, computer, textEditor, transferCancel, transferSuccessful, google.*, slack.*, gohighlevel.*, ghl, make, unknown Fails the build, naming the tool
  • Structural rule: any key in the exported TOOL_BEARING_KEYS outside a handled position fails with "unsupported tool position". Examples: model.functions, model.toolRefs, reasoner skills, declineTool, tools:append outside overrides. A future API field that carries tools fails instead of slipping through.
  • Servers are replaced, never deleted, because a deleted server falls back to the phone number's or the org's URL:
    • every assistant (target, members, inline handoff assistants, personalities) gets server: {url: "https://vapi-gitops-ci.invalid", timeoutSeconds: 1} and serverMessages: [];
    • overrides that set a server get the dead one;
    • every function tool gets the dead server, and serverUrl / serverUrlSecret are dropped;
    • scenario webhook hooks get the dead server.
  • Default mocks go in each scenario's toolMocks, never in assistant metadata, because handoffs rebuild the assistant. A user mock with enabled: false is replaced, and a mock naming no tool in the target produces a warning.
  • Also fails:
    • model.knowledgeBaseId, and custom-provider knowledge bases;
    • personality tools beyond the side-effect-free ones;
    • hook transfer actions, and hook toolIds;
    • scenarioId entries, and non-stock personalityIds.
  • Hook-fired tools (assistant hooks[].do[]) probably bypass scenario toolMocks, which apply on the LLM tool-call path. They're classified the same way and get the dead server, which is the real safeguard there. This is documented in the module header and goes into simulations.md in PR 8.
  • Opt-outs:
    • toolMocks: off skips the tool rules, for a dedicated CI org;
    • stripWebhooks: false keeps assistant servers, while tool servers are still replaced under strict mocks.

Evidence of value

Dry run of the TEST-141 parity squad. In a copy of the fixture:

  • every tool and the receptionist got real-looking example.com servers;
  • one scenario dropped its book_appointment mock;
  • the --print-payload output was then inspected:
Check Result
example.com URLs left in the payload 0 (8 dead URLs: 2 member assistants, 3 personality copies, 3 function tools including the tools:append one)
Receptionist server / serverMessages {"url":"https://vapi-gitops-ci.invalid","timeoutSeconds":1} / []
S1 and S3 mocks all 3 tools from the scenario
S2 mocks lookup_patient, check_availability from the scenario; book_appointment = default error mock
Same squad plus an sms tool on the receptionist exit 2: target.squad.members[0].assistant.model.tools[1]: sms tools can't be mocked; remove it, or set toolMocks: off with a dedicated CI org

TOOL_BEARING_KEYS audit against the API's OpenAPI schema (apps/dashboard/src/api/schema.json in the monorepo), listing every property whose schema references a tool DTO or is named like a tool reference:

  • Listed:
    • tools (all model DTOs and TransferAssistantModel);
    • tools:append (AssistantOverrides);
    • toolIds and toolRefs (all model DTOs);
    • declineTool / declineToolId (RecordingConsentPlanVerbal);
    • skills (OpenAIReasoner);
    • assistantDestinations (SquadMemberDTO).
  • Not listed, handled at their only position:
    • tool / toolId (ToolCallHookAction) and function (FunctionCallHookAction) are hook actions;
    • function also appears on handoff DTOs, as the tool's own definition.
  • functions and forwardingPhoneNumber(s) aren't in the current schema, and stay listed as fail-closed.

Tests: npm test goes from 430 to 450 passing; npm run build is clean.

Testing plan

  • tests/check-mocks.test.ts (20 tests):
    • each class in the table, with every listed failing type asserted by name;
    • query and knowledge bases, same org vs cross-org;
    • every TOOL_BEARING_KEYS entry at an unhandled position, plus functions / toolRefs / assistantDestinations placement;
    • parameters / judge schema properties named tools not tripping the rule;
    • each handoff destination kind, with inline handoff assistants walked;
    • assistant and override servers;
    • each hook action kind, and scenario webhooks;
    • personality tools;
    • knowledge-base fields;
    • entry shapes;
    • toolMocks: off and stripWebhooks: false.
  • tests/check-payload.test.ts expectations were updated for the dead servers the policy now adds, and the parity end-to-end test still passes.
  • Not tested:
    • Live runs: that scenario toolMocks intercept every function and apiRequest call is shown for the parity run only, and the full transcript scan is PR 7.
    • The unverified transferCall and integration mock names. They're rewritten or refused rather than relied on.
    • Hook-fired tools bypassing mocks (assumed, and safe either way).

Stacked on #61.

Refs TEST-141

🤖 Generated with Claude Code

scott-lowe-vapi commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor Author

Merge activity

  • Oct 3, 5:59 AM UTC: A user started a stack merge that includes this pull request via Graphite.
  • Oct 3, 6:09 AM UTC: Graphite rebased this pull request as part of a merge.
  • Oct 3, 6:09 AM UTC: @scott-lowe-vapi merged this pull request with Graphite.

@scott-lowe-vapi
scott-lowe-vapi changed the base branch from feat/check-inline-payload to graphite-base/62 October 3, 2026 06:06
@scott-lowe-vapi
scott-lowe-vapi changed the base branch from graphite-base/62 to main October 3, 2026 06:07
A PR check runs the branch's agents in a real org, so nothing it sends may
reach a real server by default. The payload builder's last pass now:

- classifies every tool at a handled position (model.tools, tools:append
  in overrides, hook do[], same-org knowledge bases in model.toolIds):
  endCall/dtmf/voicemail/output are sent as written; function tools by
  function.name and apiRequest by name are mocked; transferCall becomes
  a mocked dead-server function; handoffs pass only to squad members or
  inline assistants; everything else (sms, sipRequest, code, mcp,
  integrations, unknown types) fails the build naming the tool;
- fails any tool-bearing key (TOOL_BEARING_KEYS) outside those positions,
  so a new API field can't slip through unclassified;
- replaces servers with https://vapi-gitops-ci.invalid on every assistant
  and function tool rather than deleting them (a deleted server falls back
  to the phone number's or org's), clears serverMessages, and dead-ends
  scenario webhook hooks;
- adds a default error mock for every mocked tool to each scenario's
  toolMocks, replacing disabled ones, and warns about mocks that match no
  tool;
- fails custom knowledge bases, model.knowledgeBaseId, personality tools
  with side effects, hook transfer actions, and cross-org query or
  knowledge-base tools.

`toolMocks: off` skips the tool rules; `stripWebhooks: false` keeps
assistant servers while tool servers are still replaced.

Refs TEST-141

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@scott-lowe-vapi
scott-lowe-vapi force-pushed the feat/check-fail-closed-mocks branch from 0cbcd8e to 5f30f78 Compare October 3, 2026 06:08
@scott-lowe-vapi
scott-lowe-vapi merged commit 1a7b576 into main Oct 3, 2026
2 checks passed
scott-lowe-vapi added a commit that referenced this pull request Oct 3, 2026
## Value

**V.A.L.U.E. tier:** project — PR 7 of 10 for inline simulation PR checks ([TEST-141](https://linear.app/vapi/issue/TEST-141/gitops-run-simulation-suites-against-pr-changes-inline-as-ci-checks)); this is the first PR that sends anything.

- **Problem:** the payload builder (PRs 5–6) produces a safe inline body, but nothing runs it or reports the result where a reviewer looks. PAL-608 asks for a stable `Vapi Evals` commit status whose **Details** link opens the exact run.
- **Who it affects:** gitops users, who can now run `npm run check -- core` locally against their branch's files and get a strict verdict with a run link. The PR workflow (PR 8) and the promotion gate (PR 10) call the same command.
- **What changes:**
  - **`src/check-run.ts`:** one run per target, at most 3 at once.
    - It reuses `npm run sim`'s create/poll/cancel/hydrate/verdict loop, now extracted from `runSimulation` as `simRunExecute`, so a check is judged by the same strict rules (all-skipped judges, canceled or missing items are never a pass).
    - Each run's deadline is the earlier of `timeoutMinutes` and `--budget-minutes`, no run starts with under 5 minutes left, and the deadline, SIGINT and SIGTERM cancel in-flight runs.
    - A 402 is reported as billing.
    - A 5xx on create is never retried, because a duplicate paid run is worse than a retry.
    - Transcripts are scanned for default-mock answers ("unmocked tool called").
  - **`src/check-select.ts`:** `--changed-since <ref>` diffs from the merge base. A check is affected by:
    - its org's and run org's `resources/**` and state files;
    - `vapi-checks.yml` or `promotion.yml`;
    - `src/**` or `package*.json`;
    - its own `paths`.
  - **`src/check-status.ts`:** GitHub commit statuses with plain `fetch`, when `GITHUB_TOKEN`, `GITHUB_REPOSITORY` and `HEAD_SHA` are set.
    - `Vapi Evals / <check> / <target>` goes pending with the run's canonical `url` as soon as the run exists, then to success, failure or error.
    - `--all` runs post the aggregate `Vapi Evals` from a `finally`: the worst state, `success` if nothing is affected, `error` for a dry run of an affected PR, and `error` when config fails to parse or an exception escapes.
    - A rejected post (a fork's read-only token) only warns.
  - **`src/check-report.ts`:** a markdown summary, also appended to `$GITHUB_STEP_SUMMARY`. It has one row per target with the run link, then failing evaluations with expected vs extracted values, mock notices and warnings. `--json` writes the same data. No PR comments.
  - **`src/check-cmd.ts`** gains live runs and `--changed-since`, `--budget-minutes`, `--refresh-bindings` (the read-only bindings pull promotion uses) and `--json`.
    - **Keys:** `VAPI_CHECK_TOKENS`, then `.env.<runOrg>`, then `VAPI_PRIVATE_API_KEY` when every selected check runs in one org.
    - **Exit codes:** 0 passed, 1 failed, 2 config or build error, 3 incomplete.
  - Runs send `User-Agent: vapi-gitops-check/<version>`, for the post-deploy PostHog measure.
  - The README and AGENTS.md `npm run check` rows now describe live mode.

## Evidence of value

**The definition-of-done pair, run live** in the owner's test org on the TEST-141 parity squad (2 members, function tools, a handoff, 3 scenarios; built from `tests/fixtures/check-parity/`):

| Run | Command | Exit | Result |
|---|---|---|---|
| Innocuous (fixture as-is) | `npm run check -- core` | **0** | ✅ 3/3 passed — [run f68ba89b](https://dashboard.vapi.ai/simulations/run/f68ba89b-9680-470e-be71-2286b32a4e9c) |
| Degraded scheduler prompt ("always say there are no openings, never book") | `npm run check -- core` | **1** | ❌ 1/3 — [run 19711623](https://dashboard.vapi.ai/simulations/run/19711623-d26a-4570-82cd-e963a03bc1e2) |

The degraded run's report named each failing judge with expected vs extracted:

| Simulation | Evaluation | Comparator | Expected | Got |
|---|---|---|---|---|
| S3 alternative slot | offered-alternative | = | true | false |
| S3 alternative slot | booked-wednesday | = | true | false |
| S1 book cleaning | booking-confirmed | = | true | false |
| S1 book cleaning | only-real-slots | = | true | false |

S2 (the hours question, which never reaches the scheduler) correctly still passed.

**Nothing was created in the org.** Counts of assistants, tools, squads, structured outputs, personalities, scenarios, simulations, suites and credentials were identical before and after both runs: `{"/assistant":12,"/tool":8,"/squad":2,"/structured-output":8,"/eval/simulation/personality":10,"/eval/simulation/scenario":10,"/eval/simulation":10,"/eval/simulation/suite":1,"/credential":0}`. This stands in for `npm run cleanup`, which needs a gitops-managed org.

**Every tool call returned a mock.** Every `tool_call_result` in both runs' transcripts was matched to its call by `toolCallId`:

| Run | Function results equal to the scenario's mock | Built-in results (handoff, endCall) | Anything else |
|---|---|---|---|
| Innocuous | 6 | 4 | **0** |
| Degraded | 2 | 2 | **0** |

The run items also confirmed that personalities kept the dead server and `serverMessages: []`.

**A bug the live run caught, fixed in this PR:**
- **The trap:** run items echo the scenario under `metadata.scenario`, default error mocks included. Scanning the whole item would have reported "unmocked tool called" for every default mock, called or not.
- **The fix:** the scan now reads only `tool_call_result` messages in `metadata.call.messages`, mapped to tool names through `tool_calls`.
- **Checked on both runs' real items:** no notices; with one result swapped for a default-mock answer, it reported `S1 book cleaning: unmocked tool called: lookup_patient`.
- A regression test covers the echoed-defaults case.

**Tests:** `npm test` goes from 450 to 474 passing; `npm run build` is clean.

## Testing plan

- **`tests/check-run.test.ts`** (10 tests, stateful local stub):
  - pass with link and User-Agent;
  - two targets with one failing, naming the evaluation;
  - all-skipped is incomplete;
  - timeout cancels;
  - abort cancels in-flight runs and starts none;
  - the budget gate;
  - 402 as billing, and no retry on a create 502;
  - a build error sends nothing;
  - mock notices (echoed defaults ignored);
  - exactly 3 in flight with results in job order.
- **`tests/check-cmd.test.ts`** (11 tests): the live `--all` path against one stub serving both the simulations API and GitHub:
  - pending, then success per target, then the aggregate with the run link, plus the job summary and JSON;
  - a failing run exits 1 with a red aggregate;
  - a named check never posts the aggregate;
  - `--changed-since` with an unaffected change runs nothing and posts green;
  - a dry run of an affected PR posts `error` and sends nothing;
  - invalid config posts `error`.

  The file clears `VAPI_*` and `GITHUB_*` first, so a developer shell with a key exported can't reach a real org.
- **`tests/check-select.test.ts`:** the affected-path table, and the merge-base case in a temp git repo (commits on the base after branching don't count).
- **`tests/check-status.test.ts`, `tests/check-report.test.ts`:** env parsing, POST shape and 140-character truncation, 403 only warning, state ordering, the exact markdown, and the JSON.
- **Not tested:**
  - `--refresh-bindings` against a live org (it runs the same child pull promotion does);
  - EU base URLs;
  - canceling a `running` run live (cancel was verified only on a queued run in the parity experiment);
  - GitHub's real statuses API (stubbed here; PR 8's dogfood repo covers it);
  - a real customer-shaped squad, which needs the owner's permission first.

Stacked on #62.

Refs TEST-141

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants