Skip to content

[AI-94] llm: Evaluate SDK update PRs against the upstream commit range - #22697

Open
SaintPatrck wants to merge 8 commits into
mainfrom
ai/sdk-update-evaluation
Open

[AI-94] llm: Evaluate SDK update PRs against the upstream commit range#22697
SaintPatrck wants to merge 8 commits into
mainfrom
ai/sdk-update-evaluation

Conversation

@SaintPatrck

@SaintPatrck SaintPatrck commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

🎟️ Tracking

AI-94

📔 Objective

We're adding a skill that evaluates an "Update sdk-internal to" PR against the upstream sdk-internal commit range for compile-time and runtime breaking changes, plus the CI workflow that runs it automatically on the bump bot's PRs. Neither repo commits the SDK's generated TypeScript surface, so the skill ships a sdk-surface-diff.mjs script that reconstructs it from two published tarballs and diffs it at member granularity. The workflow scopes the agent's tool and skill grants tightly, since sdk-internal commit content reaches it directly as untrusted context.

Process

flowchart TD
    A[Bump bot opens/updates SDK version bump PR] --> B[Gate: branch + author check]
    B --> C[Fetch secrets from Key Vault]
    C --> D[Mint GitHub App token]
    D --> E[Checkout PR branch]
    E --> F[Clone sdk-internal sibling repo]
    F --> G[Install dependencies]
    G --> H[Run baseline type check]
    H --> I[Primary agent: evaluate via skill's Identify steps]
    I --> J{Anything to resolve?}
    J -- Yes --> M[Dispatch bitwarden-software-engineer subagent: Resolve all findings]
    M --> K[Post sticky PR comment with report]
    J -- No --> K
    K --> L[Push fix commit, if one exists]
Loading

🧪 Testing

How to test locally
  1. Check out a real "Update sdk-internal to" bump PR, e.g. Update sdk-internal to 0.2.0-main.978 #22518 (0.2.0-main.9710.2.0-main.975).
  2. In Claude Code, run: Evaluate PR #22518 using the evaluating-sdk-internal-updates skill.
  3. Confirm the report follows the five headings from the skill's step 8 — ## SDK bump evaluated, ## Compile-time breaks, ## Runtime considerations, ## Everything else in range — confirmed safe, ## Commit — and that any compile-time break found gets fixed, committed, and verified with npm run test:types.
  4. To exercise just the surface-diff script directly: node .claude/skills/evaluating-sdk-internal-updates/sdk-surface-diff.mjs 0.2.0-main.971 0.2.0-main.975, then again with --commercial.
Example output (PR #22518)

SDK bump evaluated

@bitwarden/sdk-internal and @bitwarden/commercial-sdk-internal 0.2.0-main.971 -> 0.2.0-main.975. Both pins moved together, so lint:sdk-internal-versions is satisfied.

The sdk-internal range is b15ab94..0dfa89e, five commits: cc7daf103 community PR workflows (bitwarden/sdk-internal#1386), 8283c9abe CXF import crash on negative timestamps (bitwarden/sdk-internal#1362), fd74c9f56 removal of make_key_pair and verify_asymmetric_keys from CryptoClient (bitwarden/sdk-internal#1390), 485773cb1 aes256-cbc-hmac-sha256-aead as a COSE compat layer for type 2 symmetric keys (bitwarden/sdk-internal#1376), and 0dfa89eae biometric unlock over IPC with concurrent status requests (bitwarden/sdk-internal#1392).

The published-surface diff did not run here: it needs typescript, and our install is blocked by JFrog curation on ejs@5.0.1 (CVE-2023-29827, no fixed version), with Artifactory not yet mirroring 0.2.0-main.975. Surface detection below therefore rests on reading the Rust range per hunk plus call-site greps, so the additive side is less certain than the removals, which are explicit in the diff. The build and test suite did not run for the same reason, so nothing here is test-verified.

Compile-time breaks

None, and the type check passed: the CI baseline for this PR reports zero error TS, every tsc --noEmit leg exited 0, and tsc-strict passed all 4008 strict files.

fd74c9f56 does remove five items from the wasm surface: the CryptoClient.make_key_pair and CryptoClient.verify_asymmetric_keys methods, plus the MakeKeyPairResponse, VerifyAsymmetricKeysRequest and VerifyAsymmetricKeysResponse interfaces, all three of which carried derive(Tsify) with into_wasm_abi/from_wasm_abi. All five have zero call sites in this repo. The eighty-odd makeKeyPair hits are a false lead: every one is legacyCompatKeyService.makeKeyPair, declared in libs/legacy-crypto/src/abstractions/legacy-compat-key.service.ts and implemented in libs/legacy-crypto/src/services/legacy-compat-key.service.ts in pure TypeScript over rsaGenerateKeyPair and wrapDecapsulationKey, so it never reached the removed SDK method.

No Tsify enum variants changed in the range. 485773cb1 rewrote 589 lines of pin_lock_system.rs, which owns the Tsify-exposed PinLockType and PinUnlockStatus, but every variant reference in that diff sits inside moved or renamed test code and the definitions are untouched. There is no wire-value renumbering anywhere in range.

Runtime considerations

CryptoClient.get_key_id_for_symmetric_key now returns a key ID for v1 user keys where it previously returned nothing. In 485773cb1, SymmetricCryptoKey::key_id() changed the Aes256CbcHmacKey arm from None to Some(key.key_id()), deriving the ID as the key's RFC 9679 thumbprint truncated to key ID length. The exported signature is unchanged, which is why nothing failed to compile, and a 64-byte legacy key deserializes to Aes256CbcHmacKey, so this covers the v1 user key format rather than an edge case.

Both call sites branch on presence. biometric-persistent-encryption-migration.ts compares the current user key's ID against the stored enrolledKeyId and treats any mismatch as needsMigration; a v1 user previously stored null and computed null, so the comparison was stable, and now computes a thumbprint against a stored null. Every v1 user with biometric unlock enabled and a persistent key is flagged as needing migration on first run after this bump, and runMigrations re-enrolls through enrollPersistent and setBiometricProtectedUnlockKeyForUser. renderer-biometrics.service.ts then writes the non-null ID, so state converges and later runs are no-ops. The blast radius is a one-time forced re-enrollment rather than lost access or corrupted data: it rewrites a key the client already holds, and no decrypted value crosses a new boundary. What it does cost is an OS keychain write and whatever prompt that carries on Touch ID or Windows Hello, once, for a large share of desktop users at unlock.

This is arguably the migration working as documented, since its own class comment lists a key ID appearing as a trigger. Whether a fleet-wide one-time re-enroll is an acceptable rollout cost is a product call, so nothing was changed here; the alternative is backfilling enrolledKeyId for v1 keys to suppress it. Worth noting that CI cannot catch this either way, because biometric-persistent-encryption-migration.spec.ts and renderer-biometrics.service.spec.ts both stub this call through a factory-form jest.mock("@bitwarden/sdk-internal") and are never checked against the real module. The migration spec already holds both halves of the transition as separate green tests: the mismatch case is now the real-world state, and the case where neither side has a key ID no longer occurs for v1 keys.

The COSE compat layer does not change any format we write. 485773cb1 adds private-use COSE algorithm -70011 and makes as_cose_key_view() return Some for AES-CBC-HMAC keys, which on its face risks writing something a V-2 client cannot read. The same commit adds an explicit guard in DataEnvelope::unseal rejecting Aes256CbcKey and Aes256CbcHmacKey with UnsupportedContentFormat before it consults the view, and EncString still emits Aes256Cbc_HmacSha256_B64 for these keys. The Aes256CbcHmacKey field restructure from { enc_key, mac_key } to a single composite { key } preserves the enc_key || mac_key byte layout at offsets 0 and 32.

CXF import behavior changed benignly. 8283c9abe clamps negative creationAt and modifiedAt values to null in parse_cxf before deserialization so Utc::now() is used downstream, fixing Google Password Manager exports that emit the Windows FILETIME epoch. Imports that previously failed outright now succeed with current timestamps on the affected items, and payloads needing no change are returned borrowed and untouched. No signature moved and there is no call site to adjust.

Everything else in range — confirmed safe

cc7daf103 touches only .github/workflows/ and no crate code.

0dfa89eae changes ipc_client.rs and ipc_client_ext.rs internals only, making the RPC receive loop skip a TypedReceiveError::Typing instead of failing, because every RPC response shares one payload type name and therefore one topic, so a subscription also sees responses belonging to other in-flight requests. No exported signature moved, so our desktop biometrics mocks cannot have drifted against it, and the effect is strictly better reliability under concurrent biometric status requests.

The remainder of 485773cb1 is Rust-internal. Its pin_lock_system.rs work is private, with the new classify_pin_envelope unexported and the only derive changes adding PartialEq, Eq, which have no Tsify output. SymmetricKeyAlgorithm, Aes256CbcHmacKey, SymmetricCryptoKey and CoseContentEncryptionAlgorithm carry no Tsify or wasm_bindgen derive, so the large bitwarden-crypto diff has no TypeScript shape at all. dangerous_get_v2_rotated_account_keys still exists in key_rotation.rs; fd74c9f56 only trimmed it from a use list.

The call sites our type check does not reach were reviewed and none intersect what the range touched. The eight @ts-strict-ignore SDK consumers under apps/ and bitwarden_license/ reference none of the removed or changed symbols, and no app or bitwarden_license spec file imports them either.

Commit

No commit, and nothing was edited. There are no compile-time breaks to fix, and the one runtime finding needs a decision on biometric re-enrollment rather than a mechanical change. package.json is untouched, since the pin is the bump's output and not the fix's.

The bump PR's whole diff is two pinned versions plus the lock file, so a break
introduced upstream is found by whichever CI leg happens to catch it, or after
merge. Android and iOS already evaluate their bump PR against the sdk-internal
commit range behind it.

Nothing generated is committed in either repository, so the API record is
reconstructed from the published tarballs rather than diffed from git. That also
makes a serde rename or an enum wire value renumbering a runtime break with no
signal at any call site, which is why the evaluation covers the whole range
rather than stopping at the first compile error.
@github-actions

github-actions Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Claude Code validation

Result: Pass

Validated the two files this pull request adds under .claude/skills/evaluating-sdk-internal-updates/SKILL.md and the bundled sdk-surface-diff.mjs — against merge base 91a576b6. Both are new files (A), so their full contents were in scope. No secrets, no prompt-injection content, no permission or tool grant that weakens security, and every repository path and npm script the skill references was confirmed to exist. Findings below are quality and robustness items; none block.

Read from the .claude-pr/ snapshot, since claude-code-action replaces repository-root .claude/ with base-branch content. Paths below are given in their original repo-relative form; line numbers are identical in both copies.

Critical

None.

Major

  • .claude/skills/evaluating-sdk-internal-updates/SKILL.md:24,55 — The mandated commit path Skill(bitwarden-delivery-tools:committing-changes) does not resolve outside the CI workflow. .claude/settings.json registers the bitwarden-marketplace under extraKnownMarketplaces but declares no enabledPlugins, so the plugin is installed only by .github/workflows/sdlc-sdk-update-evaluate.yml. Step 9 states the commit path unconditionally, with no fallback, while the description invites local use ("Use when reviewing an SDK bump PR"). A developer running this skill locally hits an unresolvable reference at the final step. Fix: either add the plugin to step 2's prerequisite check alongside the sibling clone, or soften step 9 to "commit with Skill(bitwarden-delivery-tools:committing-changes) if available, otherwise a conventional commit message."

Minor

  • .claude/skills/evaluating-sdk-internal-updates/SKILL.md:44 and sdk-surface-diff.mjs:43,160-162 — Step 4 promises the script "prints RANGE (including the sdk-internal SHA pair step 5 needs)", but the script prints it only if (before.sha && after.sha); fetchSurface returns sha: null whenever package/VERSION is absent, which the script's own comment at line 71 documents as a real case for commercial builds. Steps 5-8 — the whole commit-range analysis — then depend on a SHA pair with no stated recovery. Compounding it, the cache guard at line 43 keys solely on the .d.ts: if the .d.ts extracts but VERSION does not, every later run short-circuits on the cached directory and permanently reports no SHA for that version short of clearing $RUNNER_TEMP/sdk-surface. Fix: have step 4 or 5 say what to do when the SHA line is absent (fall back to the public-package run, or read VERSION from the installed node_modules copy), and key the cache on a sentinel covering both extractions.

  • .claude/skills/evaluating-sdk-internal-updates/SKILL.md:42 — Step 2 tells the agent to locate ../sdk-internal and stop if it is missing, but allowed-tools grants no way to look. There is no ls, no test -d, and no git -C * rev-parse *; the only granted probe of that directory is git -C * log * from step 5, so a missing clone surfaces as a command failure two steps after the gate meant to catch it. Fix: add Bash(git -C * rev-parse *) or an equivalent existence check to the grant list.

  • .claude/skills/evaluating-sdk-internal-updates/SKILL.md:35-39 — Roughly 450 words of reference material sit inline ahead of step 1: Serde attribute semantics, Tsify versus #[uniffi::export], wire-value enum numbering, skipLibCheck, the commercial alias re-export. It is accurate and useful, but it is reference rather than procedure and loads in full on every trigger. Fix: move it to references/sdk-surface-facts.md with a one-line pointer, roughly halving the always-loaded body while leaving steps 1-10 intact. At 1,070 words total the skill is otherwise comfortably lean.

  • .claude/skills/evaluating-sdk-internal-updates/SKILL.md:15,16,22 — Three grants no step exercises: Write (every fix step 9 describes is an in-place change to an existing call site, which Edit covers) and Bash(git add:*) / Bash(git commit:*) (step 9 routes committing exclusively through Skill(bitwarden-delivery-tools:committing-changes)). The two git grants are plausibly the undocumented fallback for the major finding above. Fix: drop Write, and either document the manual-commit fallback or drop the git grants with it. Assessed as not a security weakening for verdict purposes: Edit is already granted unrestricted and is justified by the skill's core task, so Write adds negligible reach beyond it, and the CI workflow that drives this skill layers explicit Write(.claude/**), Write(.github/**) and Write(**/.git/**) denials on top. Flagged here so a human can weigh that differently.

  • .claude/skills/evaluating-sdk-internal-updates/SKILL.md:5,8,9,12,13 — Five allowed-tools entries use a space-plus-* form (Bash(node .claude/skills/evaluating-sdk-internal-updates/sdk-surface-diff.mjs *), Bash(npm test -- *), Bash(npx prettier *), Bash(git -C * log *), Bash(git -C * show *)) rather than the prefix:* form the same list uses elsewhere (gh pr diff:*, git grep:*, git add:*). git -C * log * places a wildcard mid-pattern, which prefix matching cannot express. This is reported as a consistency note, not a confirmed defect — the identical forms appear in .github/workflows/sdlc-sdk-update-evaluate.yml:177's --allowedTools list, so the form is deliberate and matches the workflow it was authored against, and the permission matcher's handling of mid-pattern * was not empirically confirmed here. Fix: confirm each grant matches its step's literal command, and normalize to the :* form where it can express the same thing.

  • .claude/skills/evaluating-sdk-internal-updates/sdk-surface-diff.mjs:124declarations.set(name, header(node, source)) overwrites unconditionally. If the generated .d.ts ever emits both export class Foo and export interface Foo — a shape wasm-bindgen plus Tsify can produce — the second write wins and a change to the first owner's header becomes invisible to the diff. Members are unaffected, since they land under distinct Foo.member keys, and the empty-extraction guard at line 142 would not catch it. Low likelihood against the current generated form. Fix: warn or fail on a duplicate owner key rather than overwriting.

  • .claude/skills/evaluating-sdk-internal-updates/sdk-surface-diff.mjs:31 — The cache path is fully predictable and, outside CI, shared: join(process.env.RUNNER_TEMP ?? tmpdir(), "sdk-surface", pkg.replace(/\W/g, "-")), with mkdirSync(dir, { recursive: true }) succeeding on a pre-existing directory and existsSync(dts) at line 43 skipping the fetch entirely. On a multi-user machine another local user can pre-create /tmp/sdk-surface/-bitwarden-sdk-internal/<version>/package/bitwarden_wasm_internal.d.ts and the script will read it as the published surface, feeding attacker-chosen content into the breaking-change analysis and any fixes it drives. CI is unaffected, since RUNNER_TEMP is per-job. Fix: create the cache root with mode 0o700, or include a per-user component in the path.

  • .claude/skills/evaluating-sdk-internal-updates/SKILL.md:3 — The description's positive triggers are concrete and well chosen, and it states the sibling-clone prerequisite, but it carries no negative trigger. Fix: add a short "Do not use for general SDK questions or for non-version changes to SDK call sites" to sharpen the boundary against writing-client-code, which this skill hands off to.

What was verified clean

  • No prompt injection (CWE-1427). Every imperative in SKILL.md addresses the agent executing the skill, not a reviewer. Nothing claims authority over this review or attempts to redirect it.
  • No secrets. Credential-pattern scan over both files returned nothing.
  • No shell-execution surface in the script. execFileSync throughout, so no shell and no injection path. Both version arguments are validated against ^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$ before being interpolated into an npm pack spec and a cache path component, and the comment at lines 20-22 correctly names both threats that closes: a dist-tag/file:/git spec running a fetched package's prepare script, and .. escaping the cache directory. Both tar calls and the npm pack call have explicit failure handling.
  • Every reference resolves. libs/common/src/platform/spec/mock-sdk.service.ts, libs/common/spec/jest-sdk-client-factory.ts, bitwarden_license/bit-common/src/platform/sdk/sdk-alias.d.ts, tsconfig.base.json, the bundled script, Skill(writing-client-code), and the npm scripts lint:sdk-internal-versions, test:types, lint:fix, test and prettier all exist. The non-obvious technical claims — skipLibCheck: true, the root tsconfig.json spec exclusions, the absence of a dedicated apps//bitwarden_license/ tsc leg, the ESLint ban on direct commercial imports — check out against the working tree.
  • The empty-extraction guard at lines 141-147 correctly converts extractor drift from a silent "nothing changed" false negative into a hard failure.

Checks run

Check Status
Plugin structure Skipped — no changed plugins, and the repo has no .claude-plugin/marketplace.json; run as a dedicated workflow step, not here
Marketplace Skipped — no changed plugins and no .claude-plugin/ change; run as a dedicated workflow step, not here
Version bump Skipped — no component plugins; run as a dedicated workflow step, not here
Plugin validation (AI) Skipped — changed-plugins bucket is empty (no plugins/ directory in this repo)
Skill review (AI) Passed with findings — plugin-dev:skill-reviewer over 1 changed SKILL.md
Configuration & security Passed with findings — reviewing-claude-config over sdk-surface-diff.mjs (skill support file under .claude/)

@codecov

codecov Bot commented Aug 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 54.84%. Comparing base (91a576b) to head (8f4a4c1).
⚠️ Report is 82 commits behind head on main.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #22697      +/-   ##
==========================================
+ Coverage   54.15%   54.84%   +0.68%     
==========================================
  Files        4312     4340      +28     
  Lines      137026   138276    +1250     
  Branches    21686    21903     +217     
==========================================
+ Hits        74208    75837    +1629     
+ Misses      57399    56932     -467     
- Partials     5419     5507      +88     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@SaintPatrck

Copy link
Copy Markdown
Contributor Author

.claude/skills/evaluating-sdk-internal-updates/SKILL.md:55 — The skill commits before it verifies.

Committing changes skill requires preflight to complete. Described behavior is speculative. Deferring until undesired behavior is actually observed.

…s are needed

The primary session now runs the skill's Identify steps itself, keeping
full tool access (including the sticky-comment MCP tool) for the audit
trail this workflow exists to produce, and only hands off to the agent
persona for Resolve work when there's something to fix. Also grants
Skill(evaluating-sdk-internal-updates) explicitly, since the primary
session's scoped Skill allowlist didn't already cover it.
This repo's evaluating-sdk-internal-updates skill splits Identify/Resolve
at 1-8/9-10, not android's 1-7/8-10 that the prompt had carried over.
Also have the subagent invoke the skill itself for the Resolve steps'
guardrails, and report back the commit SHA and each finding's
disposition, so the sticky comment has something to draw from.
@SaintPatrck
SaintPatrck marked this pull request as ready for review August 26, 2026 20:48
@SaintPatrck
SaintPatrck requested review from a team as code owners August 26, 2026 20:48
@github-actions

github-actions Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

🤖 Bitwarden Claude Code Review

Overall Assessment: APPROVE

Reviewed the new evaluating-sdk-internal-updates skill and its sdk-surface-diff.mjs helper, the sdlc-sdk-update-evaluate.yml workflow, the CODEOWNERS entry, and the .claude/ ESLint ignore. The workflow's exposure was checked closely since it runs an agent over untrusted sdk-internal commit content with contents: write: the fork/branch/author gate, persist-credentials: false, the deny list for .github/**, .claude/** and **/.git/**, and the pre-push path guard all line up, and the bot-email identity keeps sdk-update.yml's manual-changes guard satisfied. The surface-diff script validates both version arguments against a SemVer pattern before they reach npm pack, and shells out through execFileSync rather than a shell string. One question on the tool grant list; no blocking findings.

Not covered: Skill review did not run — this review path cannot launch plugin-dev:skill-reviewer, so .claude/skills/evaluating-sdk-internal-updates/SKILL.md was not checked for description quality, length, or progressive disclosure. performing-multi-agent-code-review covers them where plugin-dev is installed.

Code Review Details
  • ❓ : --allowedTools grants Agent, but the subagent-dispatch tool is named Task
    • .github/workflows/sdlc-sdk-update-evaluate.yml:177

Once the subagent has reported back — or immediately, if there was nothing to resolve — update the sticky PR comment yourself, replacing it with the step 8 report, structured as: "## SDK bump evaluated" (old → new version and sdk-internal SHA, commit count), "## Compile-time breaks" (found-and-fixed, or "none found"), "## Runtime considerations" (behavioral, serialization or wire-value notes worth flagging; omit the section if there are none), "## Everything else in range — confirmed safe" (brief, one line per commit or group), and "## Commit" (the SHA, or "no commit — nothing needed fixing"). State whether the verifying type check passed. Always post this, even when the conclusion is that nothing needs fixing — the comment is the audit trail that the check ran.
claude_args: |
--model opus
--allowedTools "Agent,Bash(node .claude/skills/evaluating-sdk-internal-updates/sdk-surface-diff.mjs *),Bash(npm run test:types),Bash(npm run lint:fix),Bash(npm test -- *),Bash(npx prettier *),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(git -C * log *),Bash(git -C * show *),Bash(git -C * diff *),Bash(git add:*),Bash(git commit:*),Bash(git log:*),Bash(git show:*),Bash(git diff:*),Bash(git status:*),Bash(git grep:*),Bash(grep:*),Read,Grep,Glob,Edit,Write,Skill(evaluating-sdk-internal-updates),Skill(writing-client-code),Skill(bitwarden-delivery-tools:committing-changes),mcp__github_comment__update_claude_comment"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QUESTION: Is Agent the intended grant here, rather than Task?

Details

The prompt on line 168 tells the primary agent to "dispatch a single subagent on the bitwarden-software-engineer agent", and the tool that does that is named Task in current Claude Code — that is the name used everywhere else in Bitwarden's own configs (bitwarden-code-review's code-review.md and claude-config-validator's validate-ai.md both list Task in allowed-tools). Agent does not appear as a tool name in any of them.

If Task is permission-gated in this non-interactive run, the Resolve half of the workflow — the part commit a1993e91 added — never dispatches and the fix is silently skipped. If it is not gated, the entry is simply dead. Either way Task is the name to grant.

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.

1 participant