From 5727cfd84f555ccca33c197058951e0397d2024b Mon Sep 17 00:00:00 2001 From: Hisku Date: Thu, 19 Mar 2026 11:15:58 +0000 Subject: [PATCH 01/22] feat: add defender config option to StackOneToolSet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `defender` option to the `StackOneToolSet` constructor that allows controlling prompt injection detection behavior at the toolset level. - Add `DefenderConfig` interface with `enabled`, `blockHighRisk`, `useTier1Classification`, and `useTier2Classification` fields — matching canonical `DefenderSettings` names from `@stackone/core` - Add `defender_enabled` to `rpcActionRequestSchema` so it is no longer silently dropped by Zod validation - Forward `defender_enabled` through `RpcClient.rpcAction()` request body - Thread `defenderConfig.enabled` → `defender_enabled` in every RPC call made by `createRpcBackedTool` - Export `DefenderConfig` from the package index Co-Authored-By: Claude Sonnet 4.6 --- src/index.ts | 1 + src/rpc-client.ts | 5 ++++- src/schema.ts | 1 + src/toolsets.ts | 14 ++++++++++++++ src/types.ts | 30 ++++++++++++++++++++++++++++++ 5 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index da37d4ca..a04765cb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,6 +33,7 @@ export { export type { AISDKToolDefinition, AISDKToolResult, + DefenderConfig, ExecuteConfig, ExecuteOptions, JsonObject, diff --git a/src/rpc-client.ts b/src/rpc-client.ts index bc2f88a5..98c2ef66 100644 --- a/src/rpc-client.ts +++ b/src/rpc-client.ts @@ -52,10 +52,13 @@ export class RpcClient { const requestBody = { action: validatedRequest.action, body: validatedRequest.body, + ...(validatedRequest.defender_enabled !== undefined + ? { defender_enabled: validatedRequest.defender_enabled } + : {}), 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 4e9ccac2..3049eb53 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -8,6 +8,7 @@ import { stackOneHeadersSchema } from './headers'; export const rpcActionRequestSchema = z.object({ action: z.string(), body: z.optional(z.record(z.string(), z.unknown())), + defender_enabled: z.optional(z.boolean()), 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.ts b/src/toolsets.ts index 3642a5e0..591e2a01 100644 --- a/src/toolsets.ts +++ b/src/toolsets.ts @@ -14,6 +14,7 @@ import { } from './semantic-search'; import { BaseTool, Tools } from './tool'; import type { + DefenderConfig, ExecuteOptions, JsonObject, JsonSchemaProperties, @@ -163,6 +164,13 @@ interface StackOneToolSetBaseConfig extends BaseToolSetConfig { * Pass `{ accountIds: ['acc-1'] }` to scope tools to specific accounts. */ execute?: ExecuteToolsConfig; + /** + * Defender configuration. Controls prompt injection detection behavior. + * Overrides the project-level defender settings for all tool calls made by this toolset. + * + * - Omit or pass `undefined` → uses the project defender settings + */ + defender?: DefenderConfig; } /** @@ -459,6 +467,7 @@ export class StackOneToolSet { private readonly timeout: number; private readonly searchConfig: SearchConfig | null; private readonly executeConfig: ExecuteToolsConfig | undefined; + private readonly defenderConfig?: DefenderConfig; /** * Account ID for StackOne API @@ -520,6 +529,8 @@ export class StackOneToolSet { this.searchConfig = config?.search != null ? { method: 'auto', ...config.search } : null; this.executeConfig = config?.execute; + this.defenderConfig = config?.defender; + // Set Authentication headers if provided if (this.authentication) { // Only set auth headers if they don't already exist in custom headers @@ -1268,6 +1279,9 @@ export class StackOneToolSet { const response = await actionsClient.actions.rpcAction({ action: name, body: rpcBody, + ...(this.defenderConfig?.enabled !== undefined + ? { defender_enabled: this.defenderConfig.enabled } + : {}), headers: actionHeaders, path: pathParams ?? undefined, query: queryParams ?? undefined, diff --git a/src/types.ts b/src/types.ts index ba3ebf55..94033ea9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -234,3 +234,33 @@ export interface ClaudeAgentSdkOptions { */ serverVersion?: string; } + +/** + * Defender configuration for controlling prompt injection detection behavior. + * Field names match the canonical `DefenderSettings` from `@stackone/core`. + * + * Note: only `enabled` is applied per-request via the `defender_enabled` API field. + * The remaining fields are included for forward compatibility and documentation. + */ +export interface DefenderConfig { + /** + * Whether to enable defender. Maps to `defender_enabled` in the RPC request. + * Defaults to the project setting. + */ + enabled?: boolean; + /** + * Whether to block tool execution when a HIGH risk score is detected. + * Defaults to the project setting. + */ + blockHighRisk?: boolean; + /** + * Whether to enable tier 1 pattern-based (regex) detection. + * Defaults to the project setting. + */ + useTier1Classification?: boolean; + /** + * Whether to enable tier 2 ML-based detection. + * Defaults to the project setting. + */ + useTier2Classification?: boolean; +} From c177cf9657d5a15a421292da406ed2c7cb9b5b20 Mon Sep 17 00:00:00 2001 From: Hisku Date: Thu, 19 Mar 2026 11:49:10 +0000 Subject: [PATCH 02/22] fix: address PR review comments on defender config - Include defender_enabled in dryRun payload to match live RPC path - Clarify JSDoc that only enabled is currently applied per-request - Add tests for defender_enabled forwarding in rpc-client and toolsets Co-Authored-By: Claude Sonnet 4.6 --- mocks/handlers.stackone-rpc.ts | 4 +- src/rpc-client.test.ts | 47 ++++++++++++++++++++++ src/toolsets.test.ts | 73 ++++++++++++++++++++++++++++++++++ src/toolsets.ts | 9 ++++- 4 files changed, 131 insertions(+), 2 deletions(-) diff --git a/mocks/handlers.stackone-rpc.ts b/mocks/handlers.stackone-rpc.ts index 804e9c2a..b65ee06b 100644 --- a/mocks/handlers.stackone-rpc.ts +++ b/mocks/handlers.stackone-rpc.ts @@ -20,6 +20,7 @@ export const stackoneRpcHandlers = [ const body = (await request.json()) as { action?: string; body?: Record; + defender_enabled?: boolean; headers?: Record; path?: Record; query?: Record; @@ -70,12 +71,13 @@ export const stackoneRpcHandlers = [ ); } - // Default response for other actions + // Default response for other actions — echo back received fields including defender_enabled return HttpResponse.json({ data: { action: body.action, received: { body: body.body, + defender_enabled: body.defender_enabled, headers: body.headers, path: body.path, query: body.query, diff --git a/src/rpc-client.test.ts b/src/rpc-client.test.ts index 6d69f15f..1846bb5f 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/toolsets.test.ts b/src/toolsets.test.ts index ddca148c..c87606b9 100644 --- a/src/toolsets.test.ts +++ b/src/toolsets.test.ts @@ -513,6 +513,79 @@ 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 leave defenderConfig undefined when not provided', () => { + const toolset = new StackOneToolSet({ apiKey: 'test-key' }); + + // @ts-expect-error - Accessing private property for testing + expect(toolset.defenderConfig).toBeUndefined(); + }); + + it('should include defender_enabled 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_enabled).toBe(false); + }); + + it('should omit defender_enabled from 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).not.toHaveProperty('defender_enabled'); + }); + + it('should forward defender_enabled 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_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 591e2a01..1d735108 100644 --- a/src/toolsets.ts +++ b/src/toolsets.ts @@ -166,7 +166,11 @@ interface StackOneToolSetBaseConfig extends BaseToolSetConfig { execute?: ExecuteToolsConfig; /** * Defender configuration. Controls prompt injection detection behavior. - * Overrides the project-level defender settings for all tool calls made by this toolset. + * + * Currently only `enabled` is applied per-request (mapped to `defender_enabled` in the RPC + * payload). The other fields (`blockHighRisk`, `useTier1Classification`, + * `useTier2Classification`) are defined for forward compatibility and have no effect until + * the backend exposes them as per-request options. * * - Omit or pass `undefined` → uses the project defender settings */ @@ -1262,6 +1266,9 @@ export class StackOneToolSet { const requestPayload = { action: name, body: rpcBody, + ...(this.defenderConfig?.enabled !== undefined + ? { defender_enabled: this.defenderConfig.enabled } + : {}), headers: actionHeaders, path: pathParams ?? undefined, query: queryParams ?? undefined, From 9ffe124b1377300881c186566593cf0f1a4b576c Mon Sep 17 00:00:00 2001 From: Hisku Date: Thu, 19 Mar 2026 15:53:04 +0000 Subject: [PATCH 03/22] feat: rework defender config with useProjectSettings, null disable, and SDK defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace `DefenderConfig` interface with a discriminated union: - `{ useProjectSettings: true }` → defer to project dashboard settings - `{ enabled?, blockHighRisk?, useTier1Classification?, useTier2Classification? }` → SDK-level config - Add `DEFAULT_DEFENDER_CONFIG`: enabled=true, blockHighRisk=false, tier1+tier2 on - Omitting `defender` now applies SDK defaults (enabled, not blocking) rather than deferring to project settings — this makes SDK behavior explicit and predictable - `defender: null` explicitly disables defender for all tool calls - Constructor throws `ToolSetConfigError` if `useProjectSettings: true` is combined with other fields - Extend `rpcActionRequestSchema` and `RpcClient` to forward `block_high_risk`, `use_tier1_classification`, `use_tier2_classification` per-request (backend support for these is tracked separately) - Export `DEFAULT_DEFENDER_CONFIG` from package index Co-Authored-By: Claude Sonnet 4.6 --- src/index.ts | 2 ++ src/rpc-client.ts | 15 ++++++++-- src/schema.ts | 3 ++ src/toolsets.ts | 70 ++++++++++++++++++++++++++++++++++++----------- src/types.ts | 57 ++++++++++++++++++++++---------------- 5 files changed, 104 insertions(+), 43 deletions(-) diff --git a/src/index.ts b/src/index.ts index a04765cb..1a78d801 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,6 +30,8 @@ export { type SemanticSearchResult, } from './semantic-search'; +export { DEFAULT_DEFENDER_CONFIG } from './types'; + export type { AISDKToolDefinition, AISDKToolResult, diff --git a/src/rpc-client.ts b/src/rpc-client.ts index 98c2ef66..19779997 100644 --- a/src/rpc-client.ts +++ b/src/rpc-client.ts @@ -52,9 +52,18 @@ export class RpcClient { const requestBody = { action: validatedRequest.action, body: validatedRequest.body, - ...(validatedRequest.defender_enabled !== undefined - ? { defender_enabled: validatedRequest.defender_enabled } - : {}), + ...(validatedRequest.defender_enabled !== undefined && { + defender_enabled: validatedRequest.defender_enabled, + }), + ...(validatedRequest.block_high_risk !== undefined && { + block_high_risk: validatedRequest.block_high_risk, + }), + ...(validatedRequest.use_tier1_classification !== undefined && { + use_tier1_classification: validatedRequest.use_tier1_classification, + }), + ...(validatedRequest.use_tier2_classification !== undefined && { + use_tier2_classification: validatedRequest.use_tier2_classification, + }), headers: validatedRequest.headers, path: validatedRequest.path, query: validatedRequest.query, diff --git a/src/schema.ts b/src/schema.ts index 3049eb53..f82b7290 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -8,10 +8,13 @@ import { stackOneHeadersSchema } from './headers'; export const rpcActionRequestSchema = z.object({ action: z.string(), body: z.optional(z.record(z.string(), z.unknown())), + block_high_risk: z.optional(z.boolean()), defender_enabled: z.optional(z.boolean()), headers: z.optional(stackOneHeadersSchema), path: z.optional(z.record(z.string(), z.unknown())), query: z.optional(z.record(z.string(), z.unknown())), + use_tier1_classification: z.optional(z.boolean()), + use_tier2_classification: z.optional(z.boolean()), }); /** diff --git a/src/toolsets.ts b/src/toolsets.ts index 1d735108..53fef5de 100644 --- a/src/toolsets.ts +++ b/src/toolsets.ts @@ -23,6 +23,7 @@ import type { SearchConfig, ToolParameters, } from './types'; +import { DEFAULT_DEFENDER_CONFIG } from './types'; import { StackOneError } from './utils/error-stackone'; import { StackOneAPIError } from './utils/error-stackone-api'; import { normalizeActionName } from './utils/normalize'; @@ -165,16 +166,14 @@ interface StackOneToolSetBaseConfig extends BaseToolSetConfig { */ execute?: ExecuteToolsConfig; /** - * Defender configuration. Controls prompt injection detection behavior. + * Defender configuration. Controls prompt injection detection behavior for all tool calls. * - * Currently only `enabled` is applied per-request (mapped to `defender_enabled` in the RPC - * payload). The other fields (`blockHighRisk`, `useTier1Classification`, - * `useTier2Classification`) are defined for forward compatibility and have no effect until - * the backend exposes them as per-request options. - * - * - Omit or pass `undefined` → uses the project defender settings + * - 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; + defender?: DefenderConfig | null; } /** @@ -471,7 +470,7 @@ export class StackOneToolSet { private readonly timeout: number; private readonly searchConfig: SearchConfig | null; private readonly executeConfig: ExecuteToolsConfig | undefined; - private readonly defenderConfig?: DefenderConfig; + private readonly defenderConfig: DefenderConfig | null; /** * Account ID for StackOne API @@ -533,7 +532,20 @@ export class StackOneToolSet { this.searchConfig = config?.search != null ? { method: 'auto', ...config.search } : null; this.executeConfig = config?.execute; - this.defenderConfig = config?.defender; + // 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; // Set Authentication headers if provided if (this.authentication) { @@ -1262,13 +1274,41 @@ export class StackOneToolSet { rpcBody[key] = value as JsonObject[string]; } + const defender = this.defenderConfig; + const defenderFields = + defender === null + ? // null → explicitly disable + { defender_enabled: false } + : defender !== null && + 'useProjectSettings' in defender && + defender.useProjectSettings === true + ? // useProjectSettings: true → send nothing, backend uses project settings + {} + : // SDK-level config (default or explicit) + { + defender_enabled: + defender !== null && !('useProjectSettings' in defender) + ? (defender.enabled ?? true) + : true, + block_high_risk: + defender !== null && !('useProjectSettings' in defender) + ? (defender.blockHighRisk ?? false) + : false, + use_tier1_classification: + defender !== null && !('useProjectSettings' in defender) + ? (defender.useTier1Classification ?? true) + : true, + use_tier2_classification: + defender !== null && !('useProjectSettings' in defender) + ? (defender.useTier2Classification ?? true) + : true, + }; + if (options?.dryRun) { const requestPayload = { action: name, body: rpcBody, - ...(this.defenderConfig?.enabled !== undefined - ? { defender_enabled: this.defenderConfig.enabled } - : {}), + ...defenderFields, headers: actionHeaders, path: pathParams ?? undefined, query: queryParams ?? undefined, @@ -1286,9 +1326,7 @@ export class StackOneToolSet { const response = await actionsClient.actions.rpcAction({ action: name, body: rpcBody, - ...(this.defenderConfig?.enabled !== undefined - ? { defender_enabled: this.defenderConfig.enabled } - : {}), + ...defenderFields, headers: actionHeaders, path: pathParams ?? undefined, query: queryParams ?? undefined, diff --git a/src/types.ts b/src/types.ts index 94033ea9..0f672d56 100644 --- a/src/types.ts +++ b/src/types.ts @@ -239,28 +239,37 @@ export interface ClaudeAgentSdkOptions { * Defender configuration for controlling prompt injection detection behavior. * Field names match the canonical `DefenderSettings` from `@stackone/core`. * - * Note: only `enabled` is applied per-request via the `defender_enabled` API field. - * The remaining fields are included for forward compatibility and documentation. + * 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 interface DefenderConfig { - /** - * Whether to enable defender. Maps to `defender_enabled` in the RPC request. - * Defaults to the project setting. - */ - enabled?: boolean; - /** - * Whether to block tool execution when a HIGH risk score is detected. - * Defaults to the project setting. - */ - blockHighRisk?: boolean; - /** - * Whether to enable tier 1 pattern-based (regex) detection. - * Defaults to the project setting. - */ - useTier1Classification?: boolean; - /** - * Whether to enable tier 2 ML-based detection. - * Defaults to the project setting. - */ - useTier2Classification?: boolean; -} +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; From 6c6c2ea8d31e0193b9f45d4ac1ffdeba53d1e2e5 Mon Sep 17 00:00:00 2001 From: Hisku Date: Thu, 19 Mar 2026 16:48:16 +0000 Subject: [PATCH 04/22] fix: resolve TypeScript errors and update tests for new defender defaults - Move defenderFields computation before dryRun block so both paths share the same logic - Fix TypeScript error: defenderConfig.enabled was accessed on the useProjectSettings union variant - Update tests to reflect new behavior: omitting defender now applies SDK defaults instead of undefined Co-Authored-By: Claude Sonnet 4.6 --- src/toolsets.test.ts | 16 ++++++++++++---- src/toolsets.ts | 24 +++++------------------- 2 files changed, 17 insertions(+), 23 deletions(-) diff --git a/src/toolsets.test.ts b/src/toolsets.test.ts index c87606b9..f462c679 100644 --- a/src/toolsets.test.ts +++ b/src/toolsets.test.ts @@ -524,11 +524,16 @@ describe('StackOneToolSet', () => { expect(toolset.defenderConfig).toEqual({ enabled: false }); }); - it('should leave defenderConfig undefined when not provided', () => { + 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).toBeUndefined(); + expect(toolset.defenderConfig).toEqual({ + enabled: true, + blockHighRisk: false, + useTier1Classification: true, + useTier2Classification: true, + }); }); it('should include defender_enabled in dryRun payload when defender.enabled is set', async () => { @@ -549,7 +554,7 @@ describe('StackOneToolSet', () => { expect(parsedBody.defender_enabled).toBe(false); }); - it('should omit defender_enabled from dryRun payload when defender config is not set', async () => { + it('should include SDK default defender fields in dryRun payload when defender config is not set', async () => { const toolset = new StackOneToolSet({ baseUrl: TEST_BASE_URL, apiKey: 'test-key', @@ -563,7 +568,10 @@ describe('StackOneToolSet', () => { const result = await tool.execute({ body: { name: 'test' } }, { dryRun: true }); const parsedBody = JSON.parse(result.body as string); - expect(parsedBody).not.toHaveProperty('defender_enabled'); + expect(parsedBody.defender_enabled).toBe(true); + expect(parsedBody.block_high_risk).toBe(false); + expect(parsedBody.use_tier1_classification).toBe(true); + expect(parsedBody.use_tier2_classification).toBe(true); }); it('should forward defender_enabled in live RPC call when defender.enabled is set', async () => { diff --git a/src/toolsets.ts b/src/toolsets.ts index 53fef5de..559b7ff2 100644 --- a/src/toolsets.ts +++ b/src/toolsets.ts @@ -1279,29 +1279,15 @@ export class StackOneToolSet { defender === null ? // null → explicitly disable { defender_enabled: false } - : defender !== null && - 'useProjectSettings' in defender && - defender.useProjectSettings === true + : 'useProjectSettings' in defender && defender.useProjectSettings === true ? // useProjectSettings: true → send nothing, backend uses project settings {} : // SDK-level config (default or explicit) { - defender_enabled: - defender !== null && !('useProjectSettings' in defender) - ? (defender.enabled ?? true) - : true, - block_high_risk: - defender !== null && !('useProjectSettings' in defender) - ? (defender.blockHighRisk ?? false) - : false, - use_tier1_classification: - defender !== null && !('useProjectSettings' in defender) - ? (defender.useTier1Classification ?? true) - : true, - use_tier2_classification: - defender !== null && !('useProjectSettings' in defender) - ? (defender.useTier2Classification ?? true) - : true, + defender_enabled: defender.enabled ?? true, + block_high_risk: defender.blockHighRisk ?? false, + use_tier1_classification: defender.useTier1Classification ?? true, + use_tier2_classification: defender.useTier2Classification ?? true, }; if (options?.dryRun) { From 30cfcd5338830912c95c845c8733bd91383fbffd Mon Sep 17 00:00:00 2001 From: Hisku Date: Thu, 19 Mar 2026 16:55:08 +0000 Subject: [PATCH 05/22] fix: rewrite defenderFields as if-else to fix oxfmt formatting The ternary expression used mixed tabs+spaces alignment which oxfmt rejects. Replaced with an if-else block that uses consistent tab indentation. Co-Authored-By: Claude Sonnet 4.6 --- src/toolsets.ts | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/src/toolsets.ts b/src/toolsets.ts index 559b7ff2..e1ec7915 100644 --- a/src/toolsets.ts +++ b/src/toolsets.ts @@ -1275,20 +1275,25 @@ export class StackOneToolSet { } const defender = this.defenderConfig; - const defenderFields = - defender === null - ? // null → explicitly disable - { defender_enabled: false } - : 'useProjectSettings' in defender && defender.useProjectSettings === true - ? // useProjectSettings: true → send nothing, backend uses project settings - {} - : // SDK-level config (default or explicit) - { - defender_enabled: defender.enabled ?? true, - block_high_risk: defender.blockHighRisk ?? false, - use_tier1_classification: defender.useTier1Classification ?? true, - use_tier2_classification: defender.useTier2Classification ?? true, - }; + let defenderFields: Partial<{ + defender_enabled: boolean; + block_high_risk: boolean; + use_tier1_classification: boolean; + use_tier2_classification: boolean; + }> = {}; + if (defender === null) { + // null → explicitly disable + defenderFields = { defender_enabled: false }; + } else if (!('useProjectSettings' in defender) || !defender.useProjectSettings) { + // SDK-level config (default or explicit) + defenderFields = { + defender_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 = { From 6db2871be01e82b2abe1f4be0f3aed0da89d8751 Mon Sep 17 00:00:00 2001 From: Hisku Date: Fri, 20 Mar 2026 10:47:31 +0000 Subject: [PATCH 06/22] feat: warn when defender config is omitted at construction time SDK defaults will override project-level defender settings when no explicit defender config is passed. A console.warn nudges users to either pass an explicit config or opt into useProjectSettings: true. Co-Authored-By: Claude Sonnet 4.6 --- src/toolsets.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/toolsets.ts b/src/toolsets.ts index e1ec7915..3ffa05de 100644 --- a/src/toolsets.ts +++ b/src/toolsets.ts @@ -546,6 +546,14 @@ export class StackOneToolSet { } } 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) { From d748d1342f749dcbcbc3001e08e041ba07063b5c Mon Sep 17 00:00:00 2001 From: Hisku Date: Fri, 20 Mar 2026 11:10:32 +0000 Subject: [PATCH 07/22] refactor: ran format:oxfmt --- src/toolsets.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/toolsets.ts b/src/toolsets.ts index 3ffa05de..77c88969 100644 --- a/src/toolsets.ts +++ b/src/toolsets.ts @@ -537,8 +537,14 @@ export class StackOneToolSet { // 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 ( + 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.', From 830c95e229b5bc992a40d222041e6c9d069867dc Mon Sep 17 00:00:00 2001 From: Hisku Date: Mon, 23 Mar 2026 12:03:33 +0000 Subject: [PATCH 08/22] feat: nest defender fields under defender_config object Replace flat defender_enabled/block_high_risk/use_tier*_classification fields with a nested defender_config object. Keep defender_enabled for backward compat. Co-Authored-By: Claude Sonnet 4.6 --- mocks/handlers.stackone-rpc.ts | 3 ++- src/rpc-client.ts | 10 ++-------- src/schema.ts | 15 ++++++++++++--- src/toolsets.test.ts | 18 +++++++++--------- src/toolsets.ts | 29 ++++++++++++++++++++--------- 5 files changed, 45 insertions(+), 30 deletions(-) diff --git a/mocks/handlers.stackone-rpc.ts b/mocks/handlers.stackone-rpc.ts index b65ee06b..19ccb690 100644 --- a/mocks/handlers.stackone-rpc.ts +++ b/mocks/handlers.stackone-rpc.ts @@ -71,12 +71,13 @@ export const stackoneRpcHandlers = [ ); } - // Default response for other actions — echo back received fields including defender_enabled + // 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, diff --git a/src/rpc-client.ts b/src/rpc-client.ts index 19779997..6f61915f 100644 --- a/src/rpc-client.ts +++ b/src/rpc-client.ts @@ -55,14 +55,8 @@ export class RpcClient { ...(validatedRequest.defender_enabled !== undefined && { defender_enabled: validatedRequest.defender_enabled, }), - ...(validatedRequest.block_high_risk !== undefined && { - block_high_risk: validatedRequest.block_high_risk, - }), - ...(validatedRequest.use_tier1_classification !== undefined && { - use_tier1_classification: validatedRequest.use_tier1_classification, - }), - ...(validatedRequest.use_tier2_classification !== undefined && { - use_tier2_classification: validatedRequest.use_tier2_classification, + ...(validatedRequest.defender_config !== undefined && { + defender_config: validatedRequest.defender_config, }), headers: validatedRequest.headers, path: validatedRequest.path, diff --git a/src/schema.ts b/src/schema.ts index f82b7290..73a483b3 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 + */ +export 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,13 +18,12 @@ import { stackOneHeadersSchema } from './headers'; export const rpcActionRequestSchema = z.object({ action: z.string(), body: z.optional(z.record(z.string(), z.unknown())), - block_high_risk: z.optional(z.boolean()), + /** @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())), - use_tier1_classification: z.optional(z.boolean()), - use_tier2_classification: z.optional(z.boolean()), }); /** diff --git a/src/toolsets.test.ts b/src/toolsets.test.ts index f462c679..94de981b 100644 --- a/src/toolsets.test.ts +++ b/src/toolsets.test.ts @@ -536,7 +536,7 @@ describe('StackOneToolSet', () => { }); }); - it('should include defender_enabled in dryRun payload when defender.enabled is set', async () => { + 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', @@ -551,10 +551,10 @@ describe('StackOneToolSet', () => { const result = await tool.execute({ body: { name: 'test' } }, { dryRun: true }); const parsedBody = JSON.parse(result.body as string); - expect(parsedBody.defender_enabled).toBe(false); + expect(parsedBody.defender_config.enabled).toBe(false); }); - it('should include SDK default defender fields in dryRun payload when defender config is not set', async () => { + 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', @@ -568,13 +568,13 @@ describe('StackOneToolSet', () => { const result = await tool.execute({ body: { name: 'test' } }, { dryRun: true }); const parsedBody = JSON.parse(result.body as string); - expect(parsedBody.defender_enabled).toBe(true); - expect(parsedBody.block_high_risk).toBe(false); - expect(parsedBody.use_tier1_classification).toBe(true); - expect(parsedBody.use_tier2_classification).toBe(true); + 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_enabled in live RPC call when defender.enabled is set', async () => { + 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', @@ -589,7 +589,7 @@ describe('StackOneToolSet', () => { const result = await tool.execute({ body: { name: 'test' } }); expect(result).toMatchObject({ - data: { received: { defender_enabled: true } }, + data: { received: { defender_config: { enabled: true } } }, }); }); }); diff --git a/src/toolsets.ts b/src/toolsets.ts index 77c88969..725e5217 100644 --- a/src/toolsets.ts +++ b/src/toolsets.ts @@ -1290,21 +1290,32 @@ export class StackOneToolSet { const defender = this.defenderConfig; let defenderFields: Partial<{ - defender_enabled: boolean; - block_high_risk: boolean; - use_tier1_classification: boolean; - use_tier2_classification: boolean; + defender_config: { + enabled: boolean; + block_high_risk: boolean; + use_tier1_classification: boolean; + use_tier2_classification: boolean; + }; }> = {}; if (defender === null) { // null → explicitly disable - defenderFields = { defender_enabled: false }; + 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_enabled: defender.enabled ?? true, - block_high_risk: defender.blockHighRisk ?? false, - use_tier1_classification: defender.useTier1Classification ?? true, - use_tier2_classification: defender.useTier2Classification ?? true, + 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 From 299284e4a83085fcd4aea2d50f6c1594b882c32e Mon Sep 17 00:00:00 2001 From: Hisku Date: Mon, 23 Mar 2026 12:46:50 +0000 Subject: [PATCH 09/22] fix: add defender_config to mock handler body type Co-Authored-By: Claude Sonnet 4.6 --- mocks/handlers.stackone-rpc.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/mocks/handlers.stackone-rpc.ts b/mocks/handlers.stackone-rpc.ts index 19ccb690..496899e8 100644 --- a/mocks/handlers.stackone-rpc.ts +++ b/mocks/handlers.stackone-rpc.ts @@ -21,6 +21,12 @@ export const stackoneRpcHandlers = [ 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; From 5a4fd01f4b7ff21d1fdbc21425301e30b994fd86 Mon Sep 17 00:00:00 2001 From: Hisku Date: Mon, 23 Mar 2026 13:52:03 +0000 Subject: [PATCH 10/22] fix: remove unused export from defenderConfigRequestSchema knip flagged defenderConfigRequestSchema as an unused export since it is only used internally within schema.ts. Co-Authored-By: Claude Sonnet 4.6 --- src/schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/schema.ts b/src/schema.ts index 73a483b3..8db81942 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -4,7 +4,7 @@ import { stackOneHeadersSchema } from './headers'; /** * Zod schema for nested defender configuration sent with each RPC request */ -export const defenderConfigRequestSchema = z.object({ +const defenderConfigRequestSchema = z.object({ enabled: z.optional(z.boolean()), block_high_risk: z.optional(z.boolean()), use_tier1_classification: z.optional(z.boolean()), From 034c1152c11c33ba82bde73296e1260799c6d6c4 Mon Sep 17 00:00:00 2001 From: Hisku Date: Tue, 14 Apr 2026 15:41:46 +0100 Subject: [PATCH 11/22] =?UTF-8?q?fix(defender):=20resolve=20review=20issue?= =?UTF-8?q?s=20=E2=80=94=20remove=20deprecated=20field,=20add=20missing=20?= =?UTF-8?q?tests,=20harden=20guards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove `defender_enabled` from schema, rpc-client, mock handler, and tests. Eliminates the ambiguous payload where both `defender_enabled` and `defender_config` could be sent simultaneously with undefined precedence. - Add missing test coverage for all three defender branches: * `defender: null` → `defender_config` with all fields false * `defender: { useProjectSettings: true }` → no `defender_config` sent * `useProjectSettings: true` combined with explicit options → `ToolSetConfigError` - Remove `console.warn` on every default `StackOneToolSet()` construction. The SDK defaults are documented; the warning was too noisy for normal usage. - Add `typeof === 'object'` guard before `in` operator at constructor and `createRpcBackedTool` call sites to produce a clean error for non-TypeScript callers passing invalid runtime values. Co-Authored-By: Claude Sonnet 4.6 --- mocks/handlers.stackone-rpc.ts | 2 -- src/rpc-client.test.ts | 26 ++++------------- src/rpc-client.ts | 3 -- src/schema.ts | 2 -- src/toolsets.test.ts | 52 ++++++++++++++++++++++++++++++++++ src/toolsets.ts | 11 ++----- 6 files changed, 59 insertions(+), 37 deletions(-) diff --git a/mocks/handlers.stackone-rpc.ts b/mocks/handlers.stackone-rpc.ts index 496899e8..ba86a1da 100644 --- a/mocks/handlers.stackone-rpc.ts +++ b/mocks/handlers.stackone-rpc.ts @@ -20,7 +20,6 @@ export const stackoneRpcHandlers = [ const body = (await request.json()) as { action?: string; body?: Record; - defender_enabled?: boolean; defender_config?: { enabled?: boolean; block_high_risk?: boolean; @@ -84,7 +83,6 @@ export const stackoneRpcHandlers = [ 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/rpc-client.test.ts b/src/rpc-client.test.ts index 1846bb5f..b26cb611 100644 --- a/src/rpc-client.test.ts +++ b/src/rpc-client.test.ts @@ -129,7 +129,7 @@ test('should send x-account-id as HTTP header', async () => { }); }); -test('should forward defender_enabled: true in request payload', async () => { +test('should forward defender_config in request payload', async () => { const client = new RpcClient({ serverURL: TEST_BASE_URL, security: { username: 'test-api-key' }, @@ -137,31 +137,15 @@ test('should forward defender_enabled: true in request payload', async () => { const response = await client.actions.rpcAction({ action: 'custom_action', - defender_enabled: true, + defender_config: { enabled: true, block_high_risk: false }, }); expect(response.data).toMatchObject({ - received: { defender_enabled: true }, + received: { defender_config: { enabled: true, block_high_risk: false } }, }); }); -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 () => { +test('should omit defender_config from payload when not provided', async () => { const client = new RpcClient({ serverURL: TEST_BASE_URL, security: { username: 'test-api-key' }, @@ -172,6 +156,6 @@ test('should omit defender_enabled from payload when not provided', async () => }); expect((response.data as Record).received).not.toHaveProperty( - 'defender_enabled', + 'defender_config', ); }); diff --git a/src/rpc-client.ts b/src/rpc-client.ts index 6f61915f..cd6840a7 100644 --- a/src/rpc-client.ts +++ b/src/rpc-client.ts @@ -52,9 +52,6 @@ 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, }), diff --git a/src/schema.ts b/src/schema.ts index 8db81942..345abfa8 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -18,8 +18,6 @@ const defenderConfigRequestSchema = z.object({ 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())), diff --git a/src/toolsets.test.ts b/src/toolsets.test.ts index 94de981b..c74c1584 100644 --- a/src/toolsets.test.ts +++ b/src/toolsets.test.ts @@ -592,6 +592,58 @@ describe('StackOneToolSet', () => { data: { received: { defender_config: { enabled: true } } }, }); }); + + it('should send defender_config with all fields false when defender is null', async () => { + const toolset = new StackOneToolSet({ + baseUrl: TEST_BASE_URL, + apiKey: 'test-key', + accountId: 'test-account', + defender: null, + }); + + 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).toEqual({ + enabled: false, + block_high_risk: false, + use_tier1_classification: false, + use_tier2_classification: false, + }); + }); + + it('should omit defender_config from payload when useProjectSettings is true', async () => { + const toolset = new StackOneToolSet({ + baseUrl: TEST_BASE_URL, + apiKey: 'test-key', + accountId: 'test-account', + defender: { useProjectSettings: 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' } }, { dryRun: true }); + + const parsedBody = JSON.parse(result.body as string); + expect(parsedBody).not.toHaveProperty('defender_config'); + }); + + it('should throw ToolSetConfigError when useProjectSettings is combined with other defender options', () => { + expect( + () => + new StackOneToolSet({ + apiKey: 'test-key', + // @ts-expect-error - intentionally testing invalid runtime input + defender: { useProjectSettings: true, enabled: true }, + }), + ).toThrow(ToolSetConfigError); + }); }); describe('provider and action filtering', () => { diff --git a/src/toolsets.ts b/src/toolsets.ts index 725e5217..60276393 100644 --- a/src/toolsets.ts +++ b/src/toolsets.ts @@ -539,6 +539,7 @@ export class StackOneToolSet { const defenderInput = config?.defender; if ( defenderInput != null && + typeof defenderInput === 'object' && 'useProjectSettings' in defenderInput && defenderInput.useProjectSettings === true ) { @@ -552,14 +553,6 @@ export class StackOneToolSet { } } 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) { @@ -1307,7 +1300,7 @@ export class StackOneToolSet { use_tier2_classification: false, }, }; - } else if (!('useProjectSettings' in defender) || !defender.useProjectSettings) { + } else if (typeof defender !== 'object' || !('useProjectSettings' in defender) || !defender.useProjectSettings) { // SDK-level config (default or explicit) defenderFields = { defender_config: { From 324eda69a6c0e6245d0749ceb3433d8f51865517 Mon Sep 17 00:00:00 2001 From: Hisku Date: Tue, 14 Apr 2026 15:45:51 +0100 Subject: [PATCH 12/22] refactor: ran format:oxfmt Co-Authored-By: Claude Sonnet 4.6 --- src/rpc-client.test.ts | 4 +--- src/toolsets.ts | 6 +++++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/rpc-client.test.ts b/src/rpc-client.test.ts index b26cb611..3bba979a 100644 --- a/src/rpc-client.test.ts +++ b/src/rpc-client.test.ts @@ -155,7 +155,5 @@ test('should omit defender_config from payload when not provided', async () => { action: 'custom_action', }); - expect((response.data as Record).received).not.toHaveProperty( - 'defender_config', - ); + expect((response.data as Record).received).not.toHaveProperty('defender_config'); }); diff --git a/src/toolsets.ts b/src/toolsets.ts index 60276393..4c13ca0e 100644 --- a/src/toolsets.ts +++ b/src/toolsets.ts @@ -1300,7 +1300,11 @@ export class StackOneToolSet { use_tier2_classification: false, }, }; - } else if (typeof defender !== 'object' || !('useProjectSettings' in defender) || !defender.useProjectSettings) { + } else if ( + typeof defender !== 'object' || + !('useProjectSettings' in defender) || + !defender.useProjectSettings + ) { // SDK-level config (default or explicit) defenderFields = { defender_config: { From f918baf5f27c5401d0af52d6db842292fc88757f Mon Sep 17 00:00:00 2001 From: Hisku Date: Tue, 14 Apr 2026 15:56:05 +0100 Subject: [PATCH 13/22] fix(defender): clone DEFAULT_DEFENDER_CONFIG on assignment to prevent shared mutation Spread into a fresh object so that external mutation of the exported constant (e.g. via an any cast) cannot affect future StackOneToolSet instances. Co-Authored-By: Claude Sonnet 4.6 --- src/toolsets.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/toolsets.ts b/src/toolsets.ts index 4c13ca0e..fde95142 100644 --- a/src/toolsets.ts +++ b/src/toolsets.ts @@ -552,7 +552,8 @@ export class StackOneToolSet { ); } } - this.defenderConfig = defenderInput === undefined ? DEFAULT_DEFENDER_CONFIG : defenderInput; + this.defenderConfig = + defenderInput === undefined ? { ...DEFAULT_DEFENDER_CONFIG } : defenderInput; // Set Authentication headers if provided if (this.authentication) { From a5b53724318cdd3434d76b4a4b99760e7a0af31a Mon Sep 17 00:00:00 2001 From: Hisku Date: Wed, 15 Apr 2026 14:15:30 +0100 Subject: [PATCH 14/22] docs: add Defender section to README Documents default behavior, all four configuration modes, risk levels, and blocking vs annotating semantics. Links to @stackone/defender for pipeline details. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 60 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/README.md b/README.md index fdc81ccf..be93ec66 100644 --- a/README.md +++ b/README.md @@ -411,6 +411,66 @@ import { StackOneToolSet } from '@stackone/ai'; const toolset = new StackOneToolSet({ baseUrl: 'https://api.example-dev.com' }); ``` +### Defender + +The SDK includes built-in prompt injection protection via [StackOne Defender](https://www.npmjs.com/package/@stackone/defender). It runs on every tool call result before the content reaches your LLM, detecting and sanitizing injection attacks hidden in external data (emails, documents, CRM notes, etc.). + +**Defender is enabled by default.** When no `defender` option is passed, the SDK applies these defaults: + +| Setting | Default | Description | +|---|---|---| +| `enabled` | `true` | Scanning runs on every tool call | +| `blockHighRisk` | `false` | High/critical content is annotated but not blocked | +| `useTier1Classification` | `true` | Fast pattern-based detection (~1ms) | +| `useTier2Classification` | `true` | ML-based detection (~10ms, requires `onnxruntime-node`) | + +#### Configuration modes + +```typescript +import { StackOneToolSet } from '@stackone/ai'; + +// Default — SDK defaults apply (enabled, non-blocking) +const toolset = new StackOneToolSet({ apiKey: '...' }); + +// Explicitly disabled — no scanning on any tool call +const toolset = new StackOneToolSet({ + apiKey: '...', + defender: null, +}); + +// Defer to project dashboard settings +const toolset = new StackOneToolSet({ + apiKey: '...', + defender: { useProjectSettings: true }, +}); + +// Explicit SDK-level config — block high/critical risk results +const toolset = new StackOneToolSet({ + apiKey: '...', + defender: { + enabled: true, + blockHighRisk: true, // throw on HIGH or CRITICAL risk + useTier1Classification: true, // pattern-based (regex, role markers) + useTier2Classification: true, // ML-based (ONNX model) + }, +}); +``` + +#### Risk levels + +Defender assigns a risk level to each scanned result: + +| Level | Meaning | +|---|---| +| `low` | No threats detected | +| `medium` | Suspicious patterns detected, role markers stripped | +| `high` | Injection patterns found, content redacted | +| `critical` | Severe injection attempt with multiple indicators | + +When `blockHighRisk: false` (default), `high` and `critical` results are annotated and returned — the LLM sees the sanitized content. When `blockHighRisk: true`, those results are blocked entirely. + +For more detail on how the detection pipeline works, see the [`@stackone/defender`](https://www.npmjs.com/package/@stackone/defender) package. + ### Testing with dryRun You can use the `dryRun` option to return the api arguments from a tool call without making the actual api call: From b1dc5339633253d0684038a4dcad003ee6a5b86e Mon Sep 17 00:00:00 2001 From: Hisku Date: Wed, 15 Apr 2026 14:20:27 +0100 Subject: [PATCH 15/22] refactor: ran format:oxfmt Co-Authored-By: Claude Sonnet 4.6 --- README.md | 46 +++++++++++++++++++++++----------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index be93ec66..8339bb4d 100644 --- a/README.md +++ b/README.md @@ -417,12 +417,12 @@ The SDK includes built-in prompt injection protection via [StackOne Defender](ht **Defender is enabled by default.** When no `defender` option is passed, the SDK applies these defaults: -| Setting | Default | Description | -|---|---|---| -| `enabled` | `true` | Scanning runs on every tool call | -| `blockHighRisk` | `false` | High/critical content is annotated but not blocked | -| `useTier1Classification` | `true` | Fast pattern-based detection (~1ms) | -| `useTier2Classification` | `true` | ML-based detection (~10ms, requires `onnxruntime-node`) | +| Setting | Default | Description | +| ------------------------ | ------- | ------------------------------------------------------- | +| `enabled` | `true` | Scanning runs on every tool call | +| `blockHighRisk` | `false` | High/critical content is annotated but not blocked | +| `useTier1Classification` | `true` | Fast pattern-based detection (~1ms) | +| `useTier2Classification` | `true` | ML-based detection (~10ms, requires `onnxruntime-node`) | #### Configuration modes @@ -434,25 +434,25 @@ const toolset = new StackOneToolSet({ apiKey: '...' }); // Explicitly disabled — no scanning on any tool call const toolset = new StackOneToolSet({ - apiKey: '...', - defender: null, + apiKey: '...', + defender: null, }); // Defer to project dashboard settings const toolset = new StackOneToolSet({ - apiKey: '...', - defender: { useProjectSettings: true }, + apiKey: '...', + defender: { useProjectSettings: true }, }); // Explicit SDK-level config — block high/critical risk results const toolset = new StackOneToolSet({ - apiKey: '...', - defender: { - enabled: true, - blockHighRisk: true, // throw on HIGH or CRITICAL risk - useTier1Classification: true, // pattern-based (regex, role markers) - useTier2Classification: true, // ML-based (ONNX model) - }, + apiKey: '...', + defender: { + enabled: true, + blockHighRisk: true, // throw on HIGH or CRITICAL risk + useTier1Classification: true, // pattern-based (regex, role markers) + useTier2Classification: true, // ML-based (ONNX model) + }, }); ``` @@ -460,12 +460,12 @@ const toolset = new StackOneToolSet({ Defender assigns a risk level to each scanned result: -| Level | Meaning | -|---|---| -| `low` | No threats detected | -| `medium` | Suspicious patterns detected, role markers stripped | -| `high` | Injection patterns found, content redacted | -| `critical` | Severe injection attempt with multiple indicators | +| Level | Meaning | +| ---------- | --------------------------------------------------- | +| `low` | No threats detected | +| `medium` | Suspicious patterns detected, role markers stripped | +| `high` | Injection patterns found, content redacted | +| `critical` | Severe injection attempt with multiple indicators | When `blockHighRisk: false` (default), `high` and `critical` results are annotated and returned — the LLM sees the sanitized content. When `blockHighRisk: true`, those results are blocked entirely. From cb27cab3e5c0cb8414712c886c768fbcfef2a81c Mon Sep 17 00:00:00 2001 From: Hisku Date: Thu, 16 Apr 2026 17:30:49 +0100 Subject: [PATCH 16/22] feat(defender): implement buildDefenderFields function to map SDK config to RPC format --- src/toolsets.ts | 73 ++++++++++++++++++++++++------------------------- 1 file changed, 35 insertions(+), 38 deletions(-) diff --git a/src/toolsets.ts b/src/toolsets.ts index 6c740784..002e9e05 100644 --- a/src/toolsets.ts +++ b/src/toolsets.ts @@ -459,6 +459,37 @@ export function createExecuteTool( return tool; } +/** Wire-format defender config sent to the backend RPC action. */ +interface DefenderApiConfig { + enabled: boolean; + block_high_risk: boolean; + use_tier1_classification: boolean; + use_tier2_classification: boolean; +} + +/** + * Map SDK DefenderConfig to the wire-format sent in the RPC body. + * + * - `null` → explicitly disabled (all fields false) + * - `{ useProjectSettings: true }` → empty object (backend uses project settings) + * - SDK config → merge with defaults + */ +function buildDefenderFields( + config: DefenderConfig | null, +): { defender_config: DefenderApiConfig } | Record { + if (config !== null && 'useProjectSettings' in config && config.useProjectSettings) { + return {}; + } + return { + defender_config: { + enabled: config?.enabled ?? config !== null, + block_high_risk: config?.blockHighRisk ?? false, + use_tier1_classification: config?.useTier1Classification ?? config !== null, + use_tier2_classification: config?.useTier2Classification ?? config !== null, + }, + }; +} + /** * Class for loading StackOne tools via MCP */ @@ -471,6 +502,7 @@ export class StackOneToolSet { private readonly searchConfig: SearchConfig | null; private readonly executeConfig: ExecuteToolsConfig | undefined; private readonly defenderConfig: DefenderConfig | null; + private readonly defenderFields: { defender_config: DefenderApiConfig } | Record; /** * Account ID for StackOne API @@ -554,6 +586,7 @@ export class StackOneToolSet { } this.defenderConfig = defenderInput === undefined ? { ...DEFAULT_DEFENDER_CONFIG } : defenderInput; + this.defenderFields = buildDefenderFields(this.defenderConfig); // Set Authentication headers if provided if (this.authentication) { @@ -1310,47 +1343,11 @@ 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 ( - typeof defender !== 'object' || - !('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, + ...this.defenderFields, headers: actionHeaders, path: pathParams ?? undefined, query: queryParams ?? undefined, @@ -1368,7 +1365,7 @@ export class StackOneToolSet { const response = await actionsClient.actions.rpcAction({ action: name, body: rpcBody, - ...defenderFields, + ...this.defenderFields, headers: actionHeaders, path: pathParams ?? undefined, query: queryParams ?? undefined, From bdfb57095173b604ba6ba898bf3760523e370164 Mon Sep 17 00:00:00 2001 From: Hisku Date: Mon, 11 May 2026 08:56:15 +0100 Subject: [PATCH 17/22] refactor(defender): simplify buildDefenderFields with early null return + type guard - Extract `usesProjectSettings` type guard to deduplicate the `'useProjectSettings' in config && config.useProjectSettings === true` predicate used in both `buildDefenderFields` and the constructor. - Split the `null` branch in `buildDefenderFields` into an early return with hard-coded `false` fields, removing the `?? config !== null` fallback chain that was doing double duty across two unrelated branches. - Collapse the constructor's exclusivity-validation block: drop the manual destructure-and-count-rest in favor of `Object.keys(defenderInput).length > 1` after the type guard narrows the variant. Behavior preserved across all four input cases (null, useProjectSettings, default, partial). Addresses glebedel review feedback on PR #328. --- src/toolsets.ts | 40 +++++++++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/src/toolsets.ts b/src/toolsets.ts index 09efc4df..a96b991c 100644 --- a/src/toolsets.ts +++ b/src/toolsets.ts @@ -467,6 +467,11 @@ interface DefenderApiConfig { use_tier2_classification: boolean; } +/** Type guard: discriminate the `useProjectSettings: true` variant of DefenderConfig. */ +function usesProjectSettings(config: DefenderConfig): config is { useProjectSettings: true } { + return 'useProjectSettings' in config && config.useProjectSettings === true; +} + /** * Map SDK DefenderConfig to the wire-format sent in the RPC body. * @@ -477,15 +482,25 @@ interface DefenderApiConfig { function buildDefenderFields( config: DefenderConfig | null, ): { defender_config: DefenderApiConfig } | Record { - if (config !== null && 'useProjectSettings' in config && config.useProjectSettings) { + if (config === null) { + return { + defender_config: { + enabled: false, + block_high_risk: false, + use_tier1_classification: false, + use_tier2_classification: false, + }, + }; + } + if (usesProjectSettings(config)) { return {}; } return { defender_config: { - enabled: config?.enabled ?? config !== null, - block_high_risk: config?.blockHighRisk ?? false, - use_tier1_classification: config?.useTier1Classification ?? config !== null, - use_tier2_classification: config?.useTier2Classification ?? config !== null, + enabled: config.enabled ?? true, + block_high_risk: config.blockHighRisk ?? false, + use_tier1_classification: config.useTier1Classification ?? true, + use_tier2_classification: config.useTier2Classification ?? true, }, }; } @@ -572,17 +587,12 @@ export class StackOneToolSet { if ( defenderInput != null && typeof defenderInput === 'object' && - 'useProjectSettings' in defenderInput && - defenderInput.useProjectSettings === true + usesProjectSettings(defenderInput) && + Object.keys(defenderInput).length > 1 ) { - 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.', - ); - } + throw new ToolSetConfigError( + 'Cannot combine useProjectSettings: true with explicit defender options. Use one or the other.', + ); } this.defenderConfig = defenderInput === undefined ? { ...DEFAULT_DEFENDER_CONFIG } : defenderInput; From 02c4c0e223d8317658fa3d435a32e0ac0242a2e9 Mon Sep 17 00:00:00 2001 From: Hisku Date: Tue, 12 May 2026 10:46:33 +0100 Subject: [PATCH 18/22] feat(defender)!: switch default behavior to defer to project dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, omitting the `defender` option expanded into DEFAULT_DEFENDER_CONFIG (defender enabled, both tiers on) and sent a `defender_config` payload that overrode the project's dashboard setting. This caused two problems flagged in review: - SDK consumers paid the ~1-10ms per-call defender cost by default, even when they hadn't opted in. - The SDK silently overrode whatever the project owner had configured in the dashboard, instead of respecting it. This commit makes the SDK silent in the default case: - Omitting `defender` (or passing `undefined`) is now normalized to `{ useProjectSettings: true }`, which omits `defender_config` from the RPC payload entirely. The dashboard setting controls behavior. - `null` continues to mean "force defender off, override the dashboard." - An explicit config object continues to mean "SDK config wins, override the dashboard"; per-field defaults still fall back to `DEFAULT_DEFENDER_CONFIG`. - `DEFAULT_DEFENDER_CONFIG` is kept as a public reference users can spread (`{ ...DEFAULT_DEFENDER_CONFIG, blockHighRisk: true }`) and as the single source of truth for per-field fallbacks in buildDefenderFields. Docs in src/types.ts, the constructor JSDoc, README, and tests are updated to reflect the new four-mode contract. The behavior change is visible to anyone who relied on the implicit "enabled by default" state — callers who want the old behavior can write `defender: { ...DEFAULT_DEFENDER_CONFIG }`. Addresses shashi-stackone review feedback on PR #328. --- README.md | 34 +++++++++++++++++++++------------- src/toolsets.test.ts | 16 ++++------------ src/toolsets.ts | 30 ++++++++++++++++-------------- src/types.ts | 27 ++++++++++++++++++++------- 4 files changed, 61 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 42b339c8..9974a620 100644 --- a/README.md +++ b/README.md @@ -363,36 +363,44 @@ const toolset = new StackOneToolSet({ baseUrl: 'https://api.example-dev.com' }); The SDK includes built-in prompt injection protection via [StackOne Defender](https://www.npmjs.com/package/@stackone/defender). It runs on every tool call result before the content reaches your LLM, detecting and sanitizing injection attacks hidden in external data (emails, documents, CRM notes, etc.). -**Defender is enabled by default.** When no `defender` option is passed, the SDK applies these defaults: +**By default, the SDK defers to your project's dashboard defender setting.** Pass an explicit `defender` config to override the project setting per toolset. -| Setting | Default | Description | -| ------------------------ | ------- | ------------------------------------------------------- | -| `enabled` | `true` | Scanning runs on every tool call | -| `blockHighRisk` | `false` | High/critical content is annotated but not blocked | -| `useTier1Classification` | `true` | Fast pattern-based detection (~1ms) | -| `useTier2Classification` | `true` | ML-based detection (~10ms, requires `onnxruntime-node`) | +| `defender` option | Effective behavior | +| --------------------------------- | --------------------------------------------------------- | +| omitted _(default)_ | Project dashboard setting controls — SDK adds nothing | +| `{ useProjectSettings: true }` | Same as omitting; explicit, self-documenting form | +| `{ enabled, blockHighRisk, ... }` | SDK-level config wins, overrides the project setting | +| `null` | Defender forcibly disabled, overrides the project setting | + +When passing an explicit object, missing fields fall back to `DEFAULT_DEFENDER_CONFIG` (exported from `@stackone/ai`): `enabled: true`, `blockHighRisk: false`, both tiers on. #### Configuration modes ```typescript -import { StackOneToolSet } from '@stackone/ai'; +import { StackOneToolSet, DEFAULT_DEFENDER_CONFIG } from '@stackone/ai'; -// Default — SDK defaults apply (enabled, non-blocking) +// Default — defer to project dashboard setting const toolset = new StackOneToolSet({ apiKey: '...' }); -// Explicitly disabled — no scanning on any tool call +// Same as default, explicit form +const toolset = new StackOneToolSet({ + apiKey: '...', + defender: { useProjectSettings: true }, +}); + +// Explicitly disabled — overrides any project setting const toolset = new StackOneToolSet({ apiKey: '...', defender: null, }); -// Defer to project dashboard settings +// Opt in with safe defaults, but block on HIGH/CRITICAL — overrides project setting const toolset = new StackOneToolSet({ apiKey: '...', - defender: { useProjectSettings: true }, + defender: { ...DEFAULT_DEFENDER_CONFIG, blockHighRisk: true }, }); -// Explicit SDK-level config — block high/critical risk results +// Fully explicit SDK-level config const toolset = new StackOneToolSet({ apiKey: '...', defender: { diff --git a/src/toolsets.test.ts b/src/toolsets.test.ts index 92ed5880..795cfa9c 100644 --- a/src/toolsets.test.ts +++ b/src/toolsets.test.ts @@ -571,16 +571,11 @@ describe('StackOneToolSet', () => { expect(toolset.defenderConfig).toEqual({ enabled: false }); }); - it('should set defenderConfig to SDK defaults when not provided', () => { + it('should normalize omitted defender to useProjectSettings: true', () => { 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, - }); + expect(toolset.defenderConfig).toEqual({ useProjectSettings: true }); }); it('should include defender_config in dryRun payload when defender.enabled is set', async () => { @@ -601,7 +596,7 @@ describe('StackOneToolSet', () => { expect(parsedBody.defender_config.enabled).toBe(false); }); - it('should include SDK default defender_config in dryRun payload when defender config is not set', async () => { + it('should omit defender_config from dryRun payload when defender config is not set', async () => { const toolset = new StackOneToolSet({ baseUrl: TEST_BASE_URL, apiKey: 'test-key', @@ -615,10 +610,7 @@ describe('StackOneToolSet', () => { 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); + expect(parsedBody).not.toHaveProperty('defender_config'); }); it('should forward defender_config in live RPC call when defender.enabled is set', async () => { diff --git a/src/toolsets.ts b/src/toolsets.ts index a96b991c..06c2d3e1 100644 --- a/src/toolsets.ts +++ b/src/toolsets.ts @@ -168,10 +168,10 @@ interface StackOneToolSetBaseConfig extends BaseToolSetConfig { /** * 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 + * - Omit or pass `undefined` (default) → defer to the project dashboard setting + * - Pass `{ useProjectSettings: true }` → same as omitting; explicit form of the default + * - Pass `{ enabled, blockHighRisk, ... }` → explicit SDK-level config, overrides project settings + * - Pass `null` → defender explicitly disabled, overrides project settings */ defender?: DefenderConfig | null; } @@ -475,9 +475,9 @@ function usesProjectSettings(config: DefenderConfig): config is { useProjectSett /** * Map SDK DefenderConfig to the wire-format sent in the RPC body. * - * - `null` → explicitly disabled (all fields false) - * - `{ useProjectSettings: true }` → empty object (backend uses project settings) - * - SDK config → merge with defaults + * - `null` → explicitly disabled (all fields false, overrides project setting) + * - `{ useProjectSettings: true }` → empty object (omitted from payload, project setting controls) + * - explicit object → wire format with missing fields filled from `DEFAULT_DEFENDER_CONFIG` */ function buildDefenderFields( config: DefenderConfig | null, @@ -497,10 +497,12 @@ function buildDefenderFields( } return { defender_config: { - enabled: config.enabled ?? true, - block_high_risk: config.blockHighRisk ?? false, - use_tier1_classification: config.useTier1Classification ?? true, - use_tier2_classification: config.useTier2Classification ?? true, + enabled: config.enabled ?? DEFAULT_DEFENDER_CONFIG.enabled, + block_high_risk: config.blockHighRisk ?? DEFAULT_DEFENDER_CONFIG.blockHighRisk, + use_tier1_classification: + config.useTier1Classification ?? DEFAULT_DEFENDER_CONFIG.useTier1Classification, + use_tier2_classification: + config.useTier2Classification ?? DEFAULT_DEFENDER_CONFIG.useTier2Classification, }, }; } @@ -580,8 +582,8 @@ export class StackOneToolSet { this.executeConfig = config?.execute; // Resolve defender config: - // undefined → SDK defaults (enabled, not blocking) - // null → explicitly disabled + // undefined → defer to project dashboard setting (normalized to { useProjectSettings: true }) + // null → explicitly disabled (overrides project setting) // object → validate then store as-is const defenderInput = config?.defender; if ( @@ -595,7 +597,7 @@ export class StackOneToolSet { ); } this.defenderConfig = - defenderInput === undefined ? { ...DEFAULT_DEFENDER_CONFIG } : defenderInput; + defenderInput === undefined ? { useProjectSettings: true } : defenderInput; this.defenderFields = buildDefenderFields(this.defenderConfig); // Set Authentication headers if provided diff --git a/src/types.ts b/src/types.ts index 0f672d56..23fa9735 100644 --- a/src/types.ts +++ b/src/types.ts @@ -239,12 +239,14 @@ export interface ClaudeAgentSdkOptions { * 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. + * Four modes: + * - Omit `defender` entirely (default) — defer to whatever is configured in the project dashboard. + * The SDK sends no `defender_config` in the RPC payload, so the project setting controls behavior. + * - `{ useProjectSettings: true }` — same as omitting; provided as a self-documenting opt-in. * 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. + * - An explicit config object — the SDK owns the defender settings and sends them with every + * RPC call, overriding any project-level config. + * - `null` — defender is explicitly disabled for all tool calls, overriding the project setting. */ export type DefenderConfig = | { useProjectSettings: true } @@ -264,8 +266,19 @@ export type DefenderConfig = }; /** - * 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. + * Reference values for a fully-enabled defender configuration with safe defaults + * (scan with both tiers, annotate but never block). + * + * Spread this into an explicit `defender` config to opt in with one tweak: + * ```ts + * defender: { ...DEFAULT_DEFENDER_CONFIG, blockHighRisk: true } + * ``` + * + * These values are also the per-field fallbacks applied when an explicit `defender` + * config object is passed with some fields omitted. + * + * Note: this is NOT applied when `defender` is omitted entirely — in that case the SDK + * defers to the project dashboard setting and sends no `defender_config` in the payload. */ export const DEFAULT_DEFENDER_CONFIG = { enabled: true, From 0c74bd48534a8c1b762c1b7862677cbaba5a965d Mon Sep 17 00:00:00 2001 From: Hisku Date: Tue, 12 May 2026 13:31:10 +0100 Subject: [PATCH 19/22] feat(defender): add defenderMode getter and once-per-process override warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds runtime visibility into how a `StackOneToolSet` resolves its defender configuration, so users (and anyone inheriting a shared toolset factory) can tell whether the SDK is silently overriding the project dashboard. Changes: - `DefenderMode` type ('project' | 'disabled' | 'explicit') exported from the public entrypoint. - `StackOneToolSet#defenderMode` getter — programmatic inspection of the resolved mode without poking at private fields. - Once-per-process `console.warn` when the SDK overrides the project dashboard (modes 'disabled' and 'explicit'). Dedupes by serialized wire payload so repeat constructions with the same shape stay quiet; distinct shapes each log once. Project-mode constructions are silent. - Yellow ANSI coloring on the warning when stderr is a TTY; honors `NO_COLOR` and `FORCE_COLOR` conventions. No new dependencies (uses Node-built-in TTY detection + hand-rolled escape codes — `util.styleText` would require Node 22+ and we still support >=20.19.6). - `__resetDefenderInfoLog` test helper (`@internal`) so test files can reset the per-process dedupe cache between cases. Documentation: - README "Inspecting and observing the resolved mode" subsection documents the getter and warning behavior, including the color env-var conventions. - New `examples/defender-config.ts` walks all four configuration modes, the getter, and the dedupe behavior. Construction-only — no API key required, no network traffic. - New `tests/examples/defender-config.test.ts` mirrors the example and asserts the resulting wire payloads via dryRun + MSW. Addresses shashi-stackone's earlier review concern about being able to tell at runtime whether the dashboard setting is being respected or overridden. --- README.md | 13 +++ examples/README.md | 4 + examples/defender-config.ts | 112 +++++++++++++++++++++ src/index.ts | 1 + src/toolsets.test.ts | 84 +++++++++++++++- src/toolsets.ts | 67 +++++++++++++ src/types.ts | 9 ++ tests/examples/defender-config.test.ts | 131 +++++++++++++++++++++++++ 8 files changed, 420 insertions(+), 1 deletion(-) create mode 100644 examples/defender-config.ts create mode 100644 tests/examples/defender-config.test.ts diff --git a/README.md b/README.md index 9974a620..1ef4d300 100644 --- a/README.md +++ b/README.md @@ -412,6 +412,17 @@ const toolset = new StackOneToolSet({ }); ``` +#### Inspecting and observing the resolved mode + +Use the `defenderMode` getter to check how a toolset will behave at runtime: + +```typescript +const toolset = new StackOneToolSet({ apiKey: '...', defender: null }); +toolset.defenderMode; // 'disabled' | 'explicit' | 'project' +``` + +When the SDK overrides the project dashboard (mode `disabled` or `explicit`), it emits a yellow `console.warn` line once per process per distinct override shape so the override is visible at runtime without spamming logs. Pass `NO_COLOR=1` to suppress color, or `FORCE_COLOR=1` to force it when piping output. The `project` mode is silent. + #### Risk levels Defender assigns a risk level to each scanned result: @@ -425,6 +436,8 @@ Defender assigns a risk level to each scanned result: When `blockHighRisk: false` (default), `high` and `critical` results are annotated and returned — the LLM sees the sanitized content. When `blockHighRisk: true`, those results are blocked entirely. +[View full example](examples/defender-config.ts) + For more detail on how the detection pipeline works, see the [`@stackone/defender`](https://www.npmjs.com/package/@stackone/defender) package. ### Testing with dryRun diff --git a/examples/README.md b/examples/README.md index 0b6faba3..3532d932 100644 --- a/examples/README.md +++ b/examples/README.md @@ -74,6 +74,10 @@ Covers five approaches to finding the right tools at runtime: direct fetch with Walks through every way to configure API keys and account IDs: reading from environment variables, passing them explicitly to the constructor, setting multiple accounts with `setAccounts()`, overriding per-tool collection or per individual tool, and fetching tools for multiple accounts in one call. +### [`defender-config.ts`](./defender-config.ts) -- Defender Configuration + +Demonstrates the four ways to configure prompt-injection detection on a `StackOneToolSet`: omit (defer to project dashboard), `{ useProjectSettings: true }` (explicit form of the default), `null` (force off), and explicit config objects. Shows the `defenderMode` getter and the once-per-process warning the SDK emits when it overrides the dashboard. Construction-only — no API key required, no network calls. + ## Environment Variables | Variable | Required | Used By | diff --git a/examples/defender-config.ts b/examples/defender-config.ts new file mode 100644 index 00000000..2738423d --- /dev/null +++ b/examples/defender-config.ts @@ -0,0 +1,112 @@ +/** + * Defender configuration patterns. + * + * Shows the four ways to configure prompt-injection detection on a + * `StackOneToolSet`, the `defenderMode` getter for inspecting resolved + * behavior, and the once-per-process warning the SDK emits when it + * overrides the project dashboard setting. + * + * Construction-only — no API key required, no network calls. + * + * Run with: + * npx tsx examples/defender-config.ts + */ + +import { DEFAULT_DEFENDER_CONFIG, StackOneToolSet, ToolSetConfigError } from '@stackone/ai'; + +const heading = (label: string): void => { + console.log(`\n=== ${label} ===`); +}; + +// --- 1. Default — defer to project dashboard --- +const defaultMode = (): void => { + heading('1. Default (omit defender) — defer to dashboard'); + const toolset = new StackOneToolSet({ apiKey: 'demo-key' }); + console.log(` defenderMode: ${toolset.defenderMode}`); + console.log(' SDK adds no defender_config to the RPC payload.'); +}; + +// --- 2. Explicit form of the default --- +const explicitProject = (): void => { + heading('2. defender: { useProjectSettings: true } — same as default'); + const toolset = new StackOneToolSet({ + apiKey: 'demo-key', + defender: { useProjectSettings: true }, + }); + console.log(` defenderMode: ${toolset.defenderMode}`); +}; + +// --- 3. Force off — overrides dashboard --- +const disabled = (): void => { + heading('3. defender: null — forcibly disabled (overrides dashboard)'); + const toolset = new StackOneToolSet({ apiKey: 'demo-key', defender: null }); + console.log(` defenderMode: ${toolset.defenderMode}`); + console.log(' SDK sends defender_config with all fields false.'); +}; + +// --- 4. Spread defaults + tweak one field --- +const explicitOptIn = (): void => { + heading('4. Spread DEFAULT_DEFENDER_CONFIG + override one field'); + const toolset = new StackOneToolSet({ + apiKey: 'demo-key', + defender: { ...DEFAULT_DEFENDER_CONFIG, blockHighRisk: true }, + }); + console.log(` defenderMode: ${toolset.defenderMode}`); +}; + +// --- 5. Repeat the same shape — dedupe should suppress the warning --- +const repeatedExplicit = (): void => { + heading('5. Repeat the same explicit shape — warning suppressed'); + const toolset = new StackOneToolSet({ + apiKey: 'demo-key', + defender: { ...DEFAULT_DEFENDER_CONFIG, blockHighRisk: true }, + }); + console.log(` defenderMode: ${toolset.defenderMode}`); +}; + +// --- 6. Different explicit shape — fresh warning fires --- +const differentExplicit = (): void => { + heading('6. Different explicit shape — fresh warning'); + const toolset = new StackOneToolSet({ + apiKey: 'demo-key', + defender: { + enabled: true, + blockHighRisk: false, + useTier1Classification: true, + useTier2Classification: false, + }, + }); + console.log(` defenderMode: ${toolset.defenderMode}`); +}; + +// --- 7. Runtime validation --- +const invalidCombo = (): void => { + heading('7. useProjectSettings: true + other fields → throws'); + try { + new StackOneToolSet({ + apiKey: 'demo-key', + // @ts-expect-error - intentionally testing invalid runtime input + defender: { useProjectSettings: true, enabled: true }, + }); + console.log(' (no throw — unexpected!)'); + } catch (err) { + if (err instanceof ToolSetConfigError) { + console.log(` caught ToolSetConfigError: ${err.message}`); + } else { + throw err; + } + } +}; + +// --- Run all sections --- +defaultMode(); +explicitProject(); +disabled(); +explicitOptIn(); +repeatedExplicit(); +differentExplicit(); +invalidCombo(); + +console.log('\nDone — defender patterns demonstrated.'); +console.log('Expect three yellow warnings above: one for mode 3, one for mode 4, one for mode 6.'); +console.log('Modes 1, 2, 5 stay silent (deferring to dashboard, or repeat of mode 4).'); diff --git a/src/index.ts b/src/index.ts index 1a78d801..97269851 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,6 +36,7 @@ export type { AISDKToolDefinition, AISDKToolResult, DefenderConfig, + DefenderMode, ExecuteConfig, ExecuteOptions, JsonObject, diff --git a/src/toolsets.test.ts b/src/toolsets.test.ts index 795cfa9c..74c0f3b4 100644 --- a/src/toolsets.test.ts +++ b/src/toolsets.test.ts @@ -12,7 +12,12 @@ import { type McpToolDefinition, createMcpApp } from '../mocks/mcp-server'; import { server } from '../mocks/node'; import { TEST_BASE_URL } from '../mocks/constants'; import { SemanticSearchError } from './semantic-search'; -import { SearchTool, StackOneToolSet, ToolSetConfigError } from './toolsets'; +import { + SearchTool, + StackOneToolSet, + ToolSetConfigError, + __resetDefenderInfoLog, +} from './toolsets'; describe('StackOneToolSet', () => { beforeEach(() => { @@ -683,6 +688,83 @@ describe('StackOneToolSet', () => { }), ).toThrow(ToolSetConfigError); }); + + describe('defenderMode getter', () => { + it('returns "project" when defender is omitted', () => { + const toolset = new StackOneToolSet({ apiKey: 'test-key' }); + expect(toolset.defenderMode).toBe('project'); + }); + + it('returns "project" when defender is { useProjectSettings: true }', () => { + const toolset = new StackOneToolSet({ + apiKey: 'test-key', + defender: { useProjectSettings: true }, + }); + expect(toolset.defenderMode).toBe('project'); + }); + + it('returns "disabled" when defender is null', () => { + const toolset = new StackOneToolSet({ apiKey: 'test-key', defender: null }); + expect(toolset.defenderMode).toBe('disabled'); + }); + + it('returns "explicit" when defender is an explicit config object', () => { + const toolset = new StackOneToolSet({ + apiKey: 'test-key', + defender: { useTier2Classification: false }, + }); + expect(toolset.defenderMode).toBe('explicit'); + }); + }); + + describe('override info log', () => { + beforeEach(() => { + __resetDefenderInfoLog(); + }); + + it('logs once for disabled mode and dedupes repeat constructions', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + new StackOneToolSet({ apiKey: 'test-key', defender: null }); + new StackOneToolSet({ apiKey: 'test-key', defender: null }); + const disabledCalls = warnSpy.mock.calls.filter((args) => + String(args[0]).includes('forcibly disabled'), + ); + expect(disabledCalls).toHaveLength(1); + warnSpy.mockRestore(); + }); + + it('logs once for an explicit config and dedupes the same shape', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + new StackOneToolSet({ + apiKey: 'test-key', + defender: { enabled: true, useTier1Classification: false, useTier2Classification: false }, + }); + new StackOneToolSet({ + apiKey: 'test-key', + defender: { enabled: true, useTier1Classification: false, useTier2Classification: false }, + }); + const explicitCalls = warnSpy.mock.calls.filter((args) => + String(args[0]).includes('configured via SDK'), + ); + expect(explicitCalls).toHaveLength(1); + expect(String(explicitCalls[0]?.[0])).toContain('useTier1Classification=false'); + warnSpy.mockRestore(); + }); + + it('does not log when defender is omitted or useProjectSettings', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + new StackOneToolSet({ apiKey: 'test-key' }); + new StackOneToolSet({ + apiKey: 'test-key', + defender: { useProjectSettings: true }, + }); + const defenderCalls = warnSpy.mock.calls.filter((args) => + String(args[0]).toLowerCase().includes('defender'), + ); + expect(defenderCalls).toHaveLength(0); + warnSpy.mockRestore(); + }); + }); }); describe('provider and action filtering', () => { diff --git a/src/toolsets.ts b/src/toolsets.ts index 06c2d3e1..ed23f08f 100644 --- a/src/toolsets.ts +++ b/src/toolsets.ts @@ -15,6 +15,7 @@ import { import { BaseTool, Tools } from './tool'; import type { DefenderConfig, + DefenderMode, ExecuteOptions, JsonObject, JsonSchemaProperties, @@ -472,6 +473,58 @@ function usesProjectSettings(config: DefenderConfig): config is { useProjectSett return 'useProjectSettings' in config && config.useProjectSettings === true; } +/** + * Shapes already logged this process, keyed by mode + serialized wire payload. + * Ensures we surface one warning per distinct override shape, not per construction. + */ +const loggedDefenderShapes = new Set(); + +/** + * Test-only: clear the once-per-process dedupe cache for defender override warnings. + * @internal + */ +export function __resetDefenderInfoLog(): void { + loggedDefenderShapes.clear(); +} + +/** Wrap text in yellow ANSI, only when stderr is a TTY and color isn't suppressed. */ +function colorizeOverrideWarning(text: string): string { + if (process.env.NO_COLOR) return text; + if (!process.env.FORCE_COLOR && !process.stderr.isTTY) return text; + return `\x1b[33m${text}\x1b[0m`; +} + +/** + * Warn once when the SDK overrides the project dashboard's defender setting. + * Silent for `project` mode (no override) and for repeat constructions with the same shape. + */ +function logDefenderOverride( + config: DefenderConfig | null, + wireFields: { defender_config: DefenderApiConfig } | Record, +): void { + if (config === null) { + const key = 'disabled'; + if (loggedDefenderShapes.has(key)) return; + loggedDefenderShapes.add(key); + console.warn( + colorizeOverrideWarning( + 'Defender forcibly disabled via SDK config; project dashboard setting will be ignored.', + ), + ); + return; + } + if (usesProjectSettings(config)) return; + const key = `explicit:${JSON.stringify(wireFields)}`; + if (loggedDefenderShapes.has(key)) return; + loggedDefenderShapes.add(key); + const fields = (wireFields as { defender_config: DefenderApiConfig }).defender_config; + console.warn( + colorizeOverrideWarning( + `Defender configured via SDK (enabled=${fields.enabled}, blockHighRisk=${fields.block_high_risk}, useTier1Classification=${fields.use_tier1_classification}, useTier2Classification=${fields.use_tier2_classification}); project dashboard setting will be ignored.`, + ), + ); +} + /** * Map SDK DefenderConfig to the wire-format sent in the RPC body. * @@ -599,6 +652,7 @@ export class StackOneToolSet { this.defenderConfig = defenderInput === undefined ? { useProjectSettings: true } : defenderInput; this.defenderFields = buildDefenderFields(this.defenderConfig); + logDefenderOverride(this.defenderConfig, this.defenderFields); // Set Authentication headers if provided if (this.authentication) { @@ -640,6 +694,19 @@ export class StackOneToolSet { private catalogCache: Map = new Map(); private toolIndexCache?: { tools: Tools; index: ToolIndex }; + /** + * Resolved defender behavior for this toolset. + * + * - `'project'` — SDK adds no `defender_config` to the RPC payload; the project dashboard controls. + * - `'disabled'` — SDK forces defender off (overrides the dashboard). + * - `'explicit'` — SDK sends an explicit `defender_config` (overrides the dashboard). + */ + get defenderMode(): DefenderMode { + if (this.defenderConfig === null) return 'disabled'; + if (usesProjectSettings(this.defenderConfig)) return 'project'; + return 'explicit'; + } + /** * Set account IDs for filtering tools * @param accountIds Array of account IDs to filter tools by diff --git a/src/types.ts b/src/types.ts index 23fa9735..2b2ff738 100644 --- a/src/types.ts +++ b/src/types.ts @@ -286,3 +286,12 @@ export const DEFAULT_DEFENDER_CONFIG = { useTier1Classification: true, useTier2Classification: true, } as const; + +/** + * Resolved defender behavior on a `StackOneToolSet`. + * + * - `'project'` — SDK adds no `defender_config` to the RPC payload; the project dashboard controls. + * - `'disabled'` — SDK forces defender off, overriding the dashboard. + * - `'explicit'` — SDK sends an explicit `defender_config`, overriding the dashboard. + */ +export type DefenderMode = 'project' | 'disabled' | 'explicit'; diff --git a/tests/examples/defender-config.test.ts b/tests/examples/defender-config.test.ts new file mode 100644 index 00000000..47e4e190 --- /dev/null +++ b/tests/examples/defender-config.test.ts @@ -0,0 +1,131 @@ +/** + * E2E test for defender-config.ts example. + * + * Exercises the same defender configuration patterns the example + * demonstrates and asserts the resulting wire payloads via dryRun. + * Construction-time `defenderMode` and override-warning behavior is + * covered in `src/toolsets.test.ts`. + */ + +import { TEST_BASE_URL } from '../../mocks/constants'; +import { DEFAULT_DEFENDER_CONFIG, StackOneToolSet, ToolSetConfigError } from '../../src'; + +describe('defender-config example e2e', () => { + beforeEach(() => { + vi.stubEnv('STACKONE_API_KEY', 'test-key'); + // Silence override warnings so they don't pollute test output. + vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + const fetchDummyTool = async (toolset: StackOneToolSet) => { + const tools = await toolset.fetchTools(); + const tool = tools.toArray().find((t) => t.name === 'dummy_action'); + assert(tool, 'dummy_action tool should be defined in mocks'); + return tool; + }; + + it('omits defender_config when defender is not passed (mode 1)', async () => { + const toolset = new StackOneToolSet({ + baseUrl: TEST_BASE_URL, + accountId: 'test-account', + }); + expect(toolset.defenderMode).toBe('project'); + + const tool = await fetchDummyTool(toolset); + const result = await tool.execute({ body: { name: 'test' } }, { dryRun: true }); + const parsedBody = JSON.parse(result.body as string); + expect(parsedBody).not.toHaveProperty('defender_config'); + }); + + it('omits defender_config when defender is { useProjectSettings: true } (mode 2)', async () => { + const toolset = new StackOneToolSet({ + baseUrl: TEST_BASE_URL, + accountId: 'test-account', + defender: { useProjectSettings: true }, + }); + expect(toolset.defenderMode).toBe('project'); + + const tool = await fetchDummyTool(toolset); + const result = await tool.execute({ body: { name: 'test' } }, { dryRun: true }); + const parsedBody = JSON.parse(result.body as string); + expect(parsedBody).not.toHaveProperty('defender_config'); + }); + + it('sends all-false defender_config when defender is null (mode 3)', async () => { + const toolset = new StackOneToolSet({ + baseUrl: TEST_BASE_URL, + accountId: 'test-account', + defender: null, + }); + expect(toolset.defenderMode).toBe('disabled'); + + const tool = await fetchDummyTool(toolset); + const result = await tool.execute({ body: { name: 'test' } }, { dryRun: true }); + const parsedBody = JSON.parse(result.body as string); + expect(parsedBody.defender_config).toEqual({ + enabled: false, + block_high_risk: false, + use_tier1_classification: false, + use_tier2_classification: false, + }); + }); + + it('applies DEFAULT_DEFENDER_CONFIG fallbacks with explicit overrides (mode 4)', async () => { + const toolset = new StackOneToolSet({ + baseUrl: TEST_BASE_URL, + accountId: 'test-account', + defender: { ...DEFAULT_DEFENDER_CONFIG, blockHighRisk: true }, + }); + expect(toolset.defenderMode).toBe('explicit'); + + const tool = await fetchDummyTool(toolset); + const result = await tool.execute({ body: { name: 'test' } }, { dryRun: true }); + const parsedBody = JSON.parse(result.body as string); + expect(parsedBody.defender_config).toEqual({ + enabled: true, + block_high_risk: true, + use_tier1_classification: true, + use_tier2_classification: true, + }); + }); + + it('sends the exact explicit fields for a fully specified config (mode 6)', async () => { + const toolset = new StackOneToolSet({ + baseUrl: TEST_BASE_URL, + accountId: 'test-account', + defender: { + enabled: true, + blockHighRisk: false, + useTier1Classification: true, + useTier2Classification: false, + }, + }); + expect(toolset.defenderMode).toBe('explicit'); + + const tool = await fetchDummyTool(toolset); + const result = await tool.execute({ body: { name: 'test' } }, { dryRun: true }); + const parsedBody = JSON.parse(result.body as string); + expect(parsedBody.defender_config).toEqual({ + enabled: true, + block_high_risk: false, + use_tier1_classification: true, + use_tier2_classification: false, + }); + }); + + it('throws ToolSetConfigError when useProjectSettings is combined with other fields (mode 7)', () => { + expect( + () => + new StackOneToolSet({ + apiKey: 'demo-key', + // @ts-expect-error - intentionally testing invalid runtime input + defender: { useProjectSettings: true, enabled: true }, + }), + ).toThrow(ToolSetConfigError); + }); +}); From 60f7d6d5b33968699c828a799ac1cc4040352c32 Mon Sep 17 00:00:00 2001 From: Hisku Date: Tue, 12 May 2026 15:27:42 +0100 Subject: [PATCH 20/22] docs(defender): add live RPC section + document defenderMetadata response shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the defender example to runtime parity with the other examples (auth-management, openai-integration, etc. — all make real network calls when env vars are set). The construction-only sections 1–7 stay; section 8 makes a live `tool.execute()` and prints the defender annotations the backend returns. Captured shape from a real call: `defenderMetadata` is a sibling of `data` at the top level of the RPC response, with: - `applied: boolean` - `result.allowed: boolean` (false → blocked when blockHighRisk) - `result.riskLevel: 'low' | 'medium' | 'high' | 'critical'` - `result.fieldsSanitized: string[]` - `result.patternsByField: Record` - `result.detections: unknown[]` - `result.tier2SkipReason?: string` (when Tier 2 didn't run) - `result.latencyMs: number` Documented inline in `examples/defender-config.ts` as a comment so readers can copy the access pattern. The cast `(result as { defenderMetadata?: unknown }).defenderMetadata` is the current ergonomic — a typed accessor would be a future SDK refinement. Changes: - `examples/defender-config.ts` — new section 8 gated on `STACKONE_API_KEY`, with TOOL_NAME/TOOL_BODY_JSON env-var knobs, try/catch around `JSON.parse`, and an inline shape comment. - `mocks/handlers.stackone-rpc.ts` — default echo handler now mirrors the real backend by adding `defenderMetadata` whenever the request contains `defender_config` (`applied: false` when `defender_config.enabled === false`, matching the forced-off case). - `tests/examples/defender-config.test.ts` — new test asserting the `defenderMetadata` shape (applied/allowed/riskLevel/fieldsSanitized) so the access pattern is locked in. --- examples/defender-config.ts | 80 +++++++++++++++++++++++--- mocks/handlers.stackone-rpc.ts | 16 ++++++ tests/examples/defender-config.test.ts | 24 ++++++++ 3 files changed, 113 insertions(+), 7 deletions(-) diff --git a/examples/defender-config.ts b/examples/defender-config.ts index 2738423d..a0c766c7 100644 --- a/examples/defender-config.ts +++ b/examples/defender-config.ts @@ -1,17 +1,26 @@ /** * Defender configuration patterns. * - * Shows the four ways to configure prompt-injection detection on a - * `StackOneToolSet`, the `defenderMode` getter for inspecting resolved - * behavior, and the once-per-process warning the SDK emits when it - * overrides the project dashboard setting. + * Sections 1–7 are construction-only: the four configuration modes, + * the `defenderMode` getter, the once-per-process override warning, + * and the runtime validation error. No API key required. * - * Construction-only — no API key required, no network calls. + * Section 8 makes a live RPC call so you can see the defender + * annotations the backend returns. It's gated on `STACKONE_API_KEY` + * and skips with a friendly message if you don't have one set. * * Run with: * npx tsx examples/defender-config.ts + * + * Live section env vars: + * STACKONE_API_KEY (required to run section 8) + * STACKONE_ACCOUNT_ID (required for tool execution unless your key has a default account) + * TOOL_NAME (defaults to gmail_list_messages) + * TOOL_BODY_JSON (JSON body, defaults to `{}`) */ +import process from 'node:process'; +import type { JsonObject } from '@stackone/ai'; import { DEFAULT_DEFENDER_CONFIG, StackOneToolSet, ToolSetConfigError } from '@stackone/ai'; const heading = (label: string): void => { @@ -98,6 +107,64 @@ const invalidCombo = (): void => { } }; +// --- 8. Live tool call — inspect defender annotations in the real response --- +const liveCall = async (): Promise => { + heading('8. Live tool call — inspect defender annotations'); + + if (!process.env.STACKONE_API_KEY) { + console.log(' Skipping — set STACKONE_API_KEY to run this section.'); + console.log(' Optional: STACKONE_ACCOUNT_ID, TOOL_NAME, TOOL_BODY_JSON.'); + return; + } + + const toolName = process.env.TOOL_NAME ?? 'gmail_list_messages'; + let body: JsonObject; + try { + body = JSON.parse(process.env.TOOL_BODY_JSON ?? '{}') as JsonObject; + } catch (err) { + console.log(` Invalid TOOL_BODY_JSON: ${(err as Error).message}`); + return; + } + + const toolset = new StackOneToolSet({ + defender: { ...DEFAULT_DEFENDER_CONFIG, blockHighRisk: false }, + }); + + console.log(` Fetching tools and calling ${toolName}...`); + const tools = await toolset.fetchTools(); + const tool = tools.toArray().find((t) => t.name === toolName); + if (!tool) { + console.log(` Tool "${toolName}" not in this account.`); + return; + } + + const result = await tool.execute({ body }); + + // The backend surfaces defender annotations alongside the tool data: + // + // { + // data: , + // defenderMetadata: { + // applied: boolean, // false if defender ran but did nothing + // result: { + // allowed: boolean, // false → backend blocked (with blockHighRisk: true) + // riskLevel: 'low' | 'medium' | 'high' | 'critical', + // fieldsSanitized: string[], + // patternsByField: Record, + // detections: unknown[], + // tier2SkipReason?: string, // only when Tier 2 didn't run (e.g. no strings) + // latencyMs: number, + // } + // } + // } + const metadata = (result as { defenderMetadata?: unknown }).defenderMetadata; + if (metadata) { + console.log(' defenderMetadata:', JSON.stringify(metadata, null, 2)); + } else { + console.log(' (no defenderMetadata in response — defender may not have run)'); + } +}; + // --- Run all sections --- defaultMode(); explicitProject(); @@ -106,7 +173,6 @@ explicitOptIn(); repeatedExplicit(); differentExplicit(); invalidCombo(); +await liveCall(); console.log('\nDone — defender patterns demonstrated.'); -console.log('Expect three yellow warnings above: one for mode 3, one for mode 4, one for mode 6.'); -console.log('Modes 1, 2, 5 stay silent (deferring to dashboard, or repeat of mode 4).'); diff --git a/mocks/handlers.stackone-rpc.ts b/mocks/handlers.stackone-rpc.ts index ba86a1da..3ed0bed6 100644 --- a/mocks/handlers.stackone-rpc.ts +++ b/mocks/handlers.stackone-rpc.ts @@ -76,6 +76,21 @@ export const stackoneRpcHandlers = [ ); } + // Synthetic defender annotations + const defenderMetadata = body.defender_config + ? { + applied: body.defender_config.enabled !== false, + result: { + allowed: true, + riskLevel: 'low', + fieldsSanitized: [], + patternsByField: {}, + detections: [], + latencyMs: 0, + }, + } + : undefined; + // Default response for other actions — echo back received fields return HttpResponse.json({ data: { @@ -88,6 +103,7 @@ export const stackoneRpcHandlers = [ query: body.query, }, }, + ...(defenderMetadata ? { defenderMetadata } : {}), }); }), ]; diff --git a/tests/examples/defender-config.test.ts b/tests/examples/defender-config.test.ts index 47e4e190..13368652 100644 --- a/tests/examples/defender-config.test.ts +++ b/tests/examples/defender-config.test.ts @@ -128,4 +128,28 @@ describe('defender-config example e2e', () => { }), ).toThrow(ToolSetConfigError); }); + + it('surfaces defenderMetadata alongside data in live RPC responses (mode 8)', async () => { + const toolset = new StackOneToolSet({ + baseUrl: TEST_BASE_URL, + accountId: 'test-account', + defender: { ...DEFAULT_DEFENDER_CONFIG, blockHighRisk: false }, + }); + + const tools = await toolset.fetchTools(); + const tool = tools.toArray().find((t) => t.name === 'dummy_action'); + assert(tool, 'dummy_action tool should be defined in mocks'); + + const result = await tool.execute({ body: { name: 'test' } }); + const metadata = (result as { defenderMetadata?: Record }).defenderMetadata; + + expect(metadata).toBeDefined(); + assert(metadata, 'defenderMetadata should be defined'); + expect(metadata.applied).toBe(true); + expect(metadata.result).toMatchObject({ + allowed: true, + riskLevel: expect.stringMatching(/^(low|medium|high|critical)$/), + fieldsSanitized: expect.any(Array), + }); + }); }); From 5660317d18687e74294a544624460e83a12770a1 Mon Sep 17 00:00:00 2001 From: Hisku Date: Tue, 12 May 2026 16:05:44 +0100 Subject: [PATCH 21/22] docs(examples): correct defender-config description after live-section addition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The examples-index bullet still said "Construction-only — no API key required, no network calls", which became inaccurate when section 8 (live RPC call gated on STACKONE_API_KEY) was added in 60f7d6d. Now distinguishes sections 1–7 (construction-only) from section 8 (live, opt-in via env). Flagged by Copilot on PR #328. --- examples/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/README.md b/examples/README.md index 3532d932..7793ff3a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -76,7 +76,7 @@ Walks through every way to configure API keys and account IDs: reading from envi ### [`defender-config.ts`](./defender-config.ts) -- Defender Configuration -Demonstrates the four ways to configure prompt-injection detection on a `StackOneToolSet`: omit (defer to project dashboard), `{ useProjectSettings: true }` (explicit form of the default), `null` (force off), and explicit config objects. Shows the `defenderMode` getter and the once-per-process warning the SDK emits when it overrides the dashboard. Construction-only — no API key required, no network calls. +Demonstrates the four ways to configure prompt-injection detection on a `StackOneToolSet`: omit (defer to project dashboard), `{ useProjectSettings: true }` (explicit form of the default), `null` (force off), and explicit config objects. Shows the `defenderMode` getter and the once-per-process warning the SDK emits when it overrides the dashboard. Sections 1–7 are construction-only; section 8 makes a live RPC call when `STACKONE_API_KEY` is set so you can inspect the `defenderMetadata` shape the backend returns. ## Environment Variables From 1c6324dfe907e3839b4083bb6196d0843e4202bc Mon Sep 17 00:00:00 2001 From: Hisku Date: Wed, 13 May 2026 16:49:57 +0100 Subject: [PATCH 22/22] docs(defender-config example): show env-var override invocations Updates the header docstring on examples/defender-config.ts to make the override pattern explicit, per glebedel's review on PR #328. - Switches the documented "Run with" command to `pnpm run:example` (the project's standard runner, which also auto-loads .env). - Adds two concrete override invocations showing how to point section 8 at a different connector and tool via TOOL_NAME / TOOL_BODY_JSON. - Notes that STACKONE_API_KEY can come from .env when using run:example. --- examples/defender-config.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/examples/defender-config.ts b/examples/defender-config.ts index a0c766c7..506d7e38 100644 --- a/examples/defender-config.ts +++ b/examples/defender-config.ts @@ -10,10 +10,14 @@ * and skips with a friendly message if you don't have one set. * * Run with: - * npx tsx examples/defender-config.ts + * pnpm run:example examples/defender-config.ts + * + * Or override the connector and tool for section 8: + * TOOL_NAME=calendly_get_current_user TOOL_BODY_JSON='{}' pnpm run:example examples/defender-config.ts + * TOOL_NAME=hibob_list_employees TOOL_BODY_JSON='{"page_size": 5}' pnpm run:example examples/defender-config.ts * * Live section env vars: - * STACKONE_API_KEY (required to run section 8) + * STACKONE_API_KEY (required to run section 8; read from .env via run:example) * STACKONE_ACCOUNT_ID (required for tool execution unless your key has a default account) * TOOL_NAME (defaults to gmail_list_messages) * TOOL_BODY_JSON (JSON body, defaults to `{}`)