Skip to content

made cleaner app prompt handling and placed in JWT Key#2983

Open
tim-inkeep wants to merge 8 commits intomainfrom
bugfix/app-prompt-security-vulnerability
Open

made cleaner app prompt handling and placed in JWT Key#2983
tim-inkeep wants to merge 8 commits intomainfrom
bugfix/app-prompt-security-vulnerability

Conversation

@tim-inkeep
Copy link
Copy Markdown
Contributor

No description provided.

@vercel
Copy link
Copy Markdown

vercel bot commented Apr 2, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agents-api Ready Ready Preview, Comment Apr 3, 2026 5:06pm
agents-docs Ready Ready Preview, Comment Apr 3, 2026 5:06pm
agents-manage-ui Ready Ready Preview, Comment Apr 3, 2026 5:06pm

Request Review

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 2, 2026

🦋 Changeset detected

Latest commit: 1e43335

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 10 packages
Name Type
@inkeep/agents-core Patch
@inkeep/agents-api Patch
@inkeep/agents-manage-ui Patch
@inkeep/agents-cli Patch
@inkeep/agents-sdk Patch
@inkeep/agents-work-apps Patch
@inkeep/ai-sdk-provider Patch
@inkeep/create-agents Patch
@inkeep/agents-email Patch
@inkeep/agents-mcp Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pullfrog
Copy link
Copy Markdown
Contributor

pullfrog bot commented Apr 2, 2026

TL;DR — Fixes a security vulnerability where appId and agentId could be spoofed via HTTP headers during A2A delegation. Both values are now derived exclusively from the cryptographically signed JWT service token, appPrompt is always re-resolved from the database with project-scoped lookups, and header-supplied appId is ignored entirely for regular API key auth.

Key changes

  • Embed appId in service token JWTappId is added to ServiceTokenPayload and GenerateServiceTokenParams, included in generateServiceToken, and extracted during verifyServiceToken so it travels through the signed delegation chain instead of an untrusted header.
  • Derive agentId and subAgentId from JWT in buildExecutionContext — team delegation now sets agentId from the JWT sub claim (origin/parent agent) and subAgentId from the JWT aud claim (target agent), eliminating reliance on the x-inkeep-agent-id header.
  • Remove header-based appId forwardingexecutionHandler.ts no longer sets the x-inkeep-app-id header, the auth middleware no longer falls back to reqData.appId from headers, and regular API key auth ignores the x-inkeep-app-id header completely.
  • Remove appPrompt from auth layertryAppCredentialAuth no longer sets appPrompt in metadata; the prompt is resolved later in the handler.
  • Always re-resolve appPrompt from DB with project-scoped lookupgenerateTaskHandler.ts calls getAppByIdForProject (scoped by tenantId and projectId) on every request that has an appId, rather than skipping the lookup when appPrompt is already set.
  • Harden initiatedBy extraction in verifyServiceToken — the bare as cast is replaced with a full runtime type guard that validates structure, type enum, and id string before trusting the claim.
  • New test for cross-tenant/project rejection — verifies that appPrompt is undefined when the app belongs to a different tenant or project.

Summary | 10 files | 8 commits | base: mainbugfix/app-prompt-security-vulnerability


appId propagated via signed JWT instead of headers

Before: During A2A delegation, appId was forwarded via the x-inkeep-app-id HTTP header, which any caller could set — allowing spoofing of app context in sub-agent requests.
After: appId is embedded in the signed service token JWT payload and extracted on the receiving end from the verified token, making it tamper-proof.

The ServiceTokenPayload interface gains an optional appId field. Both generateServiceToken and verifyServiceToken handle it symmetrically. All delegation sites — createDelegateToAgentTool in relationTools.ts and the sub-agent handoff in executionHandler.ts — now include appId in the token params and no longer set the x-inkeep-app-id header. On the auth side, tryTeamAgentAuth in runAuth.ts extracts appId from the JWT payload.

service-token-auth.ts · relationTools.ts · executionHandler.ts · runAuth.ts


agentId and subAgentId derived from JWT claims, not headers

Before: In team delegation, agentId was read from the x-inkeep-agent-id header and subAgentId from x-inkeep-sub-agent-id — both attacker-controllable. If the header was absent, agentId fell back to the JWT aud (the target agent), conflating parent and target identities.
After: buildExecutionContext derives agentId from the JWT sub claim (origin/parent agent) and subAgentId from the JWT aud claim (target agent). Header values are ignored for team delegation.

This is a structural change to buildExecutionContext in runAuth.ts. The old code conditionally trusted reqData.agentId from headers; the new code branches on teamDelegation and pulls both identities from the cryptographically verified service token payload. The x-inkeep-agent-id header is still used for the non-delegation path (API key override warning), but never for team delegation.

runAuth.ts · api-key-auth.test.ts


appPrompt resolved at handler time with project-scoped lookup

Before: appPrompt was set in the auth layer by tryAppCredentialAuth and cached in metadata; sub-agents skipped the DB lookup if appPrompt was already present. The lookup used getAppById with no tenant or project scoping.
After: Auth no longer sets appPrompt. The handler always resolves it fresh from the database using getAppByIdForProject, which requires matching tenantId and projectId — preventing both cross-tenant and cross-project prompt injection.

The conditional in generateTaskHandler.ts changes from if (!appPrompt && appId) to if (appId), ensuring the prompt is re-fetched on every invocation. The lookup function switches from getAppById to getAppByIdForProject, adding a scopes: { tenantId, projectId } filter. A stale or attacker-supplied appPrompt in metadata is always overwritten with the authoritative value from the database.

Why always re-resolve instead of trusting the cached value?

A parent agent's metadata could carry a stale or manipulated appPrompt. By always fetching from DB with project scoping, the system guarantees the prompt matches the current database state and belongs to the correct tenant and project — eliminating a class of prompt injection vectors in A2A chains.

generateTaskHandler.ts · runAuth.ts


Header-based appId no longer trusted for regular API key auth

Before: The auth middleware would copy x-inkeep-app-id from headers into authResult.metadata.appId even for regular API key auth, and trace spans used this header-derived value.
After: The fallback that set appId from reqData.appId when not already present in the auth result is removed. Trace span attributes now use attempt.authResult.metadata.appId (the auth-verified value) instead of the raw header.

Two blocks in runApiKeyAuthHandler that forwarded reqData.appId into authResult.metadata are deleted. The app.id trace attribute is now set only when appId was established through a verified auth path.

runAuth.ts · runAuth-appPrompt.test.ts


initiatedBy JWT claim validated with runtime type guard

Before: verifyServiceToken extracted initiatedBy with a bare as cast — any malformed JWT claim would pass through unchecked.
After: A full runtime type guard validates the initiatedBy object structure (type must be 'user' or 'api_key', id must be a string) before trusting it. Similarly, appId is validated as a string before inclusion.

This hardens the token verification boundary against crafted JWTs that might contain unexpected shapes in custom claims.

service-token-auth.ts · service-token-auth.test.ts


Test coverage for new security behavior

Tests across four files verify the hardened flow: service token round-trips appId, auth middleware no longer sets appPrompt, header-supplied appId and agentId are ignored for both team delegation and regular API key auth, agentId/subAgentId are derived from JWT claims, and a cross-tenant/project appId lookup correctly returns no prompt.

service-token-auth.test.ts · generateTaskHandler.test.ts · runAuth-appPrompt.test.ts · api-key-auth.test.ts

Pullfrog  | View workflow run | Triggered by Pullfrog | Using Claude Opus𝕏

Copy link
Copy Markdown
Contributor

@pullfrog pullfrog bot left a comment

Choose a reason for hiding this comment

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

Solid security fix. This PR closes a real vulnerability where an attacker with a regular API key could inject an arbitrary appId via the x-inkeep-app-id header, which would then influence prompt resolution. The fix correctly moves appId propagation into the signed JWT service token and resolves appPrompt server-side with tenant scoping. Two minor items — one inline, one here:

Stale comment in runAuth.ts:899-900 (outside the diff, so noting here): The comment says "The appId is forwarded for context only" but the header-based appId forwarding has been removed by this PR. The sub-agent now gets appId from the verified JWT payload instead. Consider updating to: "When subAgentId is set, this is an internal A2A call — skip app credential auth. The sub-agent authenticates via its own service token (which carries appId in the JWT)."

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

// Parent agents already have appPrompt set at auth time.
if (!config.executionContext.metadata?.appPrompt && config.executionContext.metadata?.appId) {
// Resolve appPrompt from DB using tenant-scoped lookup.
if (config.executionContext.metadata?.appId) {
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.

This now re-fetches the app from the database on every task handler invocation, even when appPrompt was already resolved earlier in the delegation chain. Correct from a security standpoint (ensures freshness + tenant scoping), but worth noting the added DB query per handler invocation. If this becomes a hot path, a short-lived in-memory cache keyed by (tenantId, appId) could help — not a blocker, just flagging.

@github-actions github-actions bot deleted a comment from claude bot Apr 2, 2026
@tim-inkeep
Copy link
Copy Markdown
Contributor Author

@claude @pullfrog, please review this PR

@inkeep-internal-ci
Copy link
Copy Markdown
Contributor

inkeep-internal-ci bot commented Apr 2, 2026

Claude finished @tim-inkeep's task in 1m 10s —— View job


PR Review: App Prompt Security Vulnerability Fix

  • Read changed files and gather context
  • Review security fix implementation
  • Review test coverage
  • Post review findings

Summary

This is a well-executed security fix that closes a real vulnerability. The core problem — appId being forwardable via an untrusted HTTP header (x-inkeep-app-id) during A2A delegation — is correctly addressed by embedding it in the signed JWT service token. The change to always re-resolve appPrompt from the database with tenant scoping eliminates a prompt injection vector.

Security Analysis: Looks Good

What was vulnerable:

  1. Any caller with a valid API key could set x-inkeep-app-id to spoof app context
  2. In runAuth.ts, the fallback if (reqData.appId && !attempt.authResult.metadata?.appId) would blindly trust the header-supplied appId
  3. appPrompt was cached in auth metadata and trusted downstream without re-validation

What the fix does correctly:

  • appId now travels exclusively through signed JWT claims (service-token-auth.ts:24-25, 41, 64, 115, 124)
  • tryTeamAgentAuth extracts appId from the verified JWT payload (runAuth.ts:448)
  • The header-based appId fallback in runApiKeyAuthHandler is removed (both dev and prod paths)
  • x-inkeep-app-id is no longer set in outgoing A2A headers (executionHandler.ts — removed lines 308-316)
  • appPrompt is removed from auth layer (runAuth.ts:779, 851 — no longer sets appPrompt)
  • generateTaskHandler.ts:92 now unconditionally resolves appPrompt with getAppByIdForTenant (tenant-scoped)

Issues Found

1. Stale comment at runAuth.ts:899-900 (already flagged by Pullfrog)

The comment now reads:

// The sub-agent authenticates via its own service token (which carries appId in the JWT).

This is actually already updated in this PR — the old comment "The appId is forwarded for context only" was replaced. This looks correct now.

2. x-inkeep-app-id header is still extracted but partially vestigial

At runAuth.ts:83, appId is still extracted from the header:

const appId = c.req.header('x-inkeep-app-id');

And at runAuth.ts:901, it's still used to gate the app credential auth path:

if (reqData.appId && !subAgentId) {

This is correct behavior — the header is still needed for the initial app credential auth flow (where a web client sends x-inkeep-app-id to identify which app they're authenticating as). The vulnerability was specifically about A2A forwarding of this header, which is now fixed. Just wanted to confirm this is intentional.

3. Missing changeset

The changeset bot flagged this PR has no changeset. Since this modifies agents-api and agents-core runtime behavior (security fix), a patch changeset is warranted:

pnpm bump patch --pkg agents-core --pkg agents-api "Fix appId spoofing vulnerability by propagating appId via signed JWT instead of headers"

4. Minor: appId type safety in verifyServiceToken

At service-token-auth.ts:115:

const appId = typeof payload.appId === 'string' ? payload.appId : undefined;

Good defensive check — consistent with how initiatedBy is handled. No issue here.

Test Coverage: Solid

The tests comprehensively cover the new behavior:

  • service-token-auth.test.ts: Round-trip tests for appId presence and absence in JWT
  • runAuth-appPrompt.test.ts: Verifies appPrompt is NOT set at auth time, appId comes from JWT in team delegation, and header-supplied appId is ignored for regular API key auth (the 'attacker-supplied-app-id' test at line 229 is a nice touch)
  • generateTaskHandler.test.ts: Verifies tenant-scoped DB lookup, re-resolution even when already set, and graceful failure handling

Verdict

Approve. This is a clean, well-scoped security fix with good test coverage. The only action item is adding a changeset before merge.


| Branch: bugfix/app-prompt-security-vulnerability

Copy link
Copy Markdown
Contributor

@pullfrog pullfrog bot left a comment

Choose a reason for hiding this comment

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

Solid security hardening. This PR closes two real attack vectors: (1) an untrusted x-inkeep-app-id header being blindly copied into metadata.appId for non-app-credential auth paths, and (2) a client-supplied appPrompt being trusted instead of always re-resolving from DB. The appId propagation via signed JWT is the right pattern.

Two minor items flagged below — one potential issue with the tryAppCredentialAuth lookup and one nit about redundant header reading.

Pullfrog  | View workflow run | Using Claude Opus𝕏


// When subAgentId is set, this is an internal A2A call — skip app credential auth.
// The appId is forwarded for context only; the sub-agent authenticates via its own token.
// The sub-agent authenticates via its own service token (which carries appId in the JWT).
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.

The x-inkeep-app-id header is still read into reqData.appId (line 83) and still used here to gate entry into tryAppCredentialAuth. For the A2A path (subAgentId is set), this correctly skips app credential auth and relies on the JWT. However, an external caller could send x-inkeep-app-id + x-inkeep-sub-agent-id headers together with an API key to bypass the tryAppCredentialAuth path and still have reqData.appId floating around in the request data.

This is safe now because reqData.appId is no longer copied into metadata anywhere — but you could tighten this further by clearing reqData.appId when it doesn't come from a verified source (i.e., when it's just a header value and the auth strategy is not app-credential). Not blocking, just defense-in-depth.

// Parent agents already have appPrompt set at auth time.
if (!config.executionContext.metadata?.appPrompt && config.executionContext.metadata?.appId) {
// Resolve appPrompt from DB using tenant-scoped lookup.
if (config.executionContext.metadata?.appId) {
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.

Previously this had !config.executionContext.metadata?.appPrompt && as a guard, meaning it would skip the DB lookup if appPrompt was already set. Removing that guard is the right call for security — it ensures the prompt always comes from the DB, not from anything an upstream caller could have injected. Worth noting this adds one DB query per task handler invocation when appId is set, even when the prompt hasn't changed. If this becomes a hot path concern, consider a short TTL cache keyed on (appId, tenantId) — but that's a future optimization, not a blocker.

originAgentId: agentId,
targetAgentId: currentAgentId,
initiatedBy,
appId: executionContext.metadata?.appId,
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.

Clean. appId is now propagated through the signed JWT claim rather than the x-inkeep-app-id header, and the header forwarding (...(appId ? { 'x-inkeep-app-id': appId } : {})) is removed at line 315. This ensures sub-agents can only receive an appId that was cryptographically verified by the parent's service token.

Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

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

PR Review Summary

(0) Total Issues | Risk: Low

This PR correctly fixes a security vulnerability where appId (and by extension appPrompt) could be spoofed via the x-inkeep-app-id header during A2A forwarding. The fix is comprehensive and well-implemented.

✅ Security Assessment

The security fix is complete and correctly implemented:

  1. appId is now cryptographically protected — Embedded in signed JWT service tokens using HMAC-SHA256, eliminating header-based spoofing
  2. Header forwarding removed — The x-inkeep-app-id header is no longer propagated in A2A delegation calls
  3. Tenant-scoped lookup enforcedappPrompt resolution uses getAppByIdForTenant which requires matching tenantId, preventing cross-tenant prompt injection
  4. Fresh resolution on every request — Changed from if (!appPrompt && appId) to if (appId), ensuring stale/malicious prompts in metadata are always overwritten

💭 Consider

💭 1) runAuth-appPrompt.test.ts Add explicit cross-tenant attack prevention test

Issue: The test suite verifies header-based appId is blocked, but lacks an explicit test verifying that a service token JWT with an appId from a different tenant results in the tenant-scoped lookup returning null.

Why: While the code correctly uses getAppByIdForTenant with tenant scoping, the tests mock the DB layer and don't explicitly exercise this security property. An explicit test would catch regressions if tenant scoping is accidentally bypassed.

Fix: Consider adding a test that mocks getAppByIdForTenant to return undefined for cross-tenant lookups and verifies the handler continues without appPrompt:

it('should continue without appPrompt when app belongs to different tenant', async () => {
  const mockGetApp = vi.fn().mockResolvedValue(undefined);
  (getAppByIdForTenant as any).mockReturnValue(mockGetApp);
  // ... verify appPrompt remains undefined
});

Refs: generateTaskHandler.test.ts:1134

🕐 Pending Recommendations (1)


✅ APPROVE

Summary: This is a solid, well-implemented security fix. The vulnerability is correctly addressed by moving appId into the signed JWT service token and enforcing tenant-scoped lookups. The code is clean, tests are comprehensive for the core security scenarios, and the prior review feedback was addressed. Ship it! 🚀

Discarded (0)

No findings discarded.

Reviewers (3)
Reviewer Returned Main Findings Consider While You're Here Inline Comments Pending Recs Discarded
pr-review-security-iam 0 0 0 0 0 0 0
pr-review-standards 0 0 0 0 0 0 0
pr-review-tests 4 0 1 0 0 0 3
Total 4 0 1 0 0 0 3

Note: 3 test coverage suggestions from pr-review-tests were assessed as LOW confidence / low-signal and discarded (edge case validation, integration test coverage, appId format validation).

@github-actions github-actions bot deleted a comment from claude bot Apr 2, 2026
Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

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

PR Review Summary

(0) Total Issues | Risk: Low

This re-review is scoped to the delta since the last automated review. The only change is a merge from main (commit 544b60f3f) which brought in PR #2964 (copilot auth routing refactor). No new PR-specific code was introduced — the security fix remains exactly as previously approved.

✅ Delta Verified

The merge from main cleanly integrated with this PR:

  • Removed tryTempJwtAuth function (moved to dedicated app credential path in #2964)
  • Removed verifyTempToken import and isCopilotAgent utility
  • Auth flow order adjusted accordingly

These changes are orthogonal to the security fix. The core security properties remain intact:

  1. appId cryptographically protected — Embedded in signed JWT service tokens
  2. JWT-based agent identityagentId from JWT sub, subAgentId from JWT aud
  3. Tenant-scoped lookupgetAppByIdForTenant requires matching tenantId
  4. Header forwarding removedx-inkeep-app-id no longer propagated

🕐 Pending Recommendations (1)

  • 🟡 generateTaskHandler.ts:92 Performance consideration: DB query per handler invocation when appId is set (future optimization, not blocking)

✅ APPROVE

Summary: The merge from main integrated cleanly. All security properties from the previous approved review remain intact. Ready to ship! 🚀

Discarded (0)

No new findings to discard.

Reviewers (0)
Reviewer Returned Main Findings Consider While You're Here Inline Comments Pending Recs Discarded
Total 0 0 0 0 0 0 0

Note: No sub-reviewers dispatched for this delta — the only change was a merge from main with no new PR-specific code.

@vercel vercel bot temporarily deployed to Preview – agents-docs April 3, 2026 16:03 Inactive
Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

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

PR Review Summary

(1) Total Issues | Risk: Low

This re-review is scoped to the delta since the last automated review (2 commits, 3 files, +32/-17 lines). The changes strengthen the security fix.

✅ Delta Changes Verified

The delta changes are correct and improve security:

  1. Project-scoped lookup — Changed from getAppByIdForTenant to getAppByIdForProject. This is a stricter security boundary — apps are now validated against both tenantId AND projectId, preventing cross-project prompt injection within the same tenant.

  2. Tests updated — Test assertions now verify the project-scoped lookup receives both tenantId and projectId in the scopes parameter.

  3. Changeset added — Properly documents the security fix for release notes (minor typo flagged as inline comment).

🟡 Minor (1) 🟡

Inline Comments:

  • 🟡 Minor: .changeset/nice-candles-wonder.md:6 Typo: "Vulerability" → "Vulnerability"

🕐 Pending Recommendations (1)

  • 🟡 generateTaskHandler.ts:92 Performance consideration: DB query per handler invocation when appId is set (future optimization, not blocking)

✅ APPROVE

Summary: The delta strengthens the security fix by adding project-level scoping on top of tenant-level scoping. This is a strictly better security boundary. The only issue is a typo in the changeset message. Ship it! 🚀

Discarded (0)

No findings discarded.

Reviewers (0)
Reviewer Returned Main Findings Consider While You're Here Inline Comments Pending Recs Discarded
Total 0 0 0 0 0 0 0

Note: No sub-reviewers dispatched — the delta was small (3 files, +32/-17 lines) and only strengthened the existing security fix with stricter scoping.

"@inkeep/agents-api": patch
---

Bugfix App Prompt Security Vulerability
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.

🟡 Minor: Typo in changeset message

Issue: "Vulerability" should be "Vulnerability".

Why: Changeset messages appear in release notes and should be spelled correctly.

Fix: (1-click apply)

Suggested change
Bugfix App Prompt Security Vulerability
Bugfix App Prompt Security Vulnerability

@github-actions github-actions bot deleted a comment from claude bot Apr 3, 2026
@tim-inkeep
Copy link
Copy Markdown
Contributor Author

@pullfrog, tell me do any other security vulnerabilities exist in the code i changed?

Copy link
Copy Markdown
Contributor

@pullfrog pullfrog bot left a comment

Choose a reason for hiding this comment

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

Security Audit: Residual Vulnerabilities in Changed Code

This PR does a solid job closing the primary attack vectors — moving agentId/subAgentId derivation to the cryptographically verified JWT, scoping the appPrompt lookup to getAppByIdForProject, and propagating appId through the service token instead of headers. Three residual issues remain in the changed code, ordered by severity.

Severity: 1 medium, 2 low. None are critical, but the medium one is worth addressing before merge.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

@@ -108,6 +112,7 @@ export async function verifyServiceToken(token: string): Promise<VerifyServiceTo
}

const initiatedBy = payload.initiatedBy as { type: 'user' | 'api_key'; id: string } | undefined;
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.

[Medium — trust-boundary gap] initiatedBy is blindly cast without validating its shape.

The appId field on the next line correctly uses typeof ... === 'string' to validate, but initiatedBy is just cast via as. A malformed service token with initiatedBy: { type: "admin", id: "..."} or initiatedBy: "garbage" passes through unchecked.

Since initiatedBy.type flows into runAsUserId logic (executionHandler.ts:301-306 — only type === 'user' is accepted) and audit trails, a crafted token could inject unexpected values into metadata. The service token is signed so this requires a compromised signing key, but defense-in-depth says validate at the boundary.

Suggested fix — validate shape before accepting, same pattern as appId:

const initiatedBy =
  payload.initiatedBy &&
  typeof payload.initiatedBy === 'object' &&
  (payload.initiatedBy.type === 'user' || payload.initiatedBy.type === 'api_key') &&
  typeof payload.initiatedBy.id === 'string'
    ? (payload.initiatedBy as { type: 'user' | 'api_key'; id: string })
    : undefined;

// When subAgentId is set, this is an internal A2A call — skip app credential auth.
// The appId is forwarded for context only; the sub-agent authenticates via its own token.
// The sub-agent authenticates via its own service token (which carries appId in the JWT).
if (reqData.appId && !subAgentId) {
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.

[Low — auth routing confusion] x-inkeep-app-id header can force auth strategy selection.

An external caller with a valid database API key can attach x-inkeep-app-id: <any-real-app-id> (without x-inkeep-sub-agent-id) to force the request into tryAppCredentialAuth. The API key is then treated as a bearer token for JWT verification, which fails — the request is rejected rather than falling through to tryApiKeyAuth.

This is pre-existing (not introduced by this PR) and low severity (DoS of your own request, not privilege escalation), but worth noting since the auth-routing code was touched. A future hardening pass could attempt remaining strategies if tryAppCredentialAuth returns authResult: null.

apiKeyId: authResult.apiKeyId,
baseUrl: reqData.baseUrl,
subAgentId: reqData.subAgentId,
subAgentId,
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.

[Low — observation] subAgentId is no longer set from reqData.subAgentId for non-team-delegation paths.

Before this PR, subAgentId was always set from reqData.subAgentId (the x-inkeep-sub-agent-id header). Now it's only set in the teamDelegation branch from the verified JWT. This is a security improvement — external callers can no longer inject a subAgentId into their execution context by sending the header.

However, confirm that no legitimate non-team-delegation code path relies on subAgentId being set in the execution context. The header is still read and used for auth routing (authenticateRequest at line 814, tryTeamAgentAuth at line 848), but it no longer flows into the execution context for non-team paths. This looks intentional and correct — flagging for visibility.

Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

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

PR Review Summary

(0) Total Issues | Risk: Low

This re-review is scoped to the delta since the last automated review (1 commit, 1 file, +9/-1 lines). The change directly addresses pullfrog's Medium severity finding.

✅ Delta Verified

The single change in service-token-auth.ts:114-122 is correct and complete:

Before:

const initiatedBy = payload.initiatedBy as { type: 'user' | 'api_key'; id: string } | undefined;

After:

const initiatedBy =
  payload.initiatedBy &&
  typeof payload.initiatedBy === 'object' &&
  'type' in payload.initiatedBy &&
  'id' in payload.initiatedBy &&
  (payload.initiatedBy.type === 'user' || payload.initiatedBy.type === 'api_key') &&
  typeof payload.initiatedBy.id === 'string'
    ? (payload.initiatedBy as { type: 'user' | 'api_key'; id: string })
    : undefined;

Security assessment:

  1. Addresses pullfrog's Medium findinginitiatedBy is no longer blindly cast; it's now properly validated for shape
  2. Defense-in-depth — Even though the service token is signed (requiring a compromised key to exploit), validating at the boundary is correct practice
  3. Consistent pattern — Uses the same validation pattern as appId on line 123
  4. Correct type narrowing — Validates object type, key presence, enum values, and string id
  5. Graceful fallback — Returns undefined if validation fails, maintaining backward compatibility

🕐 Pending Recommendations (2)


✅ APPROVE

Summary: The delta correctly addresses the Medium severity finding from pullfrog's security audit. The initiatedBy field is now properly validated instead of blindly cast, closing the trust-boundary gap. All prior security properties remain intact. The only outstanding items are a typo in the changeset message and a future performance optimization — neither blocking. Ship it! 🎉

Discarded (0)

No findings discarded.

Reviewers (0)
Reviewer Returned Main Findings Consider While You're Here Inline Comments Pending Recs Discarded
Total 0 0 0 0 0 0 0

Note: No sub-reviewers dispatched — the delta was minimal (1 file, +9/-1 lines) and directly addressed a prior finding.

@github-actions github-actions bot deleted a comment from claude bot Apr 3, 2026
@tim-inkeep tim-inkeep closed this Apr 3, 2026
@tim-inkeep tim-inkeep reopened this Apr 3, 2026
@tim-inkeep tim-inkeep enabled auto-merge April 3, 2026 17:04
@tim-inkeep tim-inkeep added this pull request to the merge queue Apr 3, 2026
@github-merge-queue github-merge-queue bot removed this pull request from the merge queue due to failed status checks Apr 3, 2026
@tim-inkeep tim-inkeep added this pull request to the merge queue Apr 3, 2026
@github-merge-queue github-merge-queue bot removed this pull request from the merge queue due to failed status checks Apr 3, 2026
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