From 696a473994976f2b4e00fab13e592059ce5057cd Mon Sep 17 00:00:00 2001 From: Hisku Date: Thu, 19 Mar 2026 11:15:58 +0000 Subject: [PATCH 01/10] 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 f890e59..2208384 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,6 +32,7 @@ export { export type { AISDKToolDefinition, AISDKToolResult, + DefenderConfig, ExecuteConfig, ExecuteOptions, JsonObject, diff --git a/src/rpc-client.ts b/src/rpc-client.ts index ceb90dc..45e5ed8 100644 --- a/src/rpc-client.ts +++ b/src/rpc-client.ts @@ -50,10 +50,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 17f7f80..ab23633 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 18a1324..ce09829 100644 --- a/src/toolsets.ts +++ b/src/toolsets.ts @@ -13,6 +13,7 @@ import { } from './semantic-search'; import { BaseTool, Tools } from './tool'; import type { + DefenderConfig, ExecuteOptions, JsonObject, JsonSchemaProperties, @@ -141,6 +142,13 @@ interface StackOneToolSetBaseConfig extends BaseToolSetConfig { * Per-call options always override these defaults. */ search?: SearchConfig | null; + /** + * 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; } /** @@ -261,6 +269,7 @@ export class StackOneToolSet { private headers: Record; private rpcClient?: RpcClient; private readonly searchConfig: SearchConfig | null; + private readonly defenderConfig?: DefenderConfig; /** * Account ID for StackOne API @@ -320,6 +329,8 @@ export class StackOneToolSet { // Resolve search config: undefined → defaults, null → disabled, object → custom this.searchConfig = config?.search === null ? null : { method: 'auto', ...config?.search }; + 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 @@ -965,6 +976,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 ba3ebf5..94033ea 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 ea6e4f1960d7ffe7885c521e03987d351a845290 Mon Sep 17 00:00:00 2001 From: Hisku Date: Thu, 19 Mar 2026 11:49:10 +0000 Subject: [PATCH 02/10] 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 804e9c2..b65ee06 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 6d69f15..1846bb5 100644 --- a/src/rpc-client.test.ts +++ b/src/rpc-client.test.ts @@ -128,3 +128,50 @@ test('should send x-account-id as HTTP header', async () => { bodyHeader: 'test-account-123', }); }); + +test('should forward defender_enabled: true in request payload', async () => { + const client = new RpcClient({ + serverURL: TEST_BASE_URL, + security: { username: 'test-api-key' }, + }); + + const response = await client.actions.rpcAction({ + action: 'custom_action', + defender_enabled: true, + }); + + expect(response.data).toMatchObject({ + received: { defender_enabled: true }, + }); +}); + +test('should forward defender_enabled: false in request payload', async () => { + const client = new RpcClient({ + serverURL: TEST_BASE_URL, + security: { username: 'test-api-key' }, + }); + + const response = await client.actions.rpcAction({ + action: 'custom_action', + defender_enabled: false, + }); + + expect(response.data).toMatchObject({ + received: { defender_enabled: false }, + }); +}); + +test('should omit defender_enabled from payload when not provided', async () => { + const client = new RpcClient({ + serverURL: TEST_BASE_URL, + security: { username: 'test-api-key' }, + }); + + const response = await client.actions.rpcAction({ + action: 'custom_action', + }); + + expect((response.data as Record).received).not.toHaveProperty( + 'defender_enabled', + ); +}); diff --git a/src/toolsets.test.ts b/src/toolsets.test.ts index 8021c70..f285f5a 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 ce09829..6fc4d9a 100644 --- a/src/toolsets.ts +++ b/src/toolsets.ts @@ -144,7 +144,11 @@ interface StackOneToolSetBaseConfig extends BaseToolSetConfig { search?: SearchConfig | null; /** * 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 */ @@ -959,6 +963,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 7b449d11b9f4ef3fde1a499aeac08aac46babae3 Mon Sep 17 00:00:00 2001 From: Hisku Date: Thu, 19 Mar 2026 15:53:04 +0000 Subject: [PATCH 03/10] 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 | 52 +++++++++++++++++++++++++++++++----------- src/types.ts | 57 +++++++++++++++++++++++++++-------------------- 5 files changed, 89 insertions(+), 40 deletions(-) diff --git a/src/index.ts b/src/index.ts index 2208384..6a1671c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,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 45e5ed8..5da37f5 100644 --- a/src/rpc-client.ts +++ b/src/rpc-client.ts @@ -50,9 +50,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 ab23633..d05451b 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 6fc4d9a..9533527 100644 --- a/src/toolsets.ts +++ b/src/toolsets.ts @@ -21,6 +21,7 @@ import type { SearchConfig, ToolParameters, } from './types'; +import { DEFAULT_DEFENDER_CONFIG } from './types'; import { StackOneError } from './utils/error-stackone'; import { normalizeActionName } from './utils/normalize'; @@ -143,16 +144,14 @@ interface StackOneToolSetBaseConfig extends BaseToolSetConfig { */ search?: SearchConfig | null; /** - * 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; } /** @@ -273,7 +272,7 @@ export class StackOneToolSet { private headers: Record; private rpcClient?: RpcClient; private readonly searchConfig: SearchConfig | null; - private readonly defenderConfig?: DefenderConfig; + private readonly defenderConfig: DefenderConfig | null; /** * Account ID for StackOne API @@ -333,7 +332,20 @@ export class StackOneToolSet { // Resolve search config: undefined → defaults, null → disabled, object → custom this.searchConfig = config?.search === null ? null : { method: 'auto', ...config?.search }; - 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) { @@ -980,12 +992,26 @@ export class StackOneToolSet { } satisfies JsonObject; } + 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, + }; + 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 94033ea..0f672d5 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 b0ec446e730605afeddc5bc0fce11a9d5a32b108 Mon Sep 17 00:00:00 2001 From: Hisku Date: Thu, 19 Mar 2026 16:48:16 +0000 Subject: [PATCH 04/10] 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 | 36 +++++++++++++++++------------------- 2 files changed, 29 insertions(+), 23 deletions(-) diff --git a/src/toolsets.test.ts b/src/toolsets.test.ts index f285f5a..a91ff04 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 9533527..b006e52 100644 --- a/src/toolsets.ts +++ b/src/toolsets.ts @@ -971,13 +971,27 @@ export class StackOneToolSet { rpcBody[key] = value as JsonObject[string]; } + 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, + }; + 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, @@ -992,22 +1006,6 @@ export class StackOneToolSet { } satisfies JsonObject; } - 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, - }; - const response = await actionsClient.actions.rpcAction({ action: name, body: rpcBody, From f3f21e917325bce3c1d03017536f05d25d2d8e0b Mon Sep 17 00:00:00 2001 From: Hisku Date: Thu, 19 Mar 2026 16:55:08 +0000 Subject: [PATCH 05/10] 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 b006e52..d006145 100644 --- a/src/toolsets.ts +++ b/src/toolsets.ts @@ -972,20 +972,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 d2943ac1981e97e24312d316a73eecce8de26f47 Mon Sep 17 00:00:00 2001 From: Hisku Date: Fri, 20 Mar 2026 10:47:31 +0000 Subject: [PATCH 06/10] 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 d006145..6d0aede 100644 --- a/src/toolsets.ts +++ b/src/toolsets.ts @@ -346,6 +346,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 6dc5766707f2c8f86363ae4f67b28cf7a2e53c72 Mon Sep 17 00:00:00 2001 From: Hisku Date: Fri, 20 Mar 2026 11:10:32 +0000 Subject: [PATCH 07/10] 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 6d0aede..3bfdb00 100644 --- a/src/toolsets.ts +++ b/src/toolsets.ts @@ -337,8 +337,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 02bf4a2a4c54c087d0154502c17e97f42833c29b Mon Sep 17 00:00:00 2001 From: Hisku Date: Mon, 23 Mar 2026 12:03:33 +0000 Subject: [PATCH 08/10] 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 b65ee06..19ccb69 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 5da37f5..5b34931 100644 --- a/src/rpc-client.ts +++ b/src/rpc-client.ts @@ -53,14 +53,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 d05451b..cc3b936 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 a91ff04..142971e 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 3bfdb00..3795f9b 100644 --- a/src/toolsets.ts +++ b/src/toolsets.ts @@ -987,21 +987,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 bea8b05b10d68404d75ef046edf81280f67cd1b5 Mon Sep 17 00:00:00 2001 From: Hisku Date: Mon, 23 Mar 2026 12:46:50 +0000 Subject: [PATCH 09/10] 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 19ccb69..496899e 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 d35d4a95eafccc1842da58c043e3ca505fe1deab Mon Sep 17 00:00:00 2001 From: Hisku Date: Mon, 23 Mar 2026 13:52:03 +0000 Subject: [PATCH 10/10] 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 cc3b936..a15beeb 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()),