Skip to content

feat(vc): implement SSH commit signing (ENG-2002)#435

Open
hieuntg81 wants to merge 53 commits intomainfrom
feat/ENG-2002
Open

feat(vc): implement SSH commit signing (ENG-2002)#435
hieuntg81 wants to merge 53 commits intomainfrom
feat/ENG-2002

Conversation

@hieuntg81
Copy link
Copy Markdown
Collaborator

Summary

  • Problem: ByteRover commits have no cryptographic identity ? anyone can impersonate an author by setting user.name/user.email arbitrarily.
  • Why it matters: Signed commits give teams tamper-evident history and allow platforms (GitHub, GitLab) to display "Verified" badges, which is a baseline requirement for enterprise trust.
  • What changed:
    • New brv signing-key command group (add, list, remove) to manage SSH public keys registered in the Byterover IAM API.
    • brv vc commit gains --sign / --no-sign flags; prompts for passphrase interactively (up to 3 retries) on encrypted keys.
    • brv vc config supports two new keys (user.signingkey, commit.sign) and a --import-git-signing flag that reads local/global git config and imports SSH signing settings automatically.
    • New SSH infrastructure layer: pure-Node SSH key parser (no ssh-keygen subprocess), SSH agent client (Unix socket protocol), sshsig envelope signer, and an in-process signing-key fingerprint cache.
    • IAM HTTP service (HttpSigningKeyService) for signing-key CRUD against /api/v3/users/me/signing-keys.
    • New transport event vc:signing-key and error codes PASSPHRASE_REQUIRED, SIGNING_KEY_NOT_CONFIGURED, SIGNING_KEY_NOT_FOUND, INVALID_CONFIG_VALUE.
  • What did NOT change (scope boundary): Verification of signatures (reading/validating existing signed commits) is out of scope. No changes to push, fetch, or remote logic.

Type of change

  • Bug fix
  • New feature
  • Refactor (no behavior change)
  • Documentation
  • Test
  • Chore (build, dependencies, CI)

Scope (select all touched areas)

  • TUI / REPL
  • Agent / Tools
  • LLM Providers
  • Server / Daemon
  • Shared (constants, types, transport events)
  • CLI Commands (oclif)
  • Hub / Connectors
  • Cloud Sync
  • CI/CD / Infra

Linked issues

  • Closes #ENG-2002
  • Related #

Root cause (bug fixes only, otherwise write N/A)

N/A

Test plan

  • Coverage added:
    • Unit test
    • Integration test
    • Manual verification only
  • Test file(s):
    • test/unit/infra/ssh/signing-key-cache.test.ts
    • test/unit/infra/ssh/ssh-agent-signer.test.ts
    • test/unit/infra/ssh/ssh-key-parser.test.ts
    • test/unit/infra/ssh/sshsig-signer.test.ts
    • test/unit/infra/transport/handlers/vc-handler.test.ts (extended)
  • Key scenario(s) covered:
    • SSH key parsing for ed25519, rsa, and ecdsa key types (with and without passphrase)
    • SSH agent identity listing and signing via Unix socket protocol
    • sshsig envelope construction and base64 armoring
    • Signing-key fingerprint cache hit/miss/expiry
    • vc-handler signing path: sign flag propagation, PASSPHRASE_REQUIRED error surfacing

User-visible changes

  • New commands:
    • brv signing-key add -k ~/.ssh/id_ed25519 [--title "My laptop"] ? registers SSH public key with Byterover.
    • brv signing-key list ? lists registered signing keys with fingerprints.
    • brv signing-key remove <id> ? removes a registered key.
  • brv vc commit -m "msg" --sign ? signs the commit; --no-sign overrides commit.sign=true in config.
  • brv vc config user.signingkey ~/.ssh/id_ed25519 ? sets the signing key path.
  • brv vc config commit.sign true ? enables auto-signing for all commits.
  • brv vc config --import-git-signing ? imports user.signingKey + gpg.format=ssh + commit.gpgSign from local or global git config.
  • On passphrase-protected keys the CLI now prompts interactively instead of failing with a raw error.

Evidence

  • Failing test/log before + passing after
  • Trace/log snippets
  • Screenshot/recording

Attach at least one piece of evidence before merging.

Checklist

  • Tests added or updated and passing (npm test)
  • Lint passes (npm run lint)
  • Type check passes (npm run typecheck)
  • Build succeeds (npm run build)
  • Commits follow Conventional Commits format
  • Documentation updated (if applicable)
  • No breaking changes (or clearly documented above)
  • Branch is up to date with main

Risks and mitigations

  • Risk: Pure-Node SSH key parser may not support all key sub-formats (e.g., PKCS#8-wrapped RSA, newer OpenSSH format revisions).
    • Mitigation: Parser is used only for reading the public-key blob and detecting key type ? it does not perform signing. Actual signing is delegated to the SSH agent (for agent-held keys) or to sshsig-signer.ts which uses Node's native crypto.sign. Unsupported formats surface a clear error rather than silently producing a bad signature.
  • Risk: Passphrase is transmitted from CLI to daemon in the commit request payload over the local Socket.IO transport.
    • Mitigation: Transport is Unix-domain socket (loopback only, no network exposure). Passphrase is never persisted to disk or logged; it is used once then discarded.
  • Risk: SIGNING_KEY transport event handler is a new surface area that calls the IAM API on every signed commit.
    • Mitigation: SigningKeyCache caches fingerprints with a configurable TTL (default 60 s) to avoid per-commit IAM round-trips.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 16, 2026

Claude finished @hieuntg81's task in 2m 46s —— View job


PR Review: feat(vc): implement SSH commit signing (ENG-2002)

Tasks

  • Gather context and understand the request
  • Review SSH infrastructure layer (key parser, agent signer, sshsig)
  • Review transport/handler changes
  • Review CLI commands (signing-key, vc commit, vc config)
  • Review security concerns
  • Review test coverage
  • Post final review summary

Overall Score: 3 / 5

Solid foundation — the SSH agent path, sshsig envelope construction, fingerprint cache, and transport event design are all done well. However, there are several correctness/architecture issues that should be fixed before merge, and at least one critical feature (encrypted OpenSSH key decryption) is silently non-functional despite having a complete UI path for it.


Critical Issues

1. Architecture boundary violation: oclif/ imports from server/

src/oclif/commands/signing-key/add.ts:4 and src/oclif/commands/vc/config.ts:4 both import directly from server/infra/ssh/:

// add.ts:4, config.ts:4
import {parseSSHPrivateKey, resolveHome} from '../../../server/infra/ssh/index.js'

Per the documented architecture (CLAUDE.md): "All commands are daemon-routed: oclif/ and tui/ never import from server/." This boundary is ESLint-enforced. The SSH helpers needed client-side (like reading the public key blob for the IAM add call) should either be moved to shared/, or the command should delegate entirely to the daemon transport and receive back the public key in the response. Fix this →

2. Encrypted OpenSSH key decryption is silently not implemented

vc-handler.ts:558-582 shows a full retry loop for PASSPHRASE_REQUIRED, and vc/commit.ts prompts the user up to 3 times for a passphrase. But in ssh-key-parser.ts:259-263, when parseSSHPrivateKey is called with a passphrase for an encrypted OpenSSH-format key, it immediately throws:

throw new Error(
  'Encrypted OpenSSH private keys are not yet supported. ' +
  'Please use an unencrypted key or load it via ssh-agent.',
)

The user is prompted for a passphrase → sends it → gets the exact same error on every retry. The passphrase prompt loop is a dead end for OpenSSH-format encrypted keys. The PR description says "prompts for passphrase interactively (up to 3 retries) on encrypted keys" — this is misleading. Either: (a) implement bcrypt KDF + AES-256-CTR decryption, or (b) add a clear probe.needsPassphrase && isOpenSSHFormat check that skips the retry loop and tells the user to use ssh-agent instead. Fix this →

3. RSA signing algorithm is wrong in sshsig-signer.ts

sshsig-signer.ts:53:

const rawSignature = sign(null, signedData, key.privateKeyObject)

The code comment on line 51 correctly states "RSA: sign('sha512', data, key) — must specify hash", but the actual call always passes null. Node.js sign(null, ...) uses the key's default digest, which for RSA is SHA-1. This would produce an SHA-1-based RSA signature, which is wrong per the sshsig spec (rsa-sha2-512). Fix this →


Medium Issues

4. Surprising auto-sign default when only signingKey is set

vc-handler.ts:554:

const shouldSign = data.sign ?? config?.commitSign ?? config?.signingKey !== undefined

Due to operator precedence this evaluates as data.sign ?? config?.commitSign ?? (config?.signingKey !== undefined). A user who sets user.signingkey to test or prepare it — without explicitly enabling commit.sign — will find all their commits are silently signed. This contradicts git's behavior where setting user.signingKey alone doesn't auto-enable signing. Consider: config?.signingKey !== undefined should not be a fallback default. Fix this →

5. No authentication guard in SigningKeyHandler

signing-key-handler.ts:58-62:

private async createService(): Promise<ISigningKeyService> {
  const token = await this.tokenStore.load()
  const sessionKey = token?.sessionKey ?? ''  // ← empty string fallback
  const httpClient = new AuthenticatedHttpClient(sessionKey)
  return new HttpSigningKeyService(httpClient, this.iamBaseUrl)
}

When the user isn't logged in, an empty session key is used and the IAM API returns a generic HTTP 401 error. Compare with handleFetch, handlePush which throw NotAuthenticatedError proactively. Add an explicit auth check here. Fix this →

6. Missing test coverage for key new components

Per CLAUDE.md ("80% coverage minimum, critical paths must be covered"), the following have zero tests:

  • signing-key-handler.ts — the new transport event handler
  • http-signing-key-service.ts — IAM CRUD service
  • handleImportGitSigning() in vc-handler.ts — spawns execFile('git', ...), needs mocking

The existing vc-handler.test.ts only tests the signing path (flag propagation, PASSPHRASE_REQUIRED surfacing), which is good, but the IAM layer and import logic are untested.


Minor Issues

7. Deprecated Buffer.slice() in ssh-agent-signer.ts

Lines 62, 115 use .slice() on Buffer objects (deprecated since Node 17 in favor of .subarray()):

const blob = response.slice(offset, offset + blobLen)   // line 62
settle(() => resolve(accumulated.slice(4, 4 + responseLen)))  // line 115

Fix this →

8. Duplicate sshStr helper in ssh-key-parser.ts

The sshStr helper is defined twice — once inside opensshEd25519ToNodeKey (line 172) and again inside parseSSHPrivateKey (line 308). Extract it to module scope. Fix this →

9. TTL mismatch: code says 30 min, PR says 60 s

signing-key-cache.ts:26:

constructor(ttlMs: number = 30 * 60 * 1000) {  // 30 minutes

The PR description says "default 60 s". These should be consistent; 30-minute in-memory caching of a decrypted private key object is also a wider window than expected.

10. Sign indicator doesn't fire for config-based signing

vc/commit.ts:52:

const sigIndicator = sign === true ? ' 🔏' : ''

When sign is undefined but commit.sign=true is in config, the commit will be signed but no indicator is shown. The indicator should check the response or use shouldSign semantics.

11. Redundant key field sent in import-git-signing mode

vc/config.ts:77:

client.requestWithAck<IVcConfigResponse>(VcEvents.CONFIG, {importGitSigning: true, key: 'user.signingkey'})

IVcConfigRequest.key is required: VcConfigKey but is ignored server-side when importGitSigning: true. Consider making key optional in the type or documenting this behavior.


Positive Notes

  • The SSH agent integration path (priority A) is clean and gracefully degrades on agent unavailability.
  • sshsig envelope construction correctly implements the PROTOCOL.sshsig spec — magic bytes, namespace git, SHA-512 hash algorithm, and armoring width are all correct.
  • SigningKeyCache sweep-on-write prevents memory leaks without a background timer.
  • getPublicKeyMetadata correctly tries the .pub sidecar file first (no passphrase needed) — good UX for encrypted keys in the agent path.
  • Transport event design (IVcSigningKeyRequest discriminated union) is clean and extensible.
  • Test coverage for the SSH primitive layer (parser, agent, sshsig, cache) is solid.

Critical:
- Move parseSSHPrivateKey/probeSSHKey/resolveHome to src/shared/ssh/ to fix
  oclif/ → server/ import boundary violation
- Detect encrypted OpenSSH keys in resolveSigningKey and throw non-retryable
  SIGNING_KEY_NOT_SUPPORTED instead of looping PASSPHRASE_REQUIRED prompts
- Fix RSA signing: use sign('sha512',...) and blob key-type 'rsa-sha2-512'
  instead of null/'ssh-rsa' (per PROTOCOL.sshsig spec)

Medium:
- Fix shouldSign default: signingKey alone no longer auto-enables signing
  (matches git behaviour — commit.sign must be explicitly set)
- Add NotAuthenticatedError guard to SigningKeyHandler.createService()
- Add missing tests: signing-key-handler, http-signing-key-service,
  handleImportGitSigning edge cases

Minor:
- Replace deprecated Buffer.slice() with Buffer.subarray() in ssh-agent-signer
- Deduplicate sshStr helper to module scope in ssh-key-parser
- Clarify 30-minute TTL default in SigningKeyCache JSDoc
- Use result.signed from IVcCommitResponse for 🔏 indicator (shows when
  signing is triggered via config, not only via --sign flag)
- Replace IVcConfigRequest with discriminated union — key is no longer
  spuriously required when importGitSigning:true

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 16, 2026

Claude finished @hieuntg81's task in 3m 15s —— View job


PR Re-Review: feat(vc): implement SSH commit signing (ENG-2002)

Tasks

  • Gather context and understand the request
  • Read updated files to verify previous review issues were addressed
  • Check critical fixes (architecture boundary, OpenSSH passphrase, RSA signing)
  • Check medium fixes (auto-sign default, auth guard, test coverage)
  • Check minor fixes
  • Post final review summary

Overall Score: 4.5 / 5

All 3 critical issues, all 3 medium issues, and all 5 minor issues from the prior review have been addressed. The PR is in strong shape. A few small observations remain, noted below.


Previously Critical — All Fixed ✅

1. Architecture boundary violationFixed

src/server/infra/ssh/ssh-key-parser.ts is now a thin re-export shell:

// ssh-key-parser.ts (server/infra/ssh)
export { parseSSHPrivateKey, probeSSHKey, resolveHome, ... }
  from '../../../shared/ssh/key-parser.js'

Both oclif/commands/signing-key/add.ts:4 and oclif/commands/vc/config.ts:4 now import from shared/ssh/index.js. Architecture boundary is clean.

2. OpenSSH encrypted key dead loopFixed

vc-handler.ts:1488-1496 now guards early:

if (probe.needsPassphrase && probe.opensshEncrypted) {
  throw new VcError(
    `Encrypted OpenSSH private keys are not supported for direct signing. ` +
    `Load the key into ssh-agent first: ssh-add ${keyPath}`,
    VcErrorCode.SIGNING_KEY_NOT_SUPPORTED,
  )
}

probeSSHKey in shared/ssh/key-parser.ts:218-225 sets opensshEncrypted: true when cipherName !== 'none'. The passphrase retry loop is no longer a dead end.

3. RSA signing algorithmFixed

sshsig-signer.ts:53-60:

const isRsa = key.keyType === 'ssh-rsa'
const rawSignature = sign(isRsa ? 'sha512' : null, signedData, key.privateKeyObject)
const blobKeyType = isRsa ? 'rsa-sha2-512' : key.keyType

RSA now correctly uses SHA-512 and produces rsa-sha2-512 wrapped signatures per the sshsig spec.


Previously Medium — All Fixed ✅

4. Auto-sign defaultFixed

vc-handler.ts:555:

const shouldSign = data.sign ?? config?.commitSign ?? false

Setting user.signingkey alone no longer silently enables signing — matches git behaviour.

5. Auth guard in SigningKeyHandlerFixed

signing-key-handler.ts:59-60:

const token = await this.tokenStore.load()
if (!token?.isValid()) throw new NotAuthenticatedError()

No longer falls back to an empty session key.

6. Missing test coverageFixed

Two new test files added:

  • test/unit/infra/transport/handlers/signing-key-handler.test.ts — covers auth guard (missing token, expired token) and handler registration
  • test/unit/infra/iam/http-signing-key-service.test.ts — covers addKey, listKeys, removeKey, snake_case↔camelCase mapping, trailing-slash URL normalisation

Previously Minor — All Addressed ✅

# Issue Status
7 Buffer.slice() deprecated Fixed — all occurrences replaced with .subarray()
8 Duplicate sshStr helper Fixed — single module-scope definition in shared/ssh/key-parser.ts
9 TTL mismatch (code 30 min vs PR 60 s) Resolved — JSDoc in signing-key-cache.ts:33-36 explains the 30-min rationale; PR description was wrong
10 Sign indicator missing for config-based signing Fixed — now uses result.signed from server response (vc/commit.ts:52)
11 Redundant key field in import-git-signing mode Fixed — IVcConfigRequest is now a proper discriminated union; {importGitSigning: true} sent without a key field

Remaining Minor Observations

A. Dead code in parseSSHPrivateKey (shared/ssh/key-parser.ts:257-260)

if (cipherName !== 'none') {
  if (!passphrase) {
    throw new Error('Passphrase required for encrypted key')  // ← unreachable via resolveSigningKey
  }
  throw new Error('Encrypted OpenSSH private keys are not yet supported...')
}

Since resolveSigningKey now short-circuits on probe.opensshEncrypted before calling parseSSHPrivateKey, the if (!passphrase) branch here is dead code. The outer throw fires regardless. Safe to simplify to a single unconditional throw. Purely cosmetic. Fix this →

B. SigningKeyHandler action routing lacks injectable service seam

signing-key-handler.test.ts:157-163 acknowledges it cannot stub HttpSigningKeyService because the handler instantiates it internally via createService(). The test covers auth-guard + event registration, and http-signing-key-service.test.ts covers the HTTP layer separately — this is a reasonable split. For future improvement, consider accepting an optional signingKeyService?: ISigningKeyService injection in SigningKeyHandlerDeps (defaulting to the HTTP version) so the action-routing paths (add/list/remove) can be unit tested end-to-end in isolation. Fix this →

C. handleImportGitSigning still has no test coverage

vc-handler.ts:720-779 spawns execFile('git', ['config', '--get', key]). The updated vc-handler.test.ts tests the signing commit path but not the import path. The git subprocess needs mocking to cover: key-not-found, non-ssh gpg.format rejection, and successful import. Fix this →


Positive Notes

All the strengths from the original review still hold:

  • sshsig envelope construction is spec-compliant (magic, namespace, SHA-512 hash, armor width)
  • SSH agent integration gracefully degrades when agent is unavailable
  • SigningKeyCache sweep-on-write pattern prevents timer-based leaks
  • probeSSHKey correctly extracts public key from OpenSSH format without needing a passphrase
  • Transport event discriminated union (IVcConfigRequest) is now clean and type-safe
  • SSH primitive tests (parser, agent, sshsig, cache) remain solid

…(ENG-2002)

- Remove dead `if (!passphrase)` branch in parseSSHPrivateKey — unreachable
  since resolveSigningKey short-circuits on opensshEncrypted before calling it
- Add optional signingKeyService injection seam to SigningKeyHandlerDeps so
  action-routing paths (add/list/remove) can be unit tested without stubbing
  ES module instantiation; auth guard is still enforced when seam is used
- Add 4 action-routing tests to signing-key-handler.test.ts (add/list/remove
  routing + auth guard with injected service)
- Add missing handleImportGitSigning test: gpg.format=gpg → INVALID_CONFIG_VALUE

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 16, 2026

Claude finished @hieuntg81's task in 2m 2s —— View job


PR Re-Review (Round 3): feat(vc): implement SSH commit signing (ENG-2002)

Tasks

  • Gather context and understand the request
  • Check what changed since last review (commit 48ac694)
  • Verify remaining observations A, B, C from prior review
  • Read relevant files for any new issues
  • Post final review summary

Overall Score: 5 / 5

All 3 minor observations from the Round 2 review have been fully addressed. The PR is ready to merge.


Previously Minor — All Fixed ✅

A. Dead code in parseSSHPrivateKey (shared/ssh/key-parser.ts:257-260)Fixed

The unreachable if (!passphrase) branch was removed. parseSSHPrivateKey now unconditionally throws for encrypted OpenSSH keys with a clear comment explaining it is a safety-net for direct callers (since resolveSigningKey short-circuits on opensshEncrypted before this point):

if (cipherName !== 'none') {
  // resolveSigningKey short-circuits on opensshEncrypted before reaching here,
  // so this is a safety net for direct callers.
  throw new Error('Encrypted OpenSSH private keys are not yet supported...')
}

B. SigningKeyHandler injectable service seamFixed

SigningKeyHandlerDeps now has an optional signingKeyService?: ISigningKeyService field. createService() correctly enforces the auth guard first, then returns the injected service if provided:

private async createService(): Promise<ISigningKeyService> {
  const token = await this.tokenStore.load()
  if (!token?.isValid()) throw new NotAuthenticatedError()  // ← guard fires first
  if (this.injectedService) return this.injectedService     // ← seam used only if authed
  ...
}

Four new action-routing tests were added in signing-key-handler.test.ts covering add, list, remove, and confirming the auth guard still fires even when a service is injected.

C. handleImportGitSigning test coverageFixed

Three scenarios now covered in vc-handler.test.ts:

Scenario Assertion
Successful import (strips .pub, sets signingKey + commitSign) savedConfig.signingKey === keyPath, commitSign === true
Key path doesn't exist on disk throws SIGNING_KEY_NOT_FOUND
gpg.format is not ssh (e.g. gpg) throws INVALID_CONFIG_VALUE ← newly added in 48ac694

Also confirmed: SIGNING_KEY_NOT_SUPPORTED for encrypted OpenSSH keys is separately tested at vc-handler.test.ts:768.


Positive Notes

All prior strengths still hold, and the PR is now complete end-to-end:

  • sshsig envelope construction is spec-compliant (PROTOCOL.sshsig magic, namespace, SHA-512, armor width)
  • RSA signing correctly uses sha512 and produces rsa-sha2-512 wrapped signatures
  • SSH agent path gracefully degrades; encrypted OpenSSH keys have a clear, user-actionable error
  • SigningKeyCache sweep-on-write prevents timer-based leaks
  • Architecture boundary (oclifshared, not server) is clean
  • Auth guard fires proactively in SigningKeyHandler
  • shouldSign default is false — setting user.signingkey alone doesn't auto-enable signing
  • Test coverage is solid across the SSH primitive layer, IAM service, transport handlers, and config import

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 16, 2026

Claude finished @hieuntg81's task in 3m 58s —— View job


PR Re-Review (Round 4): feat(vc): implement SSH commit signing (ENG-2002)

Tasks

  • Gather context and understand the request
  • Check what changed since last review (merge from main)
  • Review any new or changed files
  • Post final review summary

Overall Score: 5 / 5

The trigger for this round was a synchronize event from commit d90806b — a merge of main into feat/ENG-2002. The merged content is the "dream" feature (PR #436: proj/dreaming), which is completely orthogonal to SSH commit signing. The merge was clean with no conflicts.


Merge Impact Analysis

The 38 files brought in from main (dream executor, lock/state/log services, brv dream command, task-router enhancements) have zero overlap with the SSH signing feature files. Spot-checked the critical interaction points:

File Touched by SSH signing? Touched by dream merge? Conflict?
feature-handlers.ts ✅ (added SigningKeyHandler) None
transport/handlers/index.ts ✅ (added SigningKeyHandler export) None
shared/transport/events/vc-events.ts ✅ (new events/types) None
server/core/domain/transport/schemas.ts ✅ (added dream task type) None
server/infra/process/task-router.ts ✅ (pre-dispatch gates) None

All fixes from Rounds 1–3 remain intact after the merge:

  • Architecture boundary (oclifshared, not server) ✅
  • shouldSign defaults to false
  • RSA signing uses sha512 / rsa-sha2-512
  • Encrypted OpenSSH keys short-circuit with SIGNING_KEY_NOT_SUPPORTED
  • SigningKeyHandler auth guard fires proactively ✅
  • Injectable service seam for testing ✅
  • handleImportGitSigning test coverage ✅

One Pre-Existing Minor Observation (carried over, not newly introduced)

signing-key add and vc config --import-git-signing fail for encrypted OpenSSH private keys without a .pub sidecar

src/oclif/commands/signing-key/add.ts:44 and src/oclif/commands/vc/config.ts:99:

const parsed = await parseSSHPrivateKey(keyPath)  // ← fails for encrypted keys

When the user passes an encrypted OpenSSH private key (the most common case) and there is no .pub sidecar, parseSSHPrivateKey throws "Encrypted OpenSSH private keys are not yet supported." But the only purpose here is to extract the public key — which does not require decryption. parseOpenSSHKey (used inside getPublicKeyMetadata) already extracts the unencrypted public key blob from the OpenSSH file header. Exporting a getPublicKeyBlob(keyPath) helper or using getPublicKeyMetadata + a separate blob-extraction path would eliminate this unnecessary failure for encrypted keys.

Impact: Low — the workaround is to pass the .pub sidecar file directly (documented in command examples: -k ~/.ssh/id_ed25519.pub). This issue was present before this merge and was not introduced or worsened by it. The commit-signing path is unaffected.


Summary

The merge from main introduces no regressions or conflicts in the SSH signing feature. The PR remains in the same excellent shape as after Round 3. Ready to merge.

…NG-2002)

signing-key add and vc config --import-git-signing previously called
parseSSHPrivateKey to extract the public key, which fails for encrypted
OpenSSH private keys with no .pub sidecar (the most common real-world case).

The OpenSSH file format stores the public key in the unencrypted file
header — decryption is never needed to derive it. Add extractPublicKey()
to shared/ssh/key-parser.ts which:
  1. Reads the .pub sidecar if present (preserves the comment field)
  2. Falls back to parseOpenSSHKey() for OpenSSH-format files (no decryption)
  3. Falls back to parseSSHPrivateKey() for PEM/PKCS8 keys

Update add.ts and config.ts to call extractPublicKey() instead of
parseSSHPrivateKey(). The .pub workaround still works and the sidecar
comment is now used as the default title.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 16, 2026

Claude finished @hieuntg81's task in 2m 2s —— View job


PR Re-Review (Round 5): feat(vc): implement SSH commit signing (ENG-2002)

Tasks

  • Gather context and understand the request
  • Check what changed since last review (commit 65618ac)
  • Verify pre-existing observation from Round 4 (encrypted OpenSSH key without .pub sidecar)
  • Read relevant changed files
  • Post final review summary

Overall Score: 5 / 5

The trigger for this round is commit 65618ac — "fix(vc): extract public key from OpenSSH header without decryption". This directly addresses the one remaining pre-existing observation carried over from Round 4. The PR is ready to merge.


Round 4 Pre-Existing Observation — Fixed ✅

signing-key add and vc config --import-git-signing fail for encrypted OpenSSH private keys without a .pub sidecarFixed

A new extractPublicKey() helper was added to shared/ssh/key-parser.ts with a clean priority chain:

// 1. .pub sidecar (also captures comment field — used as default title)
// 2. OpenSSH native format — public key lives in the unencrypted header,
//    so this works even when the private key is passphrase-protected
// 3. PEM/PKCS8 fallback — requires an unencrypted key (documented in JSDoc)
export async function extractPublicKey(keyPath: string): Promise<{
  comment?: string
  keyType: string
  publicKeyBlob: Buffer
}>

Both signing-key/add.ts:44 and vc/config.ts:100 now call extractPublicKey instead of parseSSHPrivateKey. Users can now run brv signing-key add -k ~/.ssh/id_ed25519 with an encrypted key and no sidecar — the public key is extracted from the file header without needing the passphrase.

Bonus UX improvement: extracted.comment is now used as the default key title (previously defaulted to a generic "My ${keyType} key" even when a comment was available).


Test Coverage ✅

Four new extractPublicKey() test cases in ssh-key-parser.test.ts:

Scenario Assertion
Encrypted OpenSSH key, no .pub sidecar keyType === 'ssh-ed25519', blob has length, comment === undefined
Encrypted key + .pub sidecar present Prefers sidecar; comment === 'user@laptop'; blob matches sidecar content
Unencrypted OpenSSH key, no sidecar keyType === 'ssh-ed25519', blob extracted
Non-existent file Throws

The makeEncryptedOpenSSHKey() fixture and the sshStr helper were correctly extracted to module scope (previously duplicated inline in two probeSSHKey tests) — clean-up included in the same commit.


One Cosmetic Observation (Non-blocking)

Assembled public key string omits the comment for private key inputs

In add.ts:46 and config.ts:102:

publicKey = `${extracted.keyType} ${b64}`   // ← no comment appended

The .pub-file path uses the full raw file content (including comment), while the private-key path omits it. The comment is used for the title field, just not included in the registered key string. This is functionally correct — SSH authorization only requires type blob — and is a trivial inconsistency not worth blocking on. Optional fix →


Summary

All 3 critical issues, all 3 medium issues, all 5 minor issues from Round 1, all 3 minor observations from Round 2, the pre-existing Round 4 observation — all addressed. The SSH commit signing feature is complete, architecturally sound, and well-tested.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 17, 2026

Claude finished @hieuntg81's task in 1m 20s —— View job


PR Re-Review (Round 6): feat(vc): implement SSH commit signing (ENG-2002)

Tasks

  • Gather context and understand the request
  • Check what changed since last review (commit bdd6414 — merge from main)
  • Review new files brought in from main for interaction with SSH signing feature
  • Verify all previous fixes remain intact
  • Post final review summary

Overall Score: 5 / 5

The trigger for this round was a synchronize event from commit bdd6414 — a merge of main into feat/ENG-2002. The merged content is the "query log" feature (PR #375: Proj/query log), which is completely orthogonal to SSH commit signing. The merge was clean with no conflicts.


Merge Impact Analysis

The 35 files brought in from main (query log entity, store, use cases, brv query-log commands, daemon wiring) have zero overlap with the SSH signing feature files. Spot-checked all critical interaction points:

File Touched by SSH signing? Touched by query log merge? Conflict?
feature-handlers.ts ✅ (added SigningKeyHandler) None
transport/handlers/index.ts ✅ (added SigningKeyHandler export) None
shared/transport/events/vc-events.ts ✅ (new signing-key events/types) None
server/core/domain/transport/schemas.ts ✅ (added task:queryResult event + schema) None
server/infra/daemon/brv-server.ts ✅ (added QueryLogHandler to lifecycleHooks) None

All fixes from Rounds 1–5 remain intact after the merge:

  • Architecture boundary (oclifshared/ssh, not server/infra/ssh) ✅
  • shouldSign defaults to false — setting user.signingkey alone does not auto-enable signing ✅
  • RSA signing uses sha512 / rsa-sha2-512 (sshsig-signer.ts:54,60) ✅
  • Encrypted OpenSSH keys short-circuit with SIGNING_KEY_NOT_SUPPORTED before passphrase loop (vc-handler.ts:1490) ✅
  • SigningKeyHandler auth guard fires proactively (signing-key-handler.ts:64) ✅
  • Injectable service seam for testing ✅
  • handleImportGitSigning test coverage ✅
  • extractPublicKey correctly extracts public key from encrypted OpenSSH files without passphrase (oclif/.../add.ts:44, oclif/.../config.ts:100) ✅

Summary

The merge from main introduces no regressions or conflicts in the SSH signing feature. The task:queryResult transport event added by the query log feature is entirely separate from the vc:signing-key event namespace. The PR remains in the same excellent shape as after Round 5. Ready to merge.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 17, 2026

Claude finished @hieuntg81's task in 4m 5s —— View job


PR Re-Review (Round 7): feat(vc): implement SSH commit signing (ENG-2002)

Tasks

  • Gather context and understand the request
  • Check what changed since last review (commit d4821d5)
  • Review new/modified files for correctness, security, and quality
  • Verify all previous fixes remain intact
  • Post final review summary

Overall Score: 4.5 / 5

Commit d4821d5 ("add support for SSH-agent signing, improved key parsing, and secure passphrase handling") brings several welcome improvements — Windows agent support, proper password masking, file permission hardening, and a docs page. However, it introduces one medium-severity UX regression in error handling that should be fixed before merge, plus a few smaller issues.


Medium Issues

1. isPassphraseError "unsupported" match is too broad — causes confusing passphrase prompt for non-ed25519 keys

shared/ssh/key-parser.ts:198:

return /bad decrypt|passphrase|bad password|interrupted or cancelled|unsupported/.test(msg)

Adding |unsupported was intended to catch OpenSSL "unsupported cipher" errors, but it also catches the developer-thrown errors from the new key-type restriction:

// probeSSHKey:221
throw new Error(`Unsupported OpenSSH key type: ${parsed.keyType}. Only ssh-ed25519 is supported natively. Please load this key into ssh-agent instead.`)
// probeSSHKey:236
throw new Error(`Unsupported PEM key type: ${pk.asymmetricKeyType}. Only ed25519 is supported natively. Please load this key into ssh-agent instead.`)

These errors contain "Unsupported" → isPassphraseError returns trueprobeSSHKey returns {exists: true, needsPassphrase: true} → CLI prompts the user for a passphrase → then parseSSHPrivateKey throws "Unsupported key type" anyway.

Resulting UX for an RSA/ECDSA key not loaded in the agent:

  1. brv vc commit --sign is run
  2. Agent check fails (key not in agent)
  3. probeSSHKey throws "Unsupported" → caught → returns needsPassphrase: true
  4. PASSPHRASE_REQUIRED error → CLI prompts "Enter SSH key passphrase:" (wrong prompt!)
  5. User enters passphrase → parseSSHPrivateKey throws "Unsupported key type"
  6. User is confused — they were asked for a passphrase, not told their key type is unsupported

Recommended fix: Remove |unsupported from isPassphraseError. For the new key-type restrictions, add a nativelySupported field to SSHKeyProbe and surface a clear VcError(SIGNING_KEY_NOT_SUPPORTED) from resolveSigningKey — not via the passphrase retry path. Fix this →


Minor Issues

2. --passphrase flag exposes passphrase in process list

src/oclif/commands/vc/commit.ts:19-21:

passphrase: Flags.string({
  description: 'SSH key passphrase (prefer BRV_SSH_PASSPHRASE env var)',
}),

Values passed as CLI flags are visible in ps aux and shell history. The description already says to prefer BRV_SSH_PASSPHRASE, but there's no security warning in the error output or docs. Consider either removing the flag entirely (env var only) or adding a prominent ⚠️ Security: note to the description field. Fix this →

3. Redundant user.signingKey (camelCase) in VcConfigKey type and VC_CONFIG_KEYS array

src/shared/transport/events/vc-events.ts:109-121:

export type VcConfigKey = 'commit.sign' | 'user.email' | 'user.name' | 'user.signingkey' | 'user.signingKey'
export const VC_CONFIG_KEYS: readonly string[] = ['user.name', 'user.email', 'user.signingkey', 'user.signingKey', 'commit.sign']

The server already normalises with data.key.toLowerCase() (vc-handler.ts:606), and isVcConfigKey already does case-insensitive matching. Having both user.signingkey and user.signingKey in the canonical type and array pollutes the API surface unnecessarily. Fix this →

4. Docs page is in Vietnamese only

docs/ssh-commit-signing.md is entirely in Vietnamese with no English version or fallback. If the project targets an international audience, the docs should be in English (or bilingual). Fix this →


Positive Notes

All fixes from Rounds 1–6 remain intact. The new commit also adds several genuine improvements:

  • password() instead of input() — passphrase input is now correctly masked. The old code used input() with a // @ts-expect-error hack.
  • TTY guard before promptingif (!process.stdin.isTTY) correctly errors out with an actionable message in non-interactive environments (CI, scripts).
  • Windows SSH agent named pipe\\.\pipe\openssh-ssh-agent fallback in tryGetSshAgentSigner makes the feature usable on Windows without SSH_AUTH_SOCK.
  • 0o600 on vc-git-config.json — config file now has restricted permissions; previously world-readable on Linux. Good security hardening.
  • cogit-url.ts regex ^ anchorparseUserFacingUrl now anchors the path match, preventing false matches on paths like /prefix/team/repo.git.
  • resolveHome Windows path fix — uses path.join(homedir(), keyPath.slice(2)) instead of raw string replacement, correctly handling ~\path on Windows.
  • Case-insensitive config key lookupFIELD_MAP[data.key.toLowerCase()] accepts both user.signingKey and user.signingkey.
  • Better hint on encrypted key confighandleConfig now checks the agent when setting a signing key path, giving a useful warning if the encrypted key isn't agent-loaded.

Summary

The PR is solid overall. Issue #1 (confusing passphrase prompt for unsupported key types) is the only one worth fixing before merge. Issues #2#4 are minor improvements that can go in this PR or a follow-up.

bao-byterover
bao-byterover previously approved these changes Apr 20, 2026
Copy link
Copy Markdown
Collaborator

@bao-byterover bao-byterover left a comment

Choose a reason for hiding this comment

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

Please resolve, i also add a comment on ticket 2002.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

issue (block):
I think this file should not exits, or if really needed, please make it 100% english.

Comment thread src/oclif/commands/vc/commit.ts Outdated

let pp: string
try {
pp = await password({
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

issue(block):

Oclif command imports @inquirer/prompts and calls password() — violates your 2026-04-17 rule "oclif non-interactive; all input via flags/args." TTY gate at :69 narrows but doesn't remove the interactive path.


// Derive public key fingerprint from key file (reads only public key — no passphrase needed)
const parsed = await getPublicKeyMetadata(keyPath).catch(() => null)
if (!parsed) return null
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

issue (block):
Please let claude code read the CLAUDE.md latest, it strict about return value undefined instead of null.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 20, 2026

Claude finished @hieuntg81's task in 4m 29s —— View job


PR Re-Review (Round 8): feat(vc): implement SSH commit signing (ENG-2002)

Tasks

  • Gather context and understand blocking review comments
  • Fix blocking: translate docs/ssh-commit-signing.md to 100% English (added passphrase-protected key section)
  • Fix blocking: remove @inquirer/prompts interactive prompt from vc/commit.ts — oclif is now fully non-interactive; PASSPHRASE_REQUIRED errors with ssh-add / flag / env instructions
  • Fix blocking: use undefined instead of null in ssh-agent-signer.ts (CLAUDE.md rule); updated test assertions accordingly
  • Fix medium: removed |unsupported from isPassphraseError regex in key-parser.ts
  • Commit 9f25743 created — push requires manual approval (see below)

Changes in commit 9f25743

docs/ssh-commit-signing.md — Full English translation. Added "Passphrase-protected keys" section explaining ssh-add, BRV_SSH_PASSPHRASE, and --passphrase usage.

src/oclif/commands/vc/commit.ts — Removed @inquirer/prompts import and interactive password() retry loop (MAX_PASSPHRASE_RETRIES). The command is now fully non-interactive. Passphrase comes only from --passphrase flag or BRV_SSH_PASSPHRASE env var. On PASSPHRASE_REQUIRED, the command errors with:

SSH key passphrase required. Use --passphrase flag or set BRV_SSH_PASSPHRASE env var.
For passphrase-protected keys, loading the key into ssh-agent is recommended:
  ssh-add ~/.ssh/id_ed25519_signing

src/server/infra/ssh/ssh-agent-signer.ts — All return nullreturn undefined; return type changed from Promise<null | SshAgentSigner> to Promise<SshAgentSigner | undefined> per CLAUDE.md convention.

src/shared/ssh/key-parser.ts — Removed |unsupported from isPassphraseError regex. RSA/ECDSA "Unsupported key type" errors now propagate correctly to SIGNING_KEY_NOT_SUPPORTED instead of triggering a dead-end passphrase prompt.

test/unit/infra/ssh/ssh-agent-signer.test.ts — Updated 3 test descriptions and expect(result).to.be.nullexpect(result).to.be.undefined.


Note: The commit exists locally but the push was blocked by permission settings in this environment. Please approve the push or run git push origin feat/ENG-2002 from the workflow.

@bao-byterover bao-byterover self-requested a review April 20, 2026 10:11
@bao-byterover bao-byterover dismissed their stale review April 20, 2026 10:12

Wrong click

hieuntg81 and others added 9 commits April 20, 2026 17:36
PROTOCOL.sshsig defines MAGIC_PREAMBLE as byte[6] "SSHSIG" for both the
envelope and the signed-data structure. The previous 'SSHSIG\0' (7 bytes,
with null terminator) made every signature reject under `git verify-commit`
and CoGit's verifier.

Add a round-trip test against `ssh-keygen -Y check-novalidate` so this
class of spec-misreading bug cannot recur silently. Also fix two
pre-existing self-asserting tests that re-encoded the wrong preamble.

Fixes ENG-2002 AC4 (and unblocks AC5).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
isPassphraseError() previously matched /unsupported/, which collided with
parseOpenSSHKey's own "Unsupported OpenSSH key type: ..." error string.
probeSSHKey then reported needsPassphrase:true for any non-Ed25519 OpenSSH
key, triggering a spurious passphrase prompt instead of surfacing the real
"unsupported key type" error.

Add regression tests for RSA and ECDSA OpenSSH-format keys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
B7 reported that RSA/ECDSA OpenSSH keys triggered repeated passphrase
prompts (RSA: 2, ECDSA: 3) and theorised an "implicit PASSPHRASE_REQUIRED
conversion path" elsewhere in the transport layer.

Tracing the code shows the only PASSPHRASE_REQUIRED throw is in
resolveSigningKey, gated on probe.needsPassphrase. With B6's regex fix in
place probeSSHKey now throws "Unsupported OpenSSH key type" directly for
non-Ed25519 OpenSSH keys, so PASSPHRASE_REQUIRED is never produced and the
CLI retry loop never starts.

This integration test exercises handleCommit end-to-end with a synthetic
ssh-rsa OpenSSH key and asserts the propagated error is the unsupported
keytype error, NOT PASSPHRASE_REQUIRED.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…2002 B1)

Per Bao's review (2026-04-17), oclif commands run non-interactively and
must not collect user input via inquirer prompts — passphrase entry
belongs in the TUI's Ink layer per ticket §Signing Flow step 2.

Replace the @inquirer/prompts password() + retry-recursion logic with a
clear actionable error directing users to --passphrase or
BRV_SSH_PASSPHRASE. Add two examples covering both invocation styles
(addresses B1-examples).

The Ink-layer passphrase component for the TUI remains an open follow-up
(ticket §Signing Flow step 2) — out of scope for this commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…NG-2002 B2)

Bao's review (2026-04-17) noted the prior doc was Vietnamese-only and
missing several critical pieces. This rewrite addresses all four gaps:

- English (replaces VN-only — accessible to the full team)
- Cross-platform notes for macOS, Linux, Windows native, and WSL
- `--passphrase` flag and `BRV_SSH_PASSPHRASE` env var documented
- Encrypted-OpenSSH and RSA/ECDSA caveats with the
  ssh-add → ssh-agent recovery path

Also adds a supported-key-formats matrix that makes the narrowed v1 scope
explicit (Ed25519-unencrypted-file native; everything else via ssh-agent),
plus a troubleshooting table covering the four user-facing error strings
in the current implementation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…2 C2)

The B6 fix dropped /unsupported/ from the message-fallback regex but left
the code-based check at `code.startsWith('ERR_OSSL')`, which still matches
`ERR_OSSL_UNSUPPORTED`. Node.js crypto emits this code for any PEM body
it cannot decode (malformed PKCS8, unsupported algorithm OIDs, garbage
payload), so probeSSHKey continued to false-report needsPassphrase:true
for any unparseable PEM file — the same B6 class of bug on a different
code path.

Replace the prefix match with an explicit whitelist of the two codes that
genuinely indicate a passphrase issue: ERR_OSSL_BAD_DECRYPT and
ERR_OSSL_CRYPTO_INTERRUPTED_OR_CANCELLED.

Adds a regression test that constructs a minimal malformed PEM whose body
is two characters of base64 garbage — reliably surfaces ERR_OSSL_UNSUPPORTED
across Node versions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…(ENG-2002 C1+M1)

After B0 fix the envelope and signed-data preambles are both 6-byte
"SSHSIG" — having two named constants (SSHSIG_MAGIC and
SSHSIG_SIGNED_DATA_MAGIC) for identical buffers misled future readers
into thinking the spec required different prefixes.

ssh-agent-signer.ts also still carried the pre-B0 inline comment
"Build signed data (uses 7-byte magic WITH null terminator)" which now
contradicted the code outright.

- Export a single SSHSIG_MAGIC from sshsig-signer.ts and import it in
  ssh-agent-signer.ts (single source of truth).
- Drop the stale 7-byte comment, replace with a one-line spec reference.
- Remove the redundant SSHSIG_SIGNED_DATA_MAGIC alias.

No behavior change. All 52 ssh unit tests still pass including the
ssh-keygen round-trip verifier.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three test-quality fixes from review:

M2: vc-handler.test.ts created tempdirs inline (brv-enc-key-test-*,
    brv-rsa-key-test-*) without cleanup, leaking one dir per run. Wrap
    both bodies in try/finally + rmSync.

M3: B7 regression test wrapped the PASSPHRASE_REQUIRED-must-not-fire
    assertion in `if (error instanceof VcError)`. Because probeSSHKey
    now throws a plain Error for unsupported keytypes, that branch was
    skipped — the assertion silently disappeared. Pin behavior with
    `expect(error).to.not.be.instanceOf(VcError)` so any future wrap
    into VcError is caught and reviewed deliberately.

M5: ssh-key-parser.test.ts had beforeEach() that mkdtempSync'd a fresh
    tempdir for every test in three describe blocks but no matching
    afterEach/after. Added cleanup hooks to all three.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- m1: drop `as {code: string}` assertions in commit.ts and key-parser.ts.
  TS narrows `err.code` correctly via `'code' in err && typeof err.code === 'string'`.
- m2: remove ticket reference (ENG-2002 §Signing Flow step 2) from
  commit.ts comment — explanation already conveys WHY.
- m3: rename test payload "B0 round-trip" → "round-trip test payload"
  so it doesn't bake a task ID into test data.
- m4: rephrase docs PEM/PKCS8 jargon as "the narrow exception" with a
  pointer to ssh-agent as the expected path for most users.
- m6: drop `console.warn` from skipped-suite branch in sshsig round-trip
  test; Mocha already prints "pending" when this.skip() fires.

No behavior change. All 52 ssh tests + typecheck + lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rationale (ENG-2002 M6)

Both signers had `const HASH_ALGORITHM = 'sha512'` inlined, and bare
`createHash('sha512')` / `sign('sha512', ...)` calls mirrored it.
Bao flagged this as "sha512 hard-coded, no sha256 fallback".

The hard-coding is intentional, not an oversight:

- Ed25519 (primary supported key type) mandates SHA-512 — the EdDSA
  algorithm has no other choice.
- RSA via ssh-agent already uses the RSA-SHA2-512 flag per OpenSSH
  default.
- Every OpenSSH verifier from 2017+ accepts sha512-labelled sshsig
  signatures; there is no consumer-compat benefit to offering sha256.

Move the constant into sshsig-constants.ts alongside SSHSIG_MAGIC_PREAMBLE
and spell out the three reasons inline, so the next reviewer does not
ask the same "why is this hard-coded" question. Both signers import the
shared constant; all four `'sha512'` string literals become references.

If a future need arises, the doc explicitly tells the next developer to
thread the constant through signCommitPayload and SshAgentSigner.sign
as a parameter rather than duplicating the literal.

No behavior change. 57 SSH tests + ssh-keygen round-trip still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 20, 2026

Claude finished @hieuntg81's task in 5m 53s —— View job


PR Re-Review (Round 10): feat(vc): implement SSH commit signing (ENG-2002)

Tasks

  • Gather context and understand the request
  • Check what changed since Round 9 review (6 new commits)
  • Verify blocking issue B3: nullundefinedfound, fixed locally
  • Fix nullundefined in ssh-agent-signer.ts, key-parser.ts, signing-key-cache.ts
  • Update tests for null → undefined assertions
  • Commit fixes (local commit 0770731)
  • Post final review summary

Note on push: commit 0770731 exists locally but git push returned 403 — please run git push origin feat/ENG-2002 from the workflow, or approve the push in permissions.


Overall Score: 4.5 / 5

Six new commits since Round 9 bring several genuine improvements; one blocking issue (B3) remained unfixed by the developer and has been resolved in commit 0770731 (pending push). One dead-code observation in the new TUI commit flow is worth noting.


Blocking Issue Fixed in this Round

B3 — null returns instead of undefined (CLAUDE.md violation)Fixed by commit 0770731

Three files still returned null in violation of CLAUDE.md's "Default to undefined over null" rule:

File Function Change
src/server/infra/ssh/ssh-agent-signer.ts tryGetSshAgentSigner Promise<null | SshAgentSigner>Promise<SshAgentSigner | undefined>
src/shared/ssh/key-parser.ts getPublicKeyMetadata Promise<null | {...}>Promise<{...} | undefined>
src/server/infra/ssh/signing-key-cache.ts SigningKeyCache.get() null | ParsedSSHKeyParsedSSHKey | undefined

All return null call sites and test assertions (.to.be.null, .to.not.be.null, 'returns null when ...' titles) updated consistently.


New Commits — All Reviewed ✅

010e653 — feat(tui): Ink passphrase prompt

  • InlinePassword component — masked input (renders * per char, never writes raw value to Ink stream), Escape cancels, empty submission rejected. Clean.
  • vc-commit-flow-state.ts — pure reducer for committing → awaiting-passphrase → done state machine. Correct retry cap at MAX_PASSPHRASE_RETRIES = 3.
  • VcCommitFlow rewired to useReducer. When daemon returns PASSPHRASE_REQUIRED, InlinePassword renders and submitting fires a retry commit with passphrase in payload.
  • 13 unit tests cover all state transitions without spinning up an Ink tree — consistent with this project's convention. ✅

ce82e17 — refactor(ssh): scope SigningKeyCache by (projectPath, keyPath)

Project boundary isolation: two projects sharing a keyPath no longer share a cached crypto.KeyObject. Null-byte composite key is sound (\0 cannot appear in any POSIX/Windows path). ✅

2e4d228 — feat(ssh): add scrubPassphrase helper

Generic scrubPassphrase<T> redacts passphrase before logging. Correctly handles undefined/empty-string with an early return. ✅

0b4f7a5 — feat(signing-key): require --yes to confirm destructive remove

brv signing-key remove <id> now errors without --yes. Consistent with the oclif non-interactive rule. ✅

4adcbdc — refactor(ssh): consolidate SSHSIG_HASH_ALGORITHM constant

Extracted to sshsig-constants.ts with a four-point rationale comment explaining why sha512 is the only correct choice. ✅

8feaa2c — refactor(vc): drop camel-case user.signingKey

VcConfigKey union now contains only 'user.signingkey' (canonical lowercase). Camel-case is still accepted at runtime via .toLowerCase() in vc-handler.ts, but new code can't depend on it. Round 7 Minor #3 fully resolved. ✅


Minor Observations (Non-blocking)

A. Dead passphraseRef in vc-commit-flow.tsx

vc-commit-flow.tsx:73-74:

onSubmit={(pp) => {
  passphraseRef.current = pp   // ← written but never read
  dispatch({type: 'passphrase-submitted'})
  fireCommit(pp)               // ← pp passed directly
}}

passphraseRef.current is set to pp but fireCommit(pp) receives it directly in the same synchronous handler. No useEffect reads back from the ref. The ref write can be removed. Fix this →

B. --passphrase flag description could warn about process-list visibility

src/oclif/commands/vc/commit.ts:21:

description: 'SSH key passphrase (or set BRV_SSH_PASSPHRASE env var)',

CLI flag values are visible in ps aux and shell history. The description should include a security note. (Raised in Round 7 Minor #2 — still not addressed.) Fix this →


Positive Notes

All strengths from prior rounds hold, plus the new commits add:

  • Passphrase retry loop is now correctly in the TUI (interactive) and cleanly absent from oclif (non-interactive). The separation is architecturally correct.
  • InlinePassword properly masks input — no raw secret in the Ink render tree.
  • vc-commit-flow-state.ts pure reducer is a clean split: testable transitions with no Ink dependency.
  • SigningKeyCache project scoping closes a subtle cross-project credential-sharing risk.
  • scrubPassphrase provides a safe logging boundary going forward.

hieuntg81 and others added 2 commits April 21, 2026 09:20
…ew A)

The ref was written on InlinePassword.onSubmit but never read — the
passphrase flows directly through fireCommit(pp) in the same synchronous
handler, so there is no transition to bridge.

Bao PR #435 Round 10 Minor A.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…2002 review B)

CLI flag values appear in \`ps aux\` (same UID) and shell history.
Explicit warning at point-of-use so users don't need to read the
standalone docs first. Repeats the BRV_SSH_PASSPHRASE / ssh-agent
guidance already in docs/ssh-commit-signing.md.

Bao PR #435 Round 7 Minor #2 / Round 10 Minor B.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 21, 2026

Claude finished @hieuntg81's task in 3m 30s —— View job


PR Re-Review (Round 11): feat(vc): implement SSH commit signing (ENG-2002)

Tasks

  • Gather context and understand the request
  • Check what changed since Round 10 review
  • Verify Round 10 minor A fixed: dead passphraseRef — ✅ Fixed in db56ec0
  • Verify Round 10 minor B fixed: --passphrase flag warning — ✅ Fixed in 00931f8
  • Verify B3 null→undefined: still present — fixed locally, push blocked (see below)
  • Fix B3: nullundefined in all three files + update tests
  • Commit (local commit 9d3c64a) — push blocked, requires manual approval
  • Post final review summary

Overall Score: 5 / 5

Both minor observations from Round 10 have been fully addressed. The B3 nullundefined fix (carried since Round 9 with the push blocked each time) has been committed locally as 9d3c64aplease run git push origin feat/ENG-2002 from the workflow or approve the push.


Round 10 Observations — All Fixed ✅

A. Dead passphraseRef in vc-commit-flow.tsxFixed in db56ec0

The passphraseRef that was written but never read has been removed. The current vc-commit-flow.tsx only retains the legitimate fired ref (line 50) for guarding the first-commit useEffect. useRef import is still present for that use. ✅

B. --passphrase flag description missing security warningFixed in 00931f8

src/oclif/commands/vc/commit.ts:22:

description:
  'SSH key passphrase — WARNING: visible in process list and shell history. Prefer BRV_SSH_PASSPHRASE env var or ssh-agent.',

Clear, actionable, and correctly steers users toward the safer alternatives. ✅


Blocking Issue (B3) — Fixed Locally, Push Blocked

nullundefined in tryGetSshAgentSigner, getPublicKeyMetadata, SigningKeyCache.get()Commit 9d3c64a ready, push blocked (403)

CLAUDE.md requires undefined over null for all internal return values. Three files still violated this after Round 10 because the previous fix commit couldn't be pushed. Local commit 9d3c64a resolves all sites:

File Change
src/server/infra/ssh/ssh-agent-signer.ts Promise<null | SshAgentSigner>Promise<SshAgentSigner | undefined>; all return nullreturn undefined; .catch(() => null).catch(() => undefined); JSDoc updated
src/shared/ssh/key-parser.ts Promise<null | {…}>Promise<{…} | undefined>; both return nullreturn undefined
src/server/infra/ssh/signing-key-cache.ts null | ParsedSSHKeyParsedSSHKey | undefined; both return nullreturn undefined; JSDoc updated
test/unit/infra/ssh/ssh-agent-signer.test.ts All .to.be.null / .to.not.be.null.to.be.undefined / .to.not.be.undefined; 3 'returns null when …' descriptions updated
test/unit/infra/ssh/signing-key-cache.test.ts All .to.be.null / .to.not.be.null.to.be.undefined / .to.not.be.undefined; 3 test descriptions updated

All callers (vc-handler.ts:571,651) already use truthy/falsy checks (if (agentSigner), if (!parsed)) that work identically for undefined — no call-site changes needed.

Action required: git push origin feat/ENG-2002 from the workflow to land commit 9d3c64a.


Summary

Once 9d3c64a is pushed, the PR is complete. All 3 critical issues, 3 medium issues, 5 minor issues (Round 1), 3 observations (Round 2), and all follow-up observations through Round 11 have been resolved. The SSH commit signing feature is architecturally sound, spec-compliant, and well-tested.

hieuntg81 and others added 16 commits April 22, 2026 10:54
Conflicts resolved:
- src/server/core/interfaces/services/i-git-service.ts:
  Keep onSign callback on CommitGitParams (SSH signing, HEAD) and
  add filepath? to LogGitParams (new from main).
- src/server/infra/process/feature-handlers.ts:
  Register both SigningKeyHandler (HEAD) and ContextTreeHandler (main).
- test/unit/infra/transport/handlers/vc-handler.test.ts:
  Keep all 5 handleCommit tests — 3 SSH signing regression cases (HEAD)
  plus 2 conflict detection cases (main).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…2002 review #17)

The PEM branch of extractPublicKey called parseSSHPrivateKey(keyPath) with
no passphrase. For an encrypted PEM key without a .pub sidecar, this
surfaced either a misleading "Wrong passphrase" (user never entered one)
or a raw OpenSSL code — violating AC9-b/c.

Wrap the call with passphrase-error detection and throw a user-grade
error pointing at `ssh-keygen -y -f` as the canonical workaround.

Affects: `brv signing-key add --key <private-key>` and
`brv vc config --import-git-signing` when the configured key is an
encrypted PEM without a .pub sidecar. `vc commit` is unaffected — it
goes through probeSSHKey + explicit passphrase flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Added in 2e4d228 with the intent of safe payload logging, but never
wired into any log or error-construction site. The commit path builds
VcError instances with pre-formatted messages rather than serialising
the incoming payload, so there is no consumer for the helper today.

Delete rather than wire speculatively — re-introduce with a concrete
log site if one ever materialises.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… review #23)

The test under describe('list action') asserted only that the request
handler is a function — not that `list` maps IAM responses. Setup
registration is covered by describe('setup'), and the injectable-service
seam tests under describe('action routing (via injectable service seam)')
already exercise the real list → mapResource → toSigningKeyItem path.

Removing the fake eliminates a false coverage signal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…g change (ENG-2002 review #14)

When user ran `brv vc config user.signingkey <newpath>`, handleConfig
cached the new ParsedSSHKey but did not evict the entry for the
previously configured path. The stale ParsedSSHKey (with its decrypted
crypto.KeyObject) sat in daemon memory until its 30-min TTL expired —
low-severity but avoidable leak since SigningKeyCache already exposes
invalidate(projectPath, keyPath) for exactly this case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…iew #16)

resolveHome only expands a leading ~. Relative paths like `./id_ed25519`
or bare `id_ed25519` passed straight through and then resolved against
the daemon's CWD — so the same config value worked today and broke
tomorrow if the daemon restarted from a different directory.

Reject non-absolute paths at both entry points (handleConfig set and
handleImportGitSigning) with INVALID_CONFIG_VALUE. Matches the semantic
of `git config user.signingKey`, which also requires an absolute path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…(ENG-2002 review #18)

handleConfig (and handleImportGitSigning) wrapped parseSSHPrivateKey in
an empty catch block, so any non-passphrase failure (corrupt file, race
between probe and parse, unexpected format) disappeared silently. The
config set returned success with no hint and no warning — the user only
discovered the problem at the next `brv vc commit --sign`, with no
breadcrumb back to the config step.

Detect passphrase-class errors via isPassphraseError (exported for this
use) and skip the hint for those (intended behavior — encrypted keys
need the passphrase to derive a fingerprint). For any other error,
surface the message in the hint so the failure is visible at the point
the user took the action.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…te (ENG-2002 review #26)

React-Query retains mutation.variables until `reset()` is called
explicitly or gcTime elapses (default 5 min). Because this PR added
`passphrase` to the mutation payload, the plaintext passphrase lived in
TUI memory for ~5 min after every successful commit — exposed to
devtools, error boundaries, and any diagnostic dumper that walks the
query cache.

Call commitMutation.reset() in the same useEffect that bubbles the
terminal state up to the dialog manager. No dedicated test: the project
has no React/Ink testing library wired up, and the change is a
one-liner inside a clearly-scoped useEffect.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…iew #21+#22)

Two protocol-hardening guards against a misbehaving or truncated SSH
agent response:

#21 — request() used to resolve with whatever bytes the agent delivered,
even when the body was zero-length. Downstream `response[0] !==
SSH_AGENT_IDENTITIES_ANSWER` then formatted "undefined" into the error
message, losing the actual fault. Reject empty bodies at the request()
boundary with a clear message.

#22 — sign() read `sigLen = readUInt32(response, 1)` and returned
`subarray(5, 5 + sigLen)` without checking the declared length against
the actual body length. A truncated response yielded a short signature
silently; downstream `crypto.verify()` would then reject it with a
generic "bad signature" error instead of surfacing the agent-boundary
problem. Compare sigLen against `response.length - 5` and throw a
descriptive error when mismatched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…e.success (ENG-2002 review #19+#20)

#19 — `brv vc config --import-git-signing` previously sniffed four
English substrings ("already exists", "Duplicate", "ALREADY_EXISTS",
"409") on the error message to decide whether a key was already
registered. If IAM ever returned "Conflict: ...", "RESOURCE_EXISTS",
"DUPLICATE_KEY", or any localized variant, the substring check
fell through into the "run brv signing-key add" hint — which would
then 409 again. Translate the HTTP 409 at the boundary that knows
about it (HttpSigningKeyService) into a new
VcErrorCode.SIGNING_KEY_ALREADY_EXISTS, and match that code
structurally at the CLI via TransportRequestError.code.

#20 — listKeys silently returned [] when the response envelope carried
`{success: false}`. Callers had no way to distinguish "no keys" from
"request semantically failed." AuthenticatedHttpClient throws on
non-2xx responses, so this only bites on 200 OK + success:false — but
that's an envelope we shouldn't trust implicitly. addKey and listKeys
now both throw on success:false for symmetric defensive posture.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… gitconfig (ENG-2002 review #24)

Existing handleImportGitSigning tests all write config into the
per-repo local config, but the most common real-world setup is a
single user.signingKey in ~/.gitconfig. If a later refactor skips the
global lookup, every existing test still passes while the most common
user configuration ships broken.

Add a regression test that points GIT_CONFIG_GLOBAL at a temp config
file (so git child processes inherit it) and asserts the signingKey is
imported with no local config involvement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…eview #25)

Every existing agent-signer test uses ed25519, so the RSA-specific
branch (`flags = this.keyType === 'ssh-rsa' ? SSH_AGENT_RSA_SHA2_512 :
0`) is untested. A refactor from `=== 'ssh-rsa'` to `.startsWith('rsa-')`
or `=== 'rsa'` would go green across the whole suite while silently
signing RSA with SHA-1 — breaking the v1 recovery path documented for
users whose encrypted ~/.ssh/id_rsa lives in ssh-agent.

This test drives tryGetSshAgentSigner via a .pub sidecar labeled
"ssh-rsa" (keyType detection happens there) and a mock agent that
captures the flags uint32 from the SIGN request. Asserting
capturedFlags === 4 pins the SSH_AGENT_RSA_SHA2_512 branch exactly.

Full crypto round-trip (verify the agent signature) is out of scope —
a valid RSA SSH private key in OpenSSH format would need to be
generated outside Node and checked in as a test fixture. The flag
assertion catches the only branch we actually care about at this layer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…#2+#3+#4+#5)

Four unrelated-by-line-number but related-by-pattern cleanups, all
CLAUDE.md "Avoid as Type / ! non-null assertions" hygiene.

#2 key-parser.ts parseOpenSSHKey — runtime check went through
VALID_SSH_KEY_TYPES.has(string), then cast to SSHKeyType. Extract that
into an exported isValidSSHKeyType predicate so the narrowing is
visible to the compiler.

#3 key-parser.ts parseSSHPrivateKey — KeyObject.export has a string |
Buffer overload; the cast masked Node's narrowing. Swap to
Buffer.isBuffer and throw if the export ever returns a string. A naive
Buffer.from(exported) would UTF-8-encode and silently corrupt DER.

#4 ssh-agent-signer.ts tryGetSshAgentSigner — narrow the return type
of getPublicKeyMetadata to {keyType: SSHKeyType} (enforced via
isValidSSHKeyType on the .pub sidecar branch, already guaranteed on
the OpenSSH branch after #2). The call-site cast drops out naturally.
Also normalises the sentinel from null to undefined along the way
(addresses review #13 since both touch the same signature).

#5 signing-key/add.ts — `publicKey!` was papering over oclif's lack of
an `asserts never` declaration on this.error(). Replace with an
explicit `if (!publicKey) this.error(...)` narrow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…#7+#8+#9+#10)

Per CLAUDE.md: "Prefer type for data-only shapes (DTOs, payloads,
configs); prefer interface for behavioral contracts with method
signatures." All of the following are plain data bags — no method
signatures on the shape itself:

#6 SigningKeyResource (i-signing-key-service.ts)
#7 ApiEnvelope<T>, CreateSigningKeyData, ListSigningKeysData,
   RawSigningKeyResource (http-signing-key-service.ts)
#8 SigningKeyHandlerDeps (signing-key-handler.ts) — record of service
   refs, no methods on the deps struct itself
#9 ParsedSSHKey, SSHSignatureResult, SSHKeyProbeResult,
   SSHKeyProbeResultFound (shared/ssh/types.ts)
#10 SigningKeyItem (shared/transport/events/vc-events.ts)

ISigningKeyService stays as an interface — it's a behavioral contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#12)

Per CLAUDE.md: "Default to undefined over null; reserve null only for
external boundaries (storage, HTTP APIs) that force it." These are
pure in-memory utilities with no external boundary:

#11 SigningKeyCache.get — Map-backed TTL wrapper. Switch the miss /
expired sentinel to undefined, update the 10 call-site assertions in
signing-key-cache.test.ts.

#12 tryGetSshAgentSigner — local factory for the agent-signer chain.
Switch the "no signer available" sentinel to undefined and propagate
through the `.catch(() => undefined)` in the inner getPublicKeyMetadata
call. Update ssh-agent-signer.test.ts assertions.

#13 (getPublicKeyMetadata) was handled in the earlier as/! refactor
commit because it sits on the same signature.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…iew #15)

The sweep loop that evicts expired entries used to run only inside
set(). Long-running daemons that are read-heavy (cache once, sign
many) therefore never reclaimed memory for keys the user had stopped
using — get() only deleted the single entry it happened to touch.

Extract `sweep(now)` as a private method and call it from both get()
and set(). Ceiling growth was already practically bounded
(projects × keys × ~1KB) so this is mostly a code-smell / symmetry
fix rather than a memory leak.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 22, 2026

Claude finished @hieuntg81's task in 5m 29s —— View job


PR Re-Review (Round 12): feat(vc): implement SSH commit signing (ENG-2002)

Tasks

  • Gather context and understand the request
  • Check what changed since Round 11 review (15 new commits)
  • Verify B3 null→undefined fix was applied
  • Review new refactoring commits (type predicates, DTO→type, TTL sweep, bounds guards, path validation, cache invalidation)
  • Post final review summary

Overall Score: 5 / 5

Fifteen new commits landed since Round 11. All prior blocking/medium/minor items through Round 11 are confirmed intact. The new commits resolve a comprehensive additional review pass (labeled #1#26 in commit messages). One cosmetic formatting nit remains, noted below.


Previously Blocking (B3) — Fixed ✅

nullundefined in tryGetSshAgentSigner, getPublicKeyMetadata, SigningKeyCache.get()Fixed in 57db09e

All return null sites, .catch(() => null), and return-type annotations updated. Test assertions updated from .to.be.null / .to.not.be.null in both signing-key-cache.test.ts and ssh-agent-signer.test.ts. Matches CLAUDE.md convention.


New Commits — All Reviewed ✅

Commit Description Status
03a1f71 Remove unwired scrubPassphrase helper Correct — no call sites existed, dead code eliminated cleanly
598837a Actionable hint for encrypted PEM in extractPublicKey Correct — wraps parseSSHPrivateKey in passphrase-error detection before surfacing the error
e836706 Reject non-absolute signing-key path in config Correct — path.isAbsolute() check at both handleConfig and handleImportGitSigning entry points
7c04d97 Invalidate cache entry for previous signing-key on config change Correct — evicts signingKeyCache.invalidate(projectPath, existing.signingKey) before writing new path
c368efd Distinguish passphrase vs other parse errors in config hint Correct — only suppresses hint for passphrase-class errors; surfaces message for all other failures
29658cc Reset commit mutation to drop passphrase after terminal state Correct and important — commitMutation.reset() is called on all kind === 'done' states (success, error, cancelled), which covers every terminal outcome in the state machine
c0b44fe Guard agent response length and sigLen bounds Correct — empty body rejected at request() boundary; sigLen > response.length - 5 guards sign() against truncated agent responses
637a061 Detect signing-key duplicate via VcErrorCode + check response.success Structurally correct — 409 detection moved to the IAM service boundary and surfaced as SIGNING_KEY_ALREADY_EXISTS; callers do structural matching rather than string-sniffing
6dc8df2 Test: handleImportGitSigning picks up key from global gitconfig Good regression guard — uses GIT_CONFIG_GLOBAL env var to isolate the test
9f3225e Test: RSA-via-agent sets SSH_AGENT_RSA_SHA2_512 flag Excellent regression test — pins the exact flags = 4 constant, prevents a silent SHA-1 regression if the key-type check is ever refactored
4b24422 Type predicates in place of as/! casts Correct — isValidSSHKeyType predicate replaces a cast; Buffer.isBuffer check + throw replaces a cast; if (!publicKey) replaces publicKey!
9c0a68e Convert data-only DTO interfacetype Correct per CLAUDE.md — SigningKeyResource, ParsedSSHKey, SSHSignatureResult, SSHKeyProbeResult, SSHKeyProbeResultFound, SSHKeyProbe, SigningKeyItem, and deps structs converted; ISigningKeyService (behavioral contract) stays interface
57db09e null sentinels → undefined Correct — see B3 above
6605747 Extract sweep(now) for symmetric TTL eviction Correct — sweep now runs on get() and set(), preventing long-running daemons from accumulating stale ParsedSSHKey objects

One Cosmetic Nit (Non-blocking)

Missing indentation on signing-key-cache.ts:30

export class SigningKeyCache {
  private readonly cache = new Map<string, CacheEntry>()
private readonly ttlMs: number    // ← missing 2-space indent

Line 30 is missing its 2-space class-member indent — likely a copy/paste artefact from commit 57db09e. TypeScript compiles it fine but ESLint's indent rule would catch it. Fix this →


Positive Notes

All strengths from prior rounds remain intact, plus the new batch adds:

  • sweep() now runs symmetrically from both get() and set() — long-lived daemons no longer accumulate stale ParsedSSHKey objects
  • Agent response bounds hardening (empty body, truncated signature length) closes a latent crash path
  • Non-absolute signing-key path now rejected at config time with a clear INVALID_CONFIG_VALUE — prevents CWD-relative breakage on daemon restart
  • Stale cache entry for the previous signing-key is evicted on user.signingkey config change
  • SIGNING_KEY_ALREADY_EXISTS error code is structurally matched in the import-git-signing path — no more English substring sniffing
  • mutation.reset() after terminal state removes the plaintext passphrase from React-Query's internal variables cache

The PR is clean, architecturally sound, and well-covered by tests. Ready to merge once the cosmetic indentation nit is addressed (or accepted as-is).

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 22, 2026

Claude finished @hieuntg81's task in 1m 47s —— View job


PR Re-Review (Round 13): feat(vc): implement SSH commit signing (ENG-2002)

Tasks

  • Gather context and understand the request
  • Check what changed since Round 12 review (merge from main — commit 10b903f)
  • Verify Round 12 cosmetic nit (indentation in signing-key-cache.ts:30) — still present, fixed locally
  • Review merge impact on SSH signing feature
  • Post final review summary

Overall Score: 5 / 5

The trigger for this round was commit 10b903f — a merge of main into feat/ENG-2002. The merged content is the web UI / 3.8.0 release bundle (web UI dashboard, brv curate blocking behavior, model updates, Tailwind CSS source path fix). This is completely orthogonal to SSH commit signing. The merge was clean with no conflicts.


Merge Impact Analysis

The 4 files brought in from main have zero overlap with the SSH signing feature:

File Touched by SSH signing? Touched by main merge? Conflict?
CHANGELOG.md ✅ (added 3.8.0 release notes) None
CLAUDE.md ✅ (web UI architecture docs, curate blocking note) None
package.json ✅ (version bump) None
src/webui/styles/index.css ✅ (Tailwind source path + keyframe fix) None

All fixes from Rounds 1–12 remain intact after the merge:

  • Architecture boundary (oclifshared/ssh, not server/infra/ssh) ✅
  • shouldSign defaults to false — setting user.signingkey alone does not auto-enable signing ✅
  • RSA signing uses sha512 / rsa-sha2-512
  • Encrypted OpenSSH keys short-circuit with SIGNING_KEY_NOT_SUPPORTED before passphrase loop ✅
  • SigningKeyHandler auth guard fires proactively ✅
  • extractPublicKey correctly extracts public key from encrypted OpenSSH files without passphrase ✅
  • nullundefined throughout SSH layer ✅
  • InlinePassword passphrase masking in TUI ✅
  • mutation.reset() after terminal state removes plaintext passphrase from React-Query cache ✅
  • SigningKeyCache project-scoped, sweep-on-write, no background timers ✅

Cosmetic Nit Carried Over from Round 12 — Fixed ✅

Missing indentation on signing-key-cache.ts:30 was still present after the merge. Fixed locally in commit 0835152:

// Before
export class SigningKeyCache {
  private readonly cache = new Map<string, CacheEntry>()
private readonly ttlMs: number   // ← missing 2-space indent
// After
export class SigningKeyCache {
  private readonly cache = new Map<string, CacheEntry>()
  private readonly ttlMs: number  // ✅

Note: Commit 0835152 exists locally but git push returned 403. Please run git push origin feat/ENG-2002 from the workflow to land this cosmetic fix.


Summary

The merge from main introduces no regressions or conflicts in the SSH signing feature. The web UI, curate blocking, and Tailwind changes are entirely separate subsystems. The PR is in the same excellent shape as after Round 12, with the one remaining cosmetic nit now fixed locally (pending push). Ready to merge once the push is approved.

…02 PR #435 nit)

Copy/paste artefact from 57db09e — the ttlMs field lost its 2-space
class-member indent. TypeScript compiled it fine but ESLint's indent
rule would flag it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 22, 2026

Claude finished @hieuntg81's task in 59s —— View job


PR Re-Review (Round 14): feat(vc): implement SSH commit signing (ENG-2002)

Tasks

  • Gather context and understand the request
  • Check what changed since Round 13 review (commit 4be8e74)
  • Verify Round 13 cosmetic nit (indentation in signing-key-cache.ts:30) — fixed ✅
  • Confirm all previous fixes remain intact
  • Post final review summary

Overall Score: 5 / 5

The sole trigger for this round is commit 4be8e74 — "style(ssh): fix missing indent on SigningKeyCache.ttlMs field". This is a single-character fix addressing the one cosmetic nit from Round 12 that was carried into Round 13. The PR is ready to merge.


Round 13 Cosmetic Nit — Fixed ✅

Missing indentation on signing-key-cache.ts:30Fixed in 4be8e74

// Before (copy/paste artefact from 57db09e)
export class SigningKeyCache {
  private readonly cache = new Map<string, CacheEntry>()
private readonly ttlMs: number   // ← missing 2-space indent

// After
export class SigningKeyCache {
  private readonly cache = new Map<string, CacheEntry>()
  private readonly ttlMs: number  // ✅

Only signing-key-cache.ts changed (1 insertion, 1 deletion). No other files touched.


All Fixes From Rounds 1–13 Remain Intact ✅

Area Status
Architecture boundary (oclifshared/ssh, not server/infra/ssh)
shouldSign defaults to false — setting user.signingkey alone does not auto-enable signing
RSA signing uses sha512 / rsa-sha2-512
Encrypted OpenSSH keys short-circuit with SIGNING_KEY_NOT_SUPPORTED before passphrase loop
SigningKeyHandler auth guard fires proactively
Injectable service seam for testing
handleImportGitSigning test coverage
extractPublicKey extracts public key from encrypted OpenSSH files without passphrase
nullundefined throughout SSH layer
InlinePassword passphrase masking in TUI; mutation.reset() after terminal state
SigningKeyCache project-scoped, sweep-on-write, no background timers
Non-absolute signing-key path rejected at config time
--passphrase flag description warns about process-list visibility
isPassphraseError regex excludes ` unsupported` (no more dead-end prompt for RSA/ECDSA)

Summary

All 3 critical issues, 3 medium issues, 5 minor issues (Round 1), 3 observations (Round 2), pre-existing observation (Round 4), medium issue (Round 7), blocking issues B1/B2/B3 (Rounds 8–11), and the cosmetic indentation nit (Rounds 12–13) — all addressed. The SSH commit signing feature is complete, architecturally sound, spec-compliant, and well-tested. Ready to merge.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 22, 2026

Claude finished @hieuntg81's task in 2m 17s —— View job


PR Re-Review (Round 15): feat(vc): implement SSH commit signing (ENG-2002)

Tasks

  • Gather context and understand the request
  • Check what changed since Round 14 review (commit 4be8e74)
  • Review new/modified files for correctness, security, and quality
  • Verify all previous fixes remain intact
  • Post final review summary

Overall Score: 5 / 5

The trigger for this round was commit cd420c0 — a merge of main into feat/ENG-2002. The merged content is the 3.8.2 patch release (web UI Tailwind source path fix + version bump). This is completely orthogonal to SSH commit signing. The merge was clean with no conflicts.


Merge Impact Analysis

The 3 files brought in from main have zero overlap with the SSH signing feature:

File Touched by SSH signing? Touched by 3.8.2 merge? Conflict?
CHANGELOG.md ✅ (added 3.8.2 release notes) None
package.json ✅ (version bump 3.8.1 → 3.8.2) None
.env.example ✅ (one line added) None

All fixes from Rounds 1–14 remain intact after the merge:

Area Status
Architecture boundary (oclifshared/ssh, not server/infra/ssh)
shouldSign defaults to false — setting user.signingkey alone does not auto-enable signing
RSA signing uses sha512 / rsa-sha2-512 (sshsig-signer.ts:51,57)
Encrypted OpenSSH keys short-circuit with SIGNING_KEY_NOT_SUPPORTED before passphrase loop (vc-handler.ts:1706)
SigningKeyHandler auth guard fires proactively (signing-key-handler.ts:64)
nullundefined throughout SSH layer (all three files: ssh-agent-signer.ts, key-parser.ts, signing-key-cache.ts)
isPassphraseError regex excludes |unsupported — no more dead-end passphrase prompt for RSA/ECDSA (key-parser.ts:215)
InlinePassword passphrase masking in TUI; dead passphraseRef removed from vc-commit-flow.tsx
mutation.reset() after terminal state removes plaintext passphrase from React-Query cache
--passphrase flag description warns about process-list visibility (commit.ts:22)
Camelcase user.signingKey removed from VcConfigKey union; only canonical user.signingkey remains
SigningKeyCache project-scoped (null-byte composite key), sweep-on-write, no background timers
Non-absolute signing-key path rejected at config time with INVALID_CONFIG_VALUE
extractPublicKey extracts public key from encrypted OpenSSH files without passphrase

Summary

The 3.8.2 merge brings only a CHANGELOG entry, a version bump, and a .env.example line — no interaction whatsoever with the SSH signing feature. The PR remains in the same excellent shape as after Round 14.

All 3 critical issues, 3 medium issues, 5 minor issues (Round 1), 3 observations (Round 2), pre-existing observation (Round 4), medium issue (Round 7), blocking issues B1/B2/B3 (Rounds 8–11), and the cosmetic indentation nit (Rounds 12–13) — all resolved. The SSH commit signing feature is complete, architecturally sound, spec-compliant, and well-tested. Ready to merge.

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.

2 participants