feat(ENG-130): add defender config option to StackOneToolSet#328
Conversation
commit: |
There was a problem hiding this comment.
1 issue found across 5 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="src/toolsets.ts">
<violation number="1" location="src/toolsets.ts:979">
P2: `dryRun` request serialization is now inconsistent with real RPC calls because `defender_enabled` is forwarded only in the live path.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
There was a problem hiding this comment.
Pull request overview
Adds a toolset-level defender configuration to StackOneToolSet so consumers can control prompt-injection detection behavior (currently via per-request defender_enabled), and fixes a silent validation issue where defender_enabled was previously dropped before reaching the RPC call.
Changes:
- Introduces
DefenderConfigand exports it from the public entrypoint. - Extends the RPC request Zod schema to accept
defender_enabledand forwards it in the RPC client request payload. - Adds
defendertoStackOneToolSetconfig and threadsdefender.enabled → defender_enabledinto RPC calls.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
src/types.ts |
Adds DefenderConfig interface describing toolset-level defender settings. |
src/toolsets.ts |
Accepts/stores defender config on the toolset and injects defender_enabled into RPC calls. |
src/schema.ts |
Updates rpcActionRequestSchema to validate defender_enabled so it isn’t stripped. |
src/rpc-client.ts |
Includes validated defender_enabled in the JSON payload sent to /actions/rpc. |
src/index.ts |
Re-exports DefenderConfig for SDK consumers. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
There was a problem hiding this comment.
3 issues found across 5 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="src/toolsets.ts">
<violation number="1" location="src/toolsets.ts:995">
P2: `dryRun` no longer matches the real RPC payload for defender settings.</violation>
</file>
<file name="src/rpc-client.ts">
<violation number="1" location="src/rpc-client.ts:56">
P1: Don't forward the unsupported defender tuning fields yet. Only `defender_enabled` is wired through the RPC API today, so sending the other three keys can break RPC calls until the backend DTO is updated.</violation>
</file>
<file name="src/types.ts">
<violation number="1" location="src/types.ts:271">
P1: Defaulting omitted `defender` to an explicit config overrides existing project settings for every RPC call.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
|
@hiskudin you now have merge conflicts |
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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
…nd SDK defaults
- 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 <noreply@anthropic.com>
…ults - 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
knip flagged defenderConfigRequestSchema as an unused export since it is only used internally within schema.ts. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
d35d4a9 to
5a4fd01
Compare
…issing tests, harden guards
- 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 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a defender configuration option to StackOneToolSet so SDK consumers can explicitly control prompt-injection scanning behavior (default on, project settings, or fully disabled) and forwards the resolved settings through the RPC request payload.
Changes:
- Introduces
DefenderConfigandDEFAULT_DEFENDER_CONFIGdefaults for SDK-level defender behavior. - Extends the RPC request schema/client to accept and forward
defender_config(includingblock_high_riskand tier classification toggles). - Updates
StackOneToolSetto resolve/validatedefenderat construction time and include it in dry-run + live RPC calls, with added tests and mock support.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| src/types.ts | Adds DefenderConfig type and DEFAULT_DEFENDER_CONFIG constant. |
| src/toolsets.ts | Adds defender to toolset config, resolves config in constructor, forwards defender_config in RPC execution. |
| src/toolsets.test.ts | Adds coverage for constructor storage and RPC/dryRun payload behaviors across defender modes. |
| src/schema.ts | Extends rpcActionRequestSchema to validate optional defender_config payload. |
| src/rpc-client.ts | Forwards defender_config into the outgoing RPC request body. |
| src/rpc-client.test.ts | Adds tests ensuring defender_config is forwarded/omitted as expected. |
| src/index.ts | Exports DEFAULT_DEFENDER_CONFIG and DefenderConfig from the public entrypoint. |
| mocks/handlers.stackone-rpc.ts | Updates MSW handler to accept/echo defender_config for test assertions. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
… 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 <noreply@anthropic.com>
There was a problem hiding this comment.
0 issues found across 1 file (changes from recent commits).
Requires human review: This change introduces security-related configuration (prompt injection detection) and modifies RPC request schemas, which requires human oversight for architectural alignment.
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 <noreply@anthropic.com>
|
You're iterating quickly on this pull request. To help protect your rate limits, cubic has paused automatic reviews on new pushes for now—when you're ready for another review, comment |
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
glebedel
left a comment
There was a problem hiding this comment.
Have we benchmarked performance impact here?
Can defender via our AI sdk be applied to non-stackone tools (i'd assume not).
If not, is the main value prop here to be able to have more granular control of when/for whom defender will be applied vs what we allow via project settings?
| // 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 && | ||
| typeof defenderInput === 'object' && | ||
| '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; | ||
|
|
||
| // Set Authentication headers if provided | ||
| if (this.authentication) { | ||
| // Only set auth headers if they don't already exist in custom headers | ||
| const needsAuthHeader = !('Authorization' in this.headers); | ||
|
|
||
| if (needsAuthHeader) { | ||
| switch (this.authentication.type) { | ||
| case 'basic': | ||
| if (this.authentication.credentials?.username) { | ||
| const username = this.authentication.credentials.username; | ||
| const password = this.authentication.credentials.password || ''; | ||
| const authString = Buffer.from(`${username}:${password}`).toString('base64'); | ||
| this.headers.Authorization = `Basic ${authString}`; | ||
| } | ||
| break; | ||
| case 'bearer': | ||
| if (this.authentication.credentials?.token) { | ||
| this.headers.Authorization = `Bearer ${this.authentication.credentials.token}`; | ||
| } | ||
| break; | ||
|
|
||
| default: | ||
| this.authentication.type satisfies never; | ||
| throw new ToolSetError( | ||
| `Unsupported authentication type: ${String(this.authentication.type)}`, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| // Add any additional headers from authentication config, but don't override existing ones | ||
| if (this.authentication.headers) { | ||
| this.headers = { ...this.authentication.headers, ...this.headers }; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private semanticSearchClient?: SemanticSearchClient; | ||
| private catalogCache: Map<string, Tools> = new Map(); | ||
| private toolIndexCache?: { tools: Tools; index: ToolIndex }; | ||
|
|
||
| /** | ||
| * Set account IDs for filtering tools | ||
| * @param accountIds Array of account IDs to filter tools by | ||
| * @returns This toolset instance for chaining | ||
| */ | ||
| setAccounts(accountIds: string[]): this { | ||
| this.accountIds = accountIds; | ||
| this.clearCatalogCache(); | ||
| return this; | ||
| } | ||
|
|
||
| /** | ||
| * Invalidate cached tool catalog and local search index. | ||
| * | ||
| * Call when linked accounts change outside of {@link setAccounts} or when | ||
| * you need to force a fresh fetch from the StackOne MCP endpoint. | ||
| */ | ||
| clearCatalogCache(): void { | ||
| this.catalogCache.clear(); | ||
| this.toolIndexCache = undefined; | ||
| } | ||
|
|
||
| /** | ||
| * Get or lazily create the semantic search client. | ||
| */ | ||
| private getSemanticClient(): SemanticSearchClient { | ||
| if (!this.semanticSearchClient) { | ||
| const apiKey = this.getApiKey(); | ||
| this.semanticSearchClient = new SemanticSearchClient({ | ||
| apiKey, | ||
| baseUrl: this.baseUrl, | ||
| }); | ||
| } | ||
| return this.semanticSearchClient; | ||
| } | ||
|
|
||
| /** | ||
| * Get the current search config. | ||
| */ | ||
| getSearchConfig(): SearchConfig | null { | ||
| return this.searchConfig; | ||
| } | ||
|
|
||
| /** | ||
| * Extract the API key from authentication config. | ||
| */ | ||
| private getApiKey(): string { | ||
| const credentials = this.authentication?.credentials ?? {}; | ||
| const apiKeyFromAuth = | ||
| this.authentication?.type === 'basic' | ||
| ? credentials.username | ||
| : this.authentication?.type === 'bearer' | ||
| ? credentials.token | ||
| : credentials.username; | ||
|
|
||
| const apiKey = apiKeyFromAuth || process.env.STACKONE_API_KEY; | ||
| if (!apiKey) { | ||
| throw new ToolSetConfigError( | ||
| 'API key is required for semantic search. Provide apiKey in config or set STACKONE_API_KEY environment variable.', | ||
| ); | ||
| } | ||
| return apiKey; | ||
| } | ||
|
|
||
| /** | ||
| * Get a callable search tool that returns Tools collections. | ||
| * | ||
| * Returns a SearchTool instance that wraps `searchTools()` for use in agent loops. | ||
| * | ||
| * @param options - Options including the default search mode | ||
| * @returns SearchTool instance | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * const toolset = new StackOneToolSet({ apiKey: 'sk-xxx' }); | ||
| * const searchTool = toolset.getSearchTool(); | ||
| * const tools = await searchTool.search('manage employee records', { accountIds: ['acc-123'] }); | ||
| * ``` | ||
| */ | ||
| getSearchTool(options?: { search?: SearchMode }): SearchTool { | ||
| if (this.searchConfig === null) { | ||
| throw new ToolSetConfigError( | ||
| 'Search is disabled. Initialize StackOneToolSet with a search config to enable.', | ||
| ); | ||
| } | ||
|
|
||
| const config: SearchConfig = options?.search | ||
| ? { ...this.searchConfig, method: options.search } | ||
| : this.searchConfig; | ||
|
|
||
| return new SearchTool(this, config); | ||
| } | ||
|
|
||
| /** | ||
| * Get tool_search + tool_execute for agent-driven discovery. | ||
| * | ||
| * Returns a Tools collection with two tools that let the LLM | ||
| * discover and execute tools on-demand. | ||
| * | ||
| * @param options - Options to scope tool discovery | ||
| * @returns Tools collection containing tool_search and tool_execute | ||
| */ | ||
| getTools(options?: { accountIds?: string[] }): Tools { | ||
| const accountIds = | ||
| options?.accountIds ?? | ||
| this.executeConfig?.accountIds ?? | ||
| (this.accountIds.length > 0 ? this.accountIds : undefined); | ||
| return this.buildTools(accountIds); | ||
| } | ||
|
|
||
| /** | ||
| * Build tool_search + tool_execute tools scoped to this toolset. | ||
| */ | ||
| private buildTools(accountIds?: string[], connectors?: string): Tools { | ||
| if (this.searchConfig === null) { | ||
| throw new ToolSetConfigError( | ||
| 'Search is disabled. Initialize StackOneToolSet with a search config to enable.', | ||
| ); | ||
| } | ||
|
|
||
| const searchTool = createSearchTool(this, accountIds, connectors); | ||
| const executeTool = createExecuteTool(this, accountIds, connectors); | ||
| return new Tools([searchTool, executeTool]); | ||
| } | ||
|
|
||
| /** | ||
| * Get tools in OpenAI function calling format. | ||
| * | ||
| * @param options - Options | ||
| * @param options.mode - Tool mode. | ||
| * `undefined` (default): fetch all tools and convert to OpenAI format. | ||
| * `"search_and_execute"`: return two tools (tool_search + tool_execute) | ||
| * that let the LLM discover and execute tools on-demand. | ||
| * @param options.accountIds - Account IDs to scope tools. Overrides the `execute` | ||
| * config from the constructor. | ||
| * @returns List of tool definitions in OpenAI function format. | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * // All tools | ||
| * const toolset = new StackOneToolSet(); | ||
| * const tools = await toolset.openai(); | ||
| * | ||
| * // Search and execute for agent-driven discovery | ||
| * const toolset = new StackOneToolSet({ search: {} }); | ||
| * const tools = await toolset.openai({ mode: 'search_and_execute' }); | ||
| * ``` | ||
| */ | ||
| async openai(options?: { | ||
| mode?: 'search_and_execute'; | ||
| accountIds?: string[]; | ||
| }): Promise<ReturnType<Tools['toOpenAI']>> { | ||
| const effectiveAccountIds = options?.accountIds ?? this.executeConfig?.accountIds; | ||
|
|
||
| if (options?.mode === 'search_and_execute') { | ||
| // Discover available connectors for dynamic descriptions | ||
| let connectors: string | undefined; | ||
| try { | ||
| const allTools = await this.fetchTools({ accountIds: effectiveAccountIds }); | ||
| const connectorSet = allTools.getConnectors(); | ||
| if (connectorSet.size > 0) { | ||
| connectors = Array.from(connectorSet).sort().join(', '); | ||
| } | ||
| } catch { | ||
| // Best-effort: if discovery fails, use generic descriptions | ||
| } | ||
| return this.buildTools(effectiveAccountIds, connectors).toOpenAI(); | ||
| } | ||
|
|
||
| const tools = await this.fetchTools({ accountIds: effectiveAccountIds }); | ||
| return tools.toOpenAI(); | ||
| } | ||
|
|
||
| /** | ||
| * Search for and fetch tools using semantic or local search. | ||
| * | ||
| * This method discovers relevant tools based on natural language queries. | ||
| * | ||
| * @param query - Natural language description of needed functionality | ||
| * (e.g., "create employee", "send a message") | ||
| * @param options - Search options | ||
| * @returns Tools collection with matched tools from linked accounts | ||
| * @throws SemanticSearchError if the API call fails and search is "semantic" | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * // Semantic search (default with local fallback) | ||
| * const tools = await toolset.searchTools('manage employee records', { topK: 5 }); | ||
| * | ||
| * // Explicit semantic search | ||
| * const tools = await toolset.searchTools('manage employees', { search: 'semantic' }); | ||
| * | ||
| * // Local BM25+TF-IDF search | ||
| * const tools = await toolset.searchTools('manage employees', { search: 'local' }); | ||
| * | ||
| * // Filter by connector | ||
| * const tools = await toolset.searchTools('create time off request', { | ||
| * connector: 'bamboohr', | ||
| * search: 'semantic', | ||
| * }); | ||
| * ``` | ||
| */ | ||
| async searchTools(query: string, options?: SearchToolsOptions): Promise<Tools> { | ||
| if (this.searchConfig === null) { | ||
| throw new ToolSetConfigError( | ||
| 'Search is disabled. Initialize StackOneToolSet with a search config to enable.', | ||
| ); | ||
| } | ||
|
|
||
| const search = options?.search ?? this.searchConfig.method ?? 'auto'; | ||
| const topK = options?.topK ?? this.searchConfig.topK; | ||
| const minSimilarity = options?.minSimilarity ?? this.searchConfig.minSimilarity; | ||
| const mergedOptions = { ...options, search, topK, minSimilarity }; | ||
|
|
||
| const allTools = await this.fetchTools({ accountIds: mergedOptions.accountIds }); | ||
| const availableConnectors = allTools.getConnectors(); | ||
|
|
||
| if (availableConnectors.size === 0) { | ||
| return new Tools([]); | ||
| } | ||
|
|
||
| // Local-only search — skip semantic API entirely | ||
| if (search === 'local') { | ||
| return this.localSearch(query, allTools, mergedOptions); | ||
| } | ||
|
|
||
| try { | ||
| // Determine which connectors to search | ||
| let connectorsToSearch: Set<string>; | ||
| if (mergedOptions.connector) { | ||
| const connectorLower = mergedOptions.connector.toLowerCase(); | ||
| connectorsToSearch = availableConnectors.has(connectorLower) | ||
| ? new Set([connectorLower]) | ||
| : new Set(); | ||
| if (connectorsToSearch.size === 0) { | ||
| return new Tools([]); | ||
| } | ||
| } else { | ||
| connectorsToSearch = availableConnectors; | ||
| } | ||
|
|
||
| // Search each connector in parallel — in auto mode, treat missing | ||
| // API key as "semantic unavailable" and fall back to local search. | ||
| let client: SemanticSearchClient; | ||
| try { | ||
| client = this.getSemanticClient(); | ||
| } catch (error) { | ||
| if (search === 'auto' && error instanceof ToolSetConfigError) { | ||
| return this.localSearch(query, allTools, mergedOptions); | ||
| } | ||
| throw error; | ||
| } | ||
| const allResults: SemanticSearchResult[] = []; | ||
| let lastError: SemanticSearchError | undefined; | ||
|
|
||
| const searchPromises = [...connectorsToSearch].map(async (connector) => { | ||
| try { | ||
| const response = await client.search(query, { | ||
| connector, | ||
| topK: mergedOptions.topK, | ||
| minSimilarity: mergedOptions.minSimilarity, | ||
| }); | ||
| return response.results; | ||
| } catch (error) { | ||
| if (error instanceof SemanticSearchError) { | ||
| lastError = error; | ||
| return []; | ||
| } | ||
| throw error; | ||
| } | ||
| }); | ||
|
|
||
| const resultArrays = await Promise.all(searchPromises); | ||
| for (const results of resultArrays) { | ||
| allResults.push(...results); | ||
| } | ||
|
|
||
| // If ALL connector searches failed, re-raise to trigger fallback | ||
| if (allResults.length === 0 && lastError) { | ||
| throw lastError; | ||
| } | ||
|
|
||
| // Sort by score, apply topK | ||
| allResults.sort((a, b) => b.similarityScore - a.similarityScore); | ||
| const topResults = | ||
| mergedOptions.topK != null ? allResults.slice(0, mergedOptions.topK) : allResults; | ||
|
|
||
| if (topResults.length === 0) { | ||
| return new Tools([]); | ||
| } | ||
|
|
||
| // 1. Parse composite IDs to MCP-format action names, deduplicate | ||
| const seenNames = new Set<string>(); | ||
| const actionNames: string[] = []; | ||
| for (const result of topResults) { | ||
| const name = normalizeActionName(result.id); | ||
| if (seenNames.has(name)) { | ||
| continue; | ||
| } | ||
| seenNames.add(name); | ||
| actionNames.push(name); | ||
| } | ||
|
|
||
| if (actionNames.length === 0) { | ||
| return new Tools([]); | ||
| } | ||
|
|
||
| // 2. Use MCP tools (already fetched) — schemas come from the source of truth | ||
| // 3. Filter to only the tools search found, preserving search relevance order | ||
| const actionOrder = new Map(actionNames.map((name, i) => [name, i])); | ||
| const matchedTools = allTools.toArray().filter((t) => seenNames.has(t.name)); | ||
| matchedTools.sort( | ||
| (a, b) => | ||
| (actionOrder.get(a.name) ?? Number.POSITIVE_INFINITY) - | ||
| (actionOrder.get(b.name) ?? Number.POSITIVE_INFINITY), | ||
| ); | ||
|
|
||
| // Auto mode: if semantic returned results but none matched MCP tools, fall back to local | ||
| if (search === 'auto' && matchedTools.length === 0) { | ||
| return this.localSearch(query, allTools, mergedOptions); | ||
| } | ||
|
|
||
| return new Tools(matchedTools); | ||
| } catch (error) { | ||
| if (error instanceof SemanticSearchError) { | ||
| if (search === 'semantic') { | ||
| throw error; | ||
| } | ||
|
|
||
| // Auto mode: silently fall back to local search | ||
| return this.localSearch(query, allTools, mergedOptions); | ||
| } | ||
| throw error; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Search for action names without fetching tools. | ||
| * | ||
| * Useful when you need to inspect search results before fetching, | ||
| * or when building custom filtering logic. | ||
| * | ||
| * @param query - Natural language description of needed functionality | ||
| * @param options - Search options | ||
| * @returns List of SemanticSearchResult with action names, scores, and metadata | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * // Lightweight: inspect results before fetching | ||
| * const results = await toolset.searchActionNames('manage employees'); | ||
| * for (const r of results) { | ||
| * console.log(`${r.id}: ${r.similarityScore.toFixed(2)}`); | ||
| * } | ||
| * | ||
| * // Then fetch specific high-scoring actions | ||
| * const selected = results | ||
| * .filter(r => r.similarityScore > 0.7) | ||
| * .map(r => r.id); | ||
| * const tools = await toolset.fetchTools({ actions: selected }); | ||
| * ``` | ||
| */ | ||
| async searchActionNames( | ||
| query: string, | ||
| options?: SearchActionNamesOptions, | ||
| ): Promise<SemanticSearchResult[]> { | ||
| if (this.searchConfig === null) { | ||
| throw new ToolSetConfigError( | ||
| 'Search is disabled. Initialize StackOneToolSet with a search config to enable.', | ||
| ); | ||
| } | ||
|
|
||
| const effectiveTopK = options?.topK ?? this.searchConfig.topK; | ||
| const effectiveMinSimilarity = options?.minSimilarity ?? this.searchConfig.minSimilarity; | ||
|
|
||
| // Resolve available connectors from account IDs | ||
| let availableConnectors: Set<string> | undefined; | ||
| const effectiveAccountIds = options?.accountIds || this.accountIds; | ||
| if (effectiveAccountIds.length > 0) { | ||
| const allTools = await this.fetchTools({ accountIds: effectiveAccountIds }); | ||
| availableConnectors = allTools.getConnectors(); | ||
| if (availableConnectors.size === 0) { | ||
| return []; | ||
| } | ||
| } | ||
|
|
||
| try { | ||
| const client = this.getSemanticClient(); | ||
| let allResults: SemanticSearchResult[] = []; | ||
|
|
||
| if (availableConnectors) { | ||
| // Parallel per-connector search (only user's connectors) | ||
| let connectorsToSearch: Set<string>; | ||
| if (options?.connector) { | ||
| const connectorLower = options.connector.toLowerCase(); | ||
| connectorsToSearch = availableConnectors.has(connectorLower) | ||
| ? new Set([connectorLower]) | ||
| : new Set(); | ||
| } else { | ||
| connectorsToSearch = availableConnectors; | ||
| } | ||
|
|
||
| const searchPromises = [...connectorsToSearch].map(async (connector) => { | ||
| try { | ||
| const response = await client.search(query, { | ||
| connector, | ||
| topK: effectiveTopK, | ||
| minSimilarity: effectiveMinSimilarity, | ||
| }); | ||
| return response.results; | ||
| } catch { | ||
| return []; | ||
| } | ||
| }); | ||
|
|
||
| const resultArrays = await Promise.all(searchPromises); | ||
| for (const results of resultArrays) { | ||
| allResults.push(...results); | ||
| } | ||
| } else { | ||
| // No account filtering — single global search | ||
| const response = await client.search(query, { | ||
| connector: options?.connector, | ||
| topK: effectiveTopK, | ||
| minSimilarity: effectiveMinSimilarity, | ||
| }); | ||
| allResults = response.results; | ||
| } | ||
|
|
||
| // Sort by score — return raw results (consumers can normalize the composite ID if needed) | ||
| allResults.sort((a, b) => b.similarityScore - a.similarityScore); | ||
|
|
||
| return effectiveTopK != null ? allResults.slice(0, effectiveTopK) : allResults; | ||
| } catch (error) { | ||
| if (error instanceof SemanticSearchError) { | ||
| return []; | ||
| } | ||
| throw error; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Run local BM25+TF-IDF search over already-fetched tools. | ||
| */ | ||
| private async localSearch( | ||
| query: string, | ||
| allTools: Tools, | ||
| options?: Pick<SearchToolsOptions, 'connector' | 'topK' | 'minSimilarity'>, | ||
| ): Promise<Tools> { | ||
| const availableConnectors = allTools.getConnectors(); | ||
| if (availableConnectors.size === 0) { | ||
| return new Tools([]); | ||
| } | ||
|
|
||
| if (!this.toolIndexCache || this.toolIndexCache.tools !== allTools) { | ||
| this.toolIndexCache = { tools: allTools, index: new ToolIndex(allTools.toArray()) }; | ||
| } | ||
| const index = this.toolIndexCache.index; | ||
| const results = await index.search(query, options?.topK ?? 5, options?.minSimilarity ?? 0.0); | ||
|
|
||
| const matchedNames = results.map((r) => r.name); | ||
| const toolMap = new Map(allTools.toArray().map((t) => [t.name, t])); | ||
| const filterConnectors = options?.connector | ||
| ? new Set([options.connector.toLowerCase()]) | ||
| : availableConnectors; | ||
|
|
||
| const matchedTools = matchedNames | ||
| .filter((name) => toolMap.has(name)) | ||
| .map((name) => toolMap.get(name)!) | ||
| .filter((tool) => tool.connector && filterConnectors.has(tool.connector)); | ||
|
|
||
| return new Tools(options?.topK != null ? matchedTools.slice(0, options.topK) : matchedTools); | ||
| } | ||
|
|
||
| /** | ||
| * Fetch tools from MCP with optional filtering | ||
| * @param options Optional filtering options for account IDs, providers, and actions | ||
| * @returns Collection of tools matching the filter criteria | ||
| */ | ||
| async fetchTools(options?: FetchToolsOptions): Promise<Tools> { | ||
| // Use account IDs from options, or fall back to instance state | ||
| const effectiveAccountIds = options?.accountIds || this.accountIds; | ||
|
|
||
| const cacheKey = JSON.stringify({ | ||
| accountIds: [...effectiveAccountIds].sort(), | ||
| providers: options?.providers?.length ? [...options.providers].sort() : null, | ||
| actions: options?.actions?.length ? [...options.actions].sort() : null, | ||
| }); | ||
| const cached = this.catalogCache.get(cacheKey); | ||
| if (cached) { | ||
| return cached; | ||
| } | ||
|
|
||
| // Fetch tools (with account filtering if needed) | ||
| let tools: Tools; | ||
| if (effectiveAccountIds.length > 0) { | ||
| const toolsPromises = effectiveAccountIds.map(async (accountId) => { | ||
| const headers = { 'x-account-id': accountId }; | ||
| const mergedHeaders = { ...this.headers, ...headers }; | ||
|
|
||
| // Create a temporary toolset instance with the account-specific headers | ||
| const tempHeaders = mergedHeaders; | ||
| const originalHeaders = this.headers; | ||
| this.headers = tempHeaders; | ||
|
|
||
| try { | ||
| const tools = await this.fetchToolsFromMcp(); | ||
| return tools.toArray(); | ||
| } finally { | ||
| // Restore original headers | ||
| this.headers = originalHeaders; | ||
| } | ||
| }); | ||
|
|
||
| const toolArrays = await Promise.all(toolsPromises); | ||
| const allTools = toolArrays.flat(); | ||
| tools = new Tools(allTools); | ||
| } else { | ||
| // No account filtering - fetch all tools | ||
| tools = await this.fetchToolsFromMcp(); | ||
| } | ||
|
|
||
| // Apply provider and action filters | ||
| const filteredTools = this.filterTools(tools, options); | ||
|
|
||
| // Add feedback tool | ||
| const feedbackTool = createFeedbackTool(undefined, this.accountId, this.baseUrl); | ||
| const toolsWithFeedback = new Tools([...filteredTools.toArray(), feedbackTool]); | ||
|
|
||
| this.catalogCache.set(cacheKey, toolsWithFeedback); | ||
| return toolsWithFeedback; | ||
| } | ||
|
|
||
| /** | ||
| * Fetch tool definitions from MCP | ||
| */ | ||
| private async fetchToolsFromMcp(): Promise<Tools> { | ||
| if (!this.baseUrl) { | ||
| throw new ToolSetConfigError('baseUrl is required to fetch MCP tools'); | ||
| } | ||
|
|
||
| await using clients = await createMCPClient({ | ||
| baseUrl: `${this.baseUrl}/mcp`, | ||
| headers: this.headers, | ||
| }); | ||
|
|
||
| await clients.client.connect(clients.transport); | ||
| const listToolsResult = await clients.client.listTools(); | ||
| const actionsClient = this.getActionsClient(); | ||
|
|
||
| const tools = listToolsResult.tools.map(({ name, description, inputSchema }) => { | ||
| return this.createRpcBackedTool({ | ||
| actionsClient, | ||
| name, | ||
| description, | ||
| inputSchema, | ||
| }); | ||
| }); | ||
|
|
||
| return new Tools(tools); | ||
| } | ||
|
|
||
| /** | ||
| * Filter tools by providers and actions | ||
| * @param tools Tools collection to filter | ||
| * @param options Filtering options | ||
| * @returns Filtered tools collection | ||
| */ | ||
| private filterTools(tools: Tools, options?: FetchToolsOptions): Tools { | ||
| let filteredTools = tools.toArray(); | ||
|
|
||
| // Filter by providers if specified | ||
| if (options?.providers && options.providers.length > 0) { | ||
| const providerSet = new Set(options.providers.map((p) => p.toLowerCase())); | ||
| filteredTools = filteredTools.filter((tool) => { | ||
| return tool.connector && providerSet.has(tool.connector); | ||
| }); | ||
| } | ||
|
|
||
| // Filter by actions if specified (with glob support) | ||
| if (options?.actions && options.actions.length > 0) { | ||
| filteredTools = filteredTools.filter((tool) => | ||
| options.actions?.some((pattern) => this.matchGlob(tool.name, pattern)), | ||
| ); | ||
| } | ||
|
|
||
| return new Tools(filteredTools); | ||
| } | ||
|
|
||
| /** | ||
| * Check if a string matches a glob pattern | ||
| * @param str String to check | ||
| * @param pattern Glob pattern | ||
| * @returns True if the string matches the pattern | ||
| */ | ||
| private matchGlob(str: string, pattern: string): boolean { | ||
| // Convert glob pattern to regex | ||
| const regexPattern = pattern.replace(/\./g, '\\.').replace(/\*/g, '.*').replace(/\?/g, '.'); | ||
|
|
||
| // Create regex with start and end anchors | ||
| const regex = new RegExp(`^${regexPattern}$`); | ||
|
|
||
| // Test if the string matches the pattern | ||
| return regex.test(str); | ||
| } | ||
|
|
||
| private getActionsClient(): RpcClient { | ||
| if (this.rpcClient) { | ||
| return this.rpcClient; | ||
| } | ||
|
|
||
| const credentials = this.authentication?.credentials ?? {}; | ||
| const apiKeyFromAuth = | ||
| this.authentication?.type === 'basic' | ||
| ? credentials.username | ||
| : this.authentication?.type === 'bearer' | ||
| ? credentials.token | ||
| : credentials.username; | ||
|
|
||
| const apiKey = apiKeyFromAuth || process.env.STACKONE_API_KEY; | ||
| const password = this.authentication?.type === 'basic' ? (credentials.password ?? '') : ''; | ||
|
|
||
| if (!apiKey) { | ||
| throw new ToolSetConfigError( | ||
| 'StackOne API key is required to create an actions client. Provide rpcClient, configure authentication credentials, or set the STACKONE_API_KEY environment variable.', | ||
| ); | ||
| } | ||
|
|
||
| this.rpcClient = new RpcClient({ | ||
| serverURL: this.baseUrl, | ||
| security: { | ||
| username: apiKey, | ||
| password, | ||
| }, | ||
| timeout: this.timeout, | ||
| }); | ||
|
|
||
| return this.rpcClient; | ||
| } | ||
|
|
||
| private createRpcBackedTool({ | ||
| actionsClient, | ||
| name, | ||
| description, | ||
| inputSchema, | ||
| }: { | ||
| actionsClient: RpcClient; | ||
| name: string; | ||
| description?: string; | ||
| inputSchema: ToolInputSchema; | ||
| }): BaseTool { | ||
| const executeConfig = { | ||
| kind: 'rpc', | ||
| method: 'POST', | ||
| url: `${this.baseUrl}/actions/rpc`, | ||
| payloadKeys: { | ||
| action: 'action', | ||
| body: 'body', | ||
| headers: 'headers', | ||
| path: 'path', | ||
| query: 'query', | ||
| }, | ||
| } as const satisfies RpcExecuteConfig; // Mirrors StackOne RPC payload layout so metadata/debug stays in sync. | ||
|
|
||
| const toolParameters = { | ||
| ...inputSchema, | ||
|
|
||
| // properties are not well typed in MCP spec | ||
| properties: inputSchema?.properties as JsonSchemaProperties, | ||
| } satisfies ToolParameters; | ||
|
|
||
| const tool = new BaseTool( | ||
| name, | ||
| description ?? '', | ||
| toolParameters, | ||
| executeConfig, | ||
| this.headers, | ||
| ).setExposeExecutionMetadata(false); | ||
|
|
||
| tool.execute = async ( | ||
| inputParams?: JsonObject | string, | ||
| options?: ExecuteOptions, | ||
| ): Promise<JsonObject> => { | ||
| try { | ||
| if ( | ||
| inputParams !== undefined && | ||
| typeof inputParams !== 'object' && | ||
| typeof inputParams !== 'string' | ||
| ) { | ||
| throw new StackOneError( | ||
| `Invalid parameters type. Expected object or string, got ${typeof inputParams}. Parameters: ${JSON.stringify(inputParams)}`, | ||
| ); | ||
| } | ||
|
|
||
| const parsedParams = | ||
| typeof inputParams === 'string' ? JSON.parse(inputParams) : (inputParams ?? {}); | ||
|
|
||
| const currentHeaders = tool.getHeaders(); | ||
| const baseHeaders = this.buildActionHeaders(currentHeaders); | ||
|
|
||
| const pathParams = this.extractRecord(parsedParams, 'path'); | ||
| const queryParams = this.extractRecord(parsedParams, 'query'); | ||
| const additionalHeaders = this.extractRecord(parsedParams, 'headers'); | ||
| const extraHeaders = normalizeHeaders(additionalHeaders); | ||
| // defu merges extraHeaders into baseHeaders, both are already branded types | ||
| const actionHeaders = defu(extraHeaders, baseHeaders); | ||
|
|
||
| const bodyPayload = this.extractRecord(parsedParams, 'body'); | ||
| const rpcBody: JsonObject = bodyPayload ? { ...bodyPayload } : {}; | ||
| for (const [key, value] of Object.entries(parsedParams)) { | ||
| if (key === 'body' || key === 'headers' || key === 'path' || key === 'query') { | ||
| continue; | ||
| } | ||
| 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) { |
There was a problem hiding this comment.
This whole logic seems bloated, hard to read/parse. Did a good model spit that out as is? I'd have expected a leading model to create something simpler/cleaner by using default function parameters, using typeguards and maybe optional chaining?
There was a problem hiding this comment.
Will look this over!
|
I'll have to benchmark performance with and without defender. |
…rn + 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.
shashi-stackone
left a comment
There was a problem hiding this comment.
I think, since we discussed this last time on Slack somethng has been changed in terms of the requirements that I am missing here .. So my points might be came up due to lack of awareness on whats agreed .. However here my understanding
- ON by default contradicts my "opt-in, not by default" rule but I might be missing something
useProjectSettings: trueexists to honor the dashboard, but the default
behavior overrides the dashboard silently, making SDK strict when you defer, silent when you override
@hiskudin we will discuss this tomorrow
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.
… warning
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.
…onse shape
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<string, string[]>`
- `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.
…n addition 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.
|
@hiskudin I tested this locally and worked well Just wondering if we can add some details about how run example overriding the connector and tools Otherwise PR looks good to me (LGTM) |
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.
Summary
Adds a
defenderoption toStackOneToolSetfor explicit per-toolset control over prompt injection detection on every tool call. By default the SDK defers to the project dashboard setting and adds nodefender_configto the payload; passing an explicit value lets a single toolset opt in, opt out, or override the dashboard.defender_enabledwas dropped by Zod validation before reaching the RPC calldefender_configper-request (backend changes for the new sub-fields tracked separately)DefenderSettingsfrom@stackone/coreCurrent setup
defenderoptiondefender_configomitted{ useProjectSettings: true }){ useProjectSettings: true }(default)defender_configomitted{ enabled, blockHighRisk, ... }defender_config: { ...mapped }nulldefender_config: { enabled: false, ... all false }When passing an explicit object, missing fields fall back to
DEFAULT_DEFENDER_CONFIG(re-exported from@stackone/ai).Default behavior (omit
defender)Disable entirely
Defer to project dashboard (explicit form)
Explicit SDK-level config
Per-field fallbacks (
DEFAULT_DEFENDER_CONFIG)These apply only when an explicit
defenderconfig object is passed with some fields omitted. They do NOT apply whendefenderis omitted entirely.enabledtruefalsedisables scanning for this toolset.blockHighRiskfalsefalse, risky content is annotated but still returned.useTier1ClassificationtrueuseTier2Classificationtrueonnxruntime-nodeat runtime.Changes
src/types.ts—DefenderConfigis a discriminated union ({ useProjectSettings: true } | explicit-object);DEFAULT_DEFENDER_CONFIGkept as a public reference users can spreadsrc/schema.ts— addsdefender_configtorpcActionRequestSchemasrc/rpc-client.ts— forwardsdefender_configin request body when presentsrc/toolsets.ts— resolves defender config at construction (omitted → normalized to{ useProjectSettings: true }), validatesuseProjectSettingsexclusivity, computes wire payload once viabuildDefenderFieldssrc/index.ts— re-exportsDEFAULT_DEFENDER_CONFIGNotes
The sub-fields under
defender_config(block_high_risk,use_tier1_classification,use_tier2_classification) are forwarded in the RPC payload but require a backend change toActionsRpcRequestDtoto take full effect. That work is tracked separately.🤖 Generated with Claude Code