diff --git a/mocks/handlers.stackone-rpc.ts b/mocks/handlers.stackone-rpc.ts index 804e9c2..496899e 100644 --- a/mocks/handlers.stackone-rpc.ts +++ b/mocks/handlers.stackone-rpc.ts @@ -20,6 +20,13 @@ export const stackoneRpcHandlers = [ const body = (await request.json()) as { action?: string; body?: Record; + defender_enabled?: boolean; + defender_config?: { + enabled?: boolean; + block_high_risk?: boolean; + use_tier1_classification?: boolean; + use_tier2_classification?: boolean; + }; headers?: Record; path?: Record; query?: Record; @@ -70,12 +77,14 @@ export const stackoneRpcHandlers = [ ); } - // Default response for other actions + // Default response for other actions — echo back received fields return HttpResponse.json({ data: { action: body.action, received: { body: body.body, + defender_config: body.defender_config, + defender_enabled: body.defender_enabled, headers: body.headers, path: body.path, query: body.query, diff --git a/src/index.ts b/src/index.ts index f890e59..6a1671c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,9 +29,12 @@ export { type SemanticSearchResult, } from './semantic-search'; +export { DEFAULT_DEFENDER_CONFIG } from './types'; + export type { AISDKToolDefinition, AISDKToolResult, + DefenderConfig, ExecuteConfig, ExecuteOptions, JsonObject, diff --git a/src/rpc-client.test.ts b/src/rpc-client.test.ts index 6d69f15..1846bb5 100644 --- a/src/rpc-client.test.ts +++ b/src/rpc-client.test.ts @@ -128,3 +128,50 @@ test('should send x-account-id as HTTP header', async () => { bodyHeader: 'test-account-123', }); }); + +test('should forward defender_enabled: true in request payload', async () => { + const client = new RpcClient({ + serverURL: TEST_BASE_URL, + security: { username: 'test-api-key' }, + }); + + const response = await client.actions.rpcAction({ + action: 'custom_action', + defender_enabled: true, + }); + + expect(response.data).toMatchObject({ + received: { defender_enabled: true }, + }); +}); + +test('should forward defender_enabled: false in request payload', async () => { + const client = new RpcClient({ + serverURL: TEST_BASE_URL, + security: { username: 'test-api-key' }, + }); + + const response = await client.actions.rpcAction({ + action: 'custom_action', + defender_enabled: false, + }); + + expect(response.data).toMatchObject({ + received: { defender_enabled: false }, + }); +}); + +test('should omit defender_enabled from payload when not provided', async () => { + const client = new RpcClient({ + serverURL: TEST_BASE_URL, + security: { username: 'test-api-key' }, + }); + + const response = await client.actions.rpcAction({ + action: 'custom_action', + }); + + expect((response.data as Record).received).not.toHaveProperty( + 'defender_enabled', + ); +}); diff --git a/src/rpc-client.ts b/src/rpc-client.ts index ceb90dc..5b34931 100644 --- a/src/rpc-client.ts +++ b/src/rpc-client.ts @@ -50,10 +50,16 @@ export class RpcClient { const requestBody = { action: validatedRequest.action, body: validatedRequest.body, + ...(validatedRequest.defender_enabled !== undefined && { + defender_enabled: validatedRequest.defender_enabled, + }), + ...(validatedRequest.defender_config !== undefined && { + defender_config: validatedRequest.defender_config, + }), headers: validatedRequest.headers, path: validatedRequest.path, query: validatedRequest.query, - } as const satisfies RpcActionRequest; + } satisfies RpcActionRequest; // Forward StackOne-specific headers as HTTP headers const requestHeaders = validatedRequest.headers; diff --git a/src/schema.ts b/src/schema.ts index 17f7f80..a15beeb 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -1,6 +1,16 @@ import { z } from 'zod/v4-mini'; import { stackOneHeadersSchema } from './headers'; +/** + * Zod schema for nested defender configuration sent with each RPC request + */ +const defenderConfigRequestSchema = z.object({ + enabled: z.optional(z.boolean()), + block_high_risk: z.optional(z.boolean()), + use_tier1_classification: z.optional(z.boolean()), + use_tier2_classification: z.optional(z.boolean()), +}); + /** * Zod schema for RPC action request validation * @see https://docs.stackone.com/platform/api-reference/actions/make-an-rpc-call-to-an-action @@ -8,6 +18,9 @@ import { stackOneHeadersSchema } from './headers'; export const rpcActionRequestSchema = z.object({ action: z.string(), body: z.optional(z.record(z.string(), z.unknown())), + /** @deprecated use defender_config instead */ + defender_enabled: z.optional(z.boolean()), + defender_config: z.optional(defenderConfigRequestSchema), headers: z.optional(stackOneHeadersSchema), path: z.optional(z.record(z.string(), z.unknown())), query: z.optional(z.record(z.string(), z.unknown())), diff --git a/src/toolsets.test.ts b/src/toolsets.test.ts index 8021c70..142971e 100644 --- a/src/toolsets.test.ts +++ b/src/toolsets.test.ts @@ -513,6 +513,87 @@ describe('StackOneToolSet', () => { }); }); + describe('defender config', () => { + it('should store defender config from constructor', () => { + const toolset = new StackOneToolSet({ + apiKey: 'test-key', + defender: { enabled: false }, + }); + + // @ts-expect-error - Accessing private property for testing + expect(toolset.defenderConfig).toEqual({ enabled: false }); + }); + + it('should set defenderConfig to SDK defaults when not provided', () => { + const toolset = new StackOneToolSet({ apiKey: 'test-key' }); + + // @ts-expect-error - Accessing private property for testing + expect(toolset.defenderConfig).toEqual({ + enabled: true, + blockHighRisk: false, + useTier1Classification: true, + useTier2Classification: true, + }); + }); + + it('should include defender_config in dryRun payload when defender.enabled is set', async () => { + const toolset = new StackOneToolSet({ + baseUrl: TEST_BASE_URL, + apiKey: 'test-key', + accountId: 'test-account', + defender: { enabled: false }, + }); + + const tools = await toolset.fetchTools(); + const tool = tools.toArray().find((t) => t.name === 'dummy_action'); + assert(tool, 'tool should be defined'); + + const result = await tool.execute({ body: { name: 'test' } }, { dryRun: true }); + + const parsedBody = JSON.parse(result.body as string); + expect(parsedBody.defender_config.enabled).toBe(false); + }); + + it('should include SDK default defender_config in dryRun payload when defender config is not set', async () => { + const toolset = new StackOneToolSet({ + baseUrl: TEST_BASE_URL, + apiKey: 'test-key', + accountId: 'test-account', + }); + + const tools = await toolset.fetchTools(); + const tool = tools.toArray().find((t) => t.name === 'dummy_action'); + assert(tool, 'tool should be defined'); + + const result = await tool.execute({ body: { name: 'test' } }, { dryRun: true }); + + const parsedBody = JSON.parse(result.body as string); + expect(parsedBody.defender_config.enabled).toBe(true); + expect(parsedBody.defender_config.block_high_risk).toBe(false); + expect(parsedBody.defender_config.use_tier1_classification).toBe(true); + expect(parsedBody.defender_config.use_tier2_classification).toBe(true); + }); + + it('should forward defender_config in live RPC call when defender.enabled is set', async () => { + const toolset = new StackOneToolSet({ + baseUrl: TEST_BASE_URL, + apiKey: 'test-key', + accountId: 'test-account', + defender: { enabled: true }, + }); + + const tools = await toolset.fetchTools(); + const tool = tools.toArray().find((t) => t.name === 'dummy_action'); + assert(tool, 'tool should be defined'); + + const result = await tool.execute({ body: { name: 'test' } }); + + expect(result).toMatchObject({ + data: { received: { defender_config: { enabled: true } } }, + }); + }); + }); + describe('provider and action filtering', () => { it('filters tools by providers', async () => { const toolset = new StackOneToolSet({ diff --git a/src/toolsets.ts b/src/toolsets.ts index 18a1324..3795f9b 100644 --- a/src/toolsets.ts +++ b/src/toolsets.ts @@ -13,6 +13,7 @@ import { } from './semantic-search'; import { BaseTool, Tools } from './tool'; import type { + DefenderConfig, ExecuteOptions, JsonObject, JsonSchemaProperties, @@ -20,6 +21,7 @@ import type { SearchConfig, ToolParameters, } from './types'; +import { DEFAULT_DEFENDER_CONFIG } from './types'; import { StackOneError } from './utils/error-stackone'; import { normalizeActionName } from './utils/normalize'; @@ -141,6 +143,15 @@ interface StackOneToolSetBaseConfig extends BaseToolSetConfig { * Per-call options always override these defaults. */ search?: SearchConfig | null; + /** + * Defender configuration. Controls prompt injection detection behavior for all tool calls. + * + * - Omit or pass `undefined` → SDK defaults apply: defender enabled, outputs never blocked + * - Pass `null` → defender explicitly disabled for all tool calls + * - Pass `{ useProjectSettings: true }` → defer to the project settings configured in the dashboard + * - Pass `{ enabled, blockHighRisk, ... }` → explicit SDK-level config, ignores project settings + */ + defender?: DefenderConfig | null; } /** @@ -261,6 +272,7 @@ export class StackOneToolSet { private headers: Record; private rpcClient?: RpcClient; private readonly searchConfig: SearchConfig | null; + private readonly defenderConfig: DefenderConfig | null; /** * Account ID for StackOne API @@ -320,6 +332,35 @@ export class StackOneToolSet { // Resolve search config: undefined → defaults, null → disabled, object → custom this.searchConfig = config?.search === null ? null : { method: 'auto', ...config?.search }; + // Resolve defender config: + // undefined → SDK defaults (enabled, not blocking) + // null → explicitly disabled + // object → validate then store as-is + const defenderInput = config?.defender; + if ( + defenderInput != null && + 'useProjectSettings' in defenderInput && + defenderInput.useProjectSettings === true + ) { + const { useProjectSettings: _, ...rest } = defenderInput as { + useProjectSettings: true; + } & Record; + if (Object.keys(rest).length > 0) { + throw new ToolSetConfigError( + 'Cannot combine useProjectSettings: true with explicit defender options. Use one or the other.', + ); + } + } + this.defenderConfig = defenderInput === undefined ? DEFAULT_DEFENDER_CONFIG : defenderInput; + if (defenderInput === undefined) { + console.warn( + '[StackOneToolSet] No defender config provided. SDK defaults are active and will override any ' + + 'project-level defender settings. To use your project settings, pass ' + + '`defender: { useProjectSettings: true }`. To suppress this warning, pass an explicit ' + + '`defender` config.', + ); + } + // Set Authentication headers if provided if (this.authentication) { // Only set auth headers if they don't already exist in custom headers @@ -944,10 +985,43 @@ export class StackOneToolSet { rpcBody[key] = value as JsonObject[string]; } + const defender = this.defenderConfig; + let defenderFields: Partial<{ + defender_config: { + enabled: boolean; + block_high_risk: boolean; + use_tier1_classification: boolean; + use_tier2_classification: boolean; + }; + }> = {}; + if (defender === null) { + // null → explicitly disable + defenderFields = { + defender_config: { + enabled: false, + block_high_risk: false, + use_tier1_classification: false, + use_tier2_classification: false, + }, + }; + } else if (!('useProjectSettings' in defender) || !defender.useProjectSettings) { + // SDK-level config (default or explicit) + defenderFields = { + defender_config: { + enabled: defender.enabled ?? true, + block_high_risk: defender.blockHighRisk ?? false, + use_tier1_classification: defender.useTier1Classification ?? true, + use_tier2_classification: defender.useTier2Classification ?? true, + }, + }; + } + // else: useProjectSettings: true → send nothing, backend uses project settings + if (options?.dryRun) { const requestPayload = { action: name, body: rpcBody, + ...defenderFields, headers: actionHeaders, path: pathParams ?? undefined, query: queryParams ?? undefined, @@ -965,6 +1039,7 @@ export class StackOneToolSet { const response = await actionsClient.actions.rpcAction({ action: name, body: rpcBody, + ...defenderFields, headers: actionHeaders, path: pathParams ?? undefined, query: queryParams ?? undefined, diff --git a/src/types.ts b/src/types.ts index ba3ebf5..0f672d5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -234,3 +234,42 @@ export interface ClaudeAgentSdkOptions { */ serverVersion?: string; } + +/** + * Defender configuration for controlling prompt injection detection behavior. + * Field names match the canonical `DefenderSettings` from `@stackone/core`. + * + * Three modes: + * - `{ useProjectSettings: true }` — defer to whatever is configured in the project dashboard. + * No other fields may be set alongside this (TypeScript enforces it; a runtime error is also thrown). + * - An explicit config object (or omitting `defender` entirely) — the SDK owns the defender + * settings and sends them with every RPC call, ignoring any project-level config. + * - `null` passed as the `defender` option — defender is explicitly disabled for all tool calls. + */ +export type DefenderConfig = + | { useProjectSettings: true } + | { + useProjectSettings?: false; + /** Whether to run defender at all. Default: `true`. */ + enabled?: boolean; + /** + * Whether to block tool execution when a HIGH risk score is detected. + * Default: `false` (scan and annotate, but do not block). + */ + blockHighRisk?: boolean; + /** Whether to enable tier 1 pattern-based (regex) detection. Default: `true`. */ + useTier1Classification?: boolean; + /** Whether to enable tier 2 ML-based detection. Default: `true`. */ + useTier2Classification?: boolean; + }; + +/** + * SDK-level defender defaults applied when no explicit `defender` config is passed. + * Defender is enabled but outputs are never blocked — scans run and results are annotated only. + */ +export const DEFAULT_DEFENDER_CONFIG = { + enabled: true, + blockHighRisk: false, + useTier1Classification: true, + useTier2Classification: true, +} as const;