Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion mocks/handlers.stackone-rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ export const stackoneRpcHandlers = [
const body = (await request.json()) as {
action?: string;
body?: Record<string, unknown>;
defender_enabled?: boolean;
defender_config?: {
enabled?: boolean;
block_high_risk?: boolean;
use_tier1_classification?: boolean;
use_tier2_classification?: boolean;
};
headers?: Record<string, string>;
path?: Record<string, string>;
query?: Record<string, string>;
Expand Down Expand Up @@ -70,12 +77,14 @@ export const stackoneRpcHandlers = [
);
}

// Default response for other actions
// Default response for other actions — echo back received fields
return HttpResponse.json({
data: {
action: body.action,
received: {
body: body.body,
defender_config: body.defender_config,
defender_enabled: body.defender_enabled,
headers: body.headers,
path: body.path,
query: body.query,
Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,12 @@ export {
type SemanticSearchResult,
} from './semantic-search';

export { DEFAULT_DEFENDER_CONFIG } from './types';

export type {
AISDKToolDefinition,
AISDKToolResult,
DefenderConfig,
ExecuteConfig,
ExecuteOptions,
JsonObject,
Expand Down
47 changes: 47 additions & 0 deletions src/rpc-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>).received).not.toHaveProperty(
'defender_enabled',
);
});
8 changes: 7 additions & 1 deletion src/rpc-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,16 @@ export class RpcClient {
const requestBody = {
action: validatedRequest.action,
body: validatedRequest.body,
...(validatedRequest.defender_enabled !== undefined && {
defender_enabled: validatedRequest.defender_enabled,
}),
...(validatedRequest.defender_config !== undefined && {
defender_config: validatedRequest.defender_config,
}),
headers: validatedRequest.headers,
path: validatedRequest.path,
query: validatedRequest.query,
} as const satisfies RpcActionRequest;
} satisfies RpcActionRequest;

// Forward StackOne-specific headers as HTTP headers
const requestHeaders = validatedRequest.headers;
Expand Down
13 changes: 13 additions & 0 deletions src/schema.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
import { z } from 'zod/v4-mini';
import { stackOneHeadersSchema } from './headers';

/**
* Zod schema for nested defender configuration sent with each RPC request
*/
const defenderConfigRequestSchema = z.object({
enabled: z.optional(z.boolean()),
block_high_risk: z.optional(z.boolean()),
use_tier1_classification: z.optional(z.boolean()),
use_tier2_classification: z.optional(z.boolean()),
});

/**
* Zod schema for RPC action request validation
* @see https://docs.stackone.com/platform/api-reference/actions/make-an-rpc-call-to-an-action
*/
export const rpcActionRequestSchema = z.object({
action: z.string(),
body: z.optional(z.record(z.string(), z.unknown())),
/** @deprecated use defender_config instead */
defender_enabled: z.optional(z.boolean()),
defender_config: z.optional(defenderConfigRequestSchema),
headers: z.optional(stackOneHeadersSchema),
path: z.optional(z.record(z.string(), z.unknown())),
query: z.optional(z.record(z.string(), z.unknown())),
Expand Down
81 changes: 81 additions & 0 deletions src/toolsets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,87 @@ describe('StackOneToolSet', () => {
});
});

describe('defender config', () => {
it('should store defender config from constructor', () => {
const toolset = new StackOneToolSet({
apiKey: 'test-key',
defender: { enabled: false },
});

// @ts-expect-error - Accessing private property for testing
expect(toolset.defenderConfig).toEqual({ enabled: false });
});

it('should set defenderConfig to SDK defaults when not provided', () => {
const toolset = new StackOneToolSet({ apiKey: 'test-key' });

// @ts-expect-error - Accessing private property for testing
expect(toolset.defenderConfig).toEqual({
enabled: true,
blockHighRisk: false,
useTier1Classification: true,
useTier2Classification: true,
});
});

it('should include defender_config in dryRun payload when defender.enabled is set', async () => {
const toolset = new StackOneToolSet({
baseUrl: TEST_BASE_URL,
apiKey: 'test-key',
accountId: 'test-account',
defender: { enabled: false },
});

const tools = await toolset.fetchTools();
const tool = tools.toArray().find((t) => t.name === 'dummy_action');
assert(tool, 'tool should be defined');

const result = await tool.execute({ body: { name: 'test' } }, { dryRun: true });

const parsedBody = JSON.parse(result.body as string);
expect(parsedBody.defender_config.enabled).toBe(false);
});

it('should include SDK default defender_config in dryRun payload when defender config is not set', async () => {
const toolset = new StackOneToolSet({
baseUrl: TEST_BASE_URL,
apiKey: 'test-key',
accountId: 'test-account',
});

const tools = await toolset.fetchTools();
const tool = tools.toArray().find((t) => t.name === 'dummy_action');
assert(tool, 'tool should be defined');

const result = await tool.execute({ body: { name: 'test' } }, { dryRun: true });

const parsedBody = JSON.parse(result.body as string);
expect(parsedBody.defender_config.enabled).toBe(true);
expect(parsedBody.defender_config.block_high_risk).toBe(false);
expect(parsedBody.defender_config.use_tier1_classification).toBe(true);
expect(parsedBody.defender_config.use_tier2_classification).toBe(true);
});

it('should forward defender_config in live RPC call when defender.enabled is set', async () => {
const toolset = new StackOneToolSet({
baseUrl: TEST_BASE_URL,
apiKey: 'test-key',
accountId: 'test-account',
defender: { enabled: true },
});

const tools = await toolset.fetchTools();
const tool = tools.toArray().find((t) => t.name === 'dummy_action');
assert(tool, 'tool should be defined');

const result = await tool.execute({ body: { name: 'test' } });

expect(result).toMatchObject({
data: { received: { defender_config: { enabled: true } } },
});
});
});

describe('provider and action filtering', () => {
it('filters tools by providers', async () => {
const toolset = new StackOneToolSet({
Expand Down
75 changes: 75 additions & 0 deletions src/toolsets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ import {
} from './semantic-search';
import { BaseTool, Tools } from './tool';
import type {
DefenderConfig,
ExecuteOptions,
JsonObject,
JsonSchemaProperties,
RpcExecuteConfig,
SearchConfig,
ToolParameters,
} from './types';
import { DEFAULT_DEFENDER_CONFIG } from './types';
import { StackOneError } from './utils/error-stackone';
import { normalizeActionName } from './utils/normalize';

Expand Down Expand Up @@ -141,6 +143,15 @@ interface StackOneToolSetBaseConfig extends BaseToolSetConfig {
* Per-call options always override these defaults.
*/
search?: SearchConfig | null;
/**
* Defender configuration. Controls prompt injection detection behavior for all tool calls.
*
* - Omit or pass `undefined` → SDK defaults apply: defender enabled, outputs never blocked
* - Pass `null` → defender explicitly disabled for all tool calls
* - Pass `{ useProjectSettings: true }` → defer to the project settings configured in the dashboard
* - Pass `{ enabled, blockHighRisk, ... }` → explicit SDK-level config, ignores project settings
*/
defender?: DefenderConfig | null;
}

/**
Expand Down Expand Up @@ -261,6 +272,7 @@ export class StackOneToolSet {
private headers: Record<string, string>;
private rpcClient?: RpcClient;
private readonly searchConfig: SearchConfig | null;
private readonly defenderConfig: DefenderConfig | null;

/**
* Account ID for StackOne API
Expand Down Expand Up @@ -320,6 +332,35 @@ export class StackOneToolSet {
// Resolve search config: undefined → defaults, null → disabled, object → custom
this.searchConfig = config?.search === null ? null : { method: 'auto', ...config?.search };

// Resolve defender config:
// undefined → SDK defaults (enabled, not blocking)
// null → explicitly disabled
// object → validate then store as-is
const defenderInput = config?.defender;
if (
defenderInput != null &&
'useProjectSettings' in defenderInput &&
defenderInput.useProjectSettings === true
) {
const { useProjectSettings: _, ...rest } = defenderInput as {
useProjectSettings: true;
} & Record<string, unknown>;
if (Object.keys(rest).length > 0) {
throw new ToolSetConfigError(
'Cannot combine useProjectSettings: true with explicit defender options. Use one or the other.',
);
}
}
this.defenderConfig = defenderInput === undefined ? DEFAULT_DEFENDER_CONFIG : defenderInput;
if (defenderInput === undefined) {
console.warn(
'[StackOneToolSet] No defender config provided. SDK defaults are active and will override any ' +
'project-level defender settings. To use your project settings, pass ' +
'`defender: { useProjectSettings: true }`. To suppress this warning, pass an explicit ' +
'`defender` config.',
);
}

// Set Authentication headers if provided
if (this.authentication) {
// Only set auth headers if they don't already exist in custom headers
Expand Down Expand Up @@ -944,10 +985,43 @@ export class StackOneToolSet {
rpcBody[key] = value as JsonObject[string];
}

const defender = this.defenderConfig;
let defenderFields: Partial<{
defender_config: {
enabled: boolean;
block_high_risk: boolean;
use_tier1_classification: boolean;
use_tier2_classification: boolean;
};
}> = {};
if (defender === null) {
// null → explicitly disable
defenderFields = {
defender_config: {
enabled: false,
block_high_risk: false,
use_tier1_classification: false,
use_tier2_classification: false,
},
};
} else if (!('useProjectSettings' in defender) || !defender.useProjectSettings) {
// SDK-level config (default or explicit)
defenderFields = {
defender_config: {
enabled: defender.enabled ?? true,
block_high_risk: defender.blockHighRisk ?? false,
use_tier1_classification: defender.useTier1Classification ?? true,
use_tier2_classification: defender.useTier2Classification ?? true,
},
};
}
// else: useProjectSettings: true → send nothing, backend uses project settings

if (options?.dryRun) {
const requestPayload = {
action: name,
body: rpcBody,
...defenderFields,
headers: actionHeaders,
path: pathParams ?? undefined,
query: queryParams ?? undefined,
Expand All @@ -965,6 +1039,7 @@ export class StackOneToolSet {
const response = await actionsClient.actions.rpcAction({
action: name,
body: rpcBody,
...defenderFields,
headers: actionHeaders,
path: pathParams ?? undefined,
query: queryParams ?? undefined,
Expand Down
39 changes: 39 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,3 +234,42 @@ export interface ClaudeAgentSdkOptions {
*/
serverVersion?: string;
}

/**
* Defender configuration for controlling prompt injection detection behavior.
* Field names match the canonical `DefenderSettings` from `@stackone/core`.
*
* Three modes:
* - `{ useProjectSettings: true }` — defer to whatever is configured in the project dashboard.
* No other fields may be set alongside this (TypeScript enforces it; a runtime error is also thrown).
* - An explicit config object (or omitting `defender` entirely) — the SDK owns the defender
* settings and sends them with every RPC call, ignoring any project-level config.
* - `null` passed as the `defender` option — defender is explicitly disabled for all tool calls.
*/
export type DefenderConfig =
| { useProjectSettings: true }
| {
useProjectSettings?: false;
/** Whether to run defender at all. Default: `true`. */
enabled?: boolean;
/**
* Whether to block tool execution when a HIGH risk score is detected.
* Default: `false` (scan and annotate, but do not block).
*/
blockHighRisk?: boolean;
/** Whether to enable tier 1 pattern-based (regex) detection. Default: `true`. */
useTier1Classification?: boolean;
/** Whether to enable tier 2 ML-based detection. Default: `true`. */
useTier2Classification?: boolean;
};

/**
* SDK-level defender defaults applied when no explicit `defender` config is passed.
* Defender is enabled but outputs are never blocked — scans run and results are annotated only.
*/
export const DEFAULT_DEFENDER_CONFIG = {
enabled: true,
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 19, 2026

Choose a reason for hiding this comment

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

P1: Defaulting omitted defender to an explicit config overrides existing project settings for every RPC call.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/types.ts, line 271:

<comment>Defaulting omitted `defender` to an explicit config overrides existing project settings for every RPC call.</comment>

<file context>
@@ -239,28 +239,37 @@ export interface ClaudeAgentSdkOptions {
+ * 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,
</file context>
Fix with Cubic

blockHighRisk: false,
useTier1Classification: true,
useTier2Classification: true,
} as const;
Loading