Skip to content

feat(ENG-130): add defender config option to StackOneToolSet#328

Merged
hiskudin merged 25 commits into
mainfrom
feat/defender-config
May 14, 2026
Merged

feat(ENG-130): add defender config option to StackOneToolSet#328
hiskudin merged 25 commits into
mainfrom
feat/defender-config

Conversation

@hiskudin
Copy link
Copy Markdown
Contributor

@hiskudin hiskudin commented Mar 19, 2026

Summary

Adds a defender option to StackOneToolSet for explicit per-toolset control over prompt injection detection on every tool call. By default the SDK defers to the project dashboard setting and adds no defender_config to the payload; passing an explicit value lets a single toolset opt in, opt out, or override the dashboard.

  • Fixes a silent bug where defender_enabled was dropped by Zod validation before reaching the RPC call
  • Extends the RPC schema to forward defender_config per-request (backend changes for the new sub-fields tracked separately)
  • Field names match canonical DefenderSettings from @stackone/core

Current setup

defender option Wire payload sent Effective behavior
omitted defender_config omitted Same as below (normalized to { useProjectSettings: true })
{ useProjectSettings: true } (default) defender_config omitted Project dashboard setting controls — SDK adds nothing
{ enabled, blockHighRisk, ... } defender_config: { ...mapped } SDK-level config wins, overrides the dashboard
null defender_config: { enabled: false, ... all false } Defender forcibly disabled, overrides the dashboard

When passing an explicit object, missing fields fall back to DEFAULT_DEFENDER_CONFIG (re-exported from @stackone/ai).

Default behavior (omit defender)

const toolset = new StackOneToolSet({ apiKey: '...' });
// → SDK adds nothing to the RPC payload; project dashboard controls

Disable entirely

const toolset = new StackOneToolSet({ apiKey: '...', defender: null });
// → SDK sends defender_config with all fields false, overriding the dashboard

Defer to project dashboard (explicit form)

const toolset = new StackOneToolSet({
  apiKey: '...',
  defender: { useProjectSettings: true },
});
// Combining with other fields throws ToolSetConfigError at construction

Explicit SDK-level config

import { StackOneToolSet, DEFAULT_DEFENDER_CONFIG } from '@stackone/ai';

// Opt in with safe defaults, but block on HIGH/CRITICAL
const toolset = new StackOneToolSet({
  apiKey: '...',
  defender: { ...DEFAULT_DEFENDER_CONFIG, blockHighRisk: true },
});

// Fully explicit
const toolset = new StackOneToolSet({
  apiKey: '...',
  defender: {
    enabled: true,
    blockHighRisk: true,          // block HIGH/CRITICAL instead of just annotating
    useTier1Classification: true, // pattern-based detection
    useTier2Classification: true, // ML-based detection (requires onnxruntime-node)
  },
});

Per-field fallbacks (DEFAULT_DEFENDER_CONFIG)

These apply only when an explicit defender config object is passed with some fields omitted. They do NOT apply when defender is omitted entirely.

Field Fallback Description
enabled true Master switch. false disables scanning for this toolset.
blockHighRisk false Block tool calls with HIGH/CRITICAL risk. When false, risky content is annotated but still returned.
useTier1Classification true Fast pattern-based detection (regex rules for role markers, instruction overrides, etc.).
useTier2Classification true ML-based detection (ONNX model). Catches novel attacks patterns miss. Requires onnxruntime-node at runtime.

Changes

  • src/types.tsDefenderConfig is a discriminated union ({ useProjectSettings: true } | explicit-object); DEFAULT_DEFENDER_CONFIG kept as a public reference users can spread
  • src/schema.ts — adds defender_config to rpcActionRequestSchema
  • src/rpc-client.ts — forwards defender_config in request body when present
  • src/toolsets.ts — resolves defender config at construction (omitted → normalized to { useProjectSettings: true }), validates useProjectSettings exclusivity, computes wire payload once via buildDefenderFields
  • src/index.ts — re-exports DEFAULT_DEFENDER_CONFIG

Notes

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 to ActionsRpcRequestDto to take full effect. That work is tracked separately.

🤖 Generated with Claude Code

@hiskudin hiskudin requested a review from a team as a code owner March 19, 2026 11:16
Copilot AI review requested due to automatic review settings March 19, 2026 11:16
@hiskudin hiskudin changed the title feat: add defender config option to StackOneToolSet feat (ENG-12325): add defender config option to StackOneToolSet Mar 19, 2026
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Mar 19, 2026

Open in StackBlitz

npm i https://pkg.pr.new/StackOneHQ/stackone-ai-node/@stackone/ai@328

commit: 1c6324d

@hiskudin hiskudin changed the title feat (ENG-12325): add defender config option to StackOneToolSet feat(ENG-12325): add defender config option to StackOneToolSet Mar 19, 2026
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

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.

Comment thread src/toolsets.ts Outdated
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 DefenderConfig and exports it from the public entrypoint.
  • Extends the RPC request Zod schema to accept defender_enabled and forwards it in the RPC client request payload.
  • Adds defender to StackOneToolSet config and threads defender.enabled → defender_enabled into 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.

Comment thread src/toolsets.ts Outdated
Comment thread src/toolsets.ts Outdated
Comment thread src/schema.ts
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

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.

Comment thread src/rpc-client.ts Outdated
Comment thread src/types.ts
Comment thread src/toolsets.ts Outdated
@hiskudin hiskudin marked this pull request as draft March 19, 2026 17:22
@hiskudin hiskudin marked this pull request as draft March 19, 2026 17:22
@hiskudin hiskudin marked this pull request as ready for review March 20, 2026 11:12
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

No issues found across 8 files

@glebedel
Copy link
Copy Markdown
Contributor

@hiskudin you now have merge conflicts

hiskudin and others added 10 commits April 14, 2026 14:09
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>
@hiskudin hiskudin force-pushed the feat/defender-config branch from d35d4a9 to 5a4fd01 Compare April 14, 2026 13:13
…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>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 DefenderConfig and DEFAULT_DEFENDER_CONFIG defaults for SDK-level defender behavior.
  • Extends the RPC request schema/client to accept and forward defender_config (including block_high_risk and tier classification toggles).
  • Updates StackOneToolSet to resolve/validate defender at 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.

Comment thread src/toolsets.ts
Comment thread src/toolsets.ts Outdated
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

0 issues found across 2 files (changes from recent commits).

Requires human review: This PR introduces new business logic for security configuration and modifies the RPC request schema, which is a core system change requiring human review.

… 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>
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

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.

hiskudin and others added 2 commits April 15, 2026 22:04
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>
@cubic-dev-ai
Copy link
Copy Markdown
Contributor

cubic-dev-ai Bot commented Apr 15, 2026

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 @cubic-dev-ai review.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@glebedel glebedel left a comment

Choose a reason for hiding this comment

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

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?

Comment thread src/toolsets.ts
Comment on lines +535 to 1349
// 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) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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?

Copy link
Copy Markdown
Contributor Author

@hiskudin hiskudin Apr 15, 2026

Choose a reason for hiding this comment

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

Will look this over!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Reviewed here: bdfb570

@hiskudin
Copy link
Copy Markdown
Contributor Author

I'll have to benchmark performance with and without defender.
Defender isn't really accessible within the AI SDK, it's configured on our backend, and yes, it'll give us more granular control programmatically, which was what I was aiming for in this PR. We can either explicitly define our defender settings OR state that we want to use project-level settings.

@hiskudin hiskudin changed the title feat(ENG-12325): add defender config option to StackOneToolSet feat(ENG-130): add defender config option to StackOneToolSet Apr 27, 2026
hiskudin added 2 commits May 11, 2026 08:26
…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.
Copy link
Copy Markdown
Contributor

@shashi-stackone shashi-stackone left a comment

Choose a reason for hiding this comment

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

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: true exists 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

Comment thread src/types.ts
Comment thread src/toolsets.test.ts
Comment thread src/toolsets.test.ts
Comment thread src/toolsets.ts
Comment thread README.md Outdated
hiskudin added 3 commits May 12, 2026 10:46
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.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 3 comments.

Comment thread src/types.ts
Comment thread examples/README.md Outdated
Comment thread src/types.ts
…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.
@shashi-stackone
Copy link
Copy Markdown
Contributor

@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 TOOL_NAME=calendly_get_current_user TOOL_BODY_JSON='{}' pnpm run:example examples/defender-config.ts

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.
Copy link
Copy Markdown
Contributor

@glebedel glebedel left a comment

Choose a reason for hiding this comment

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

LGTM

@hiskudin hiskudin merged commit f3311b5 into main May 14, 2026
17 checks passed
@hiskudin hiskudin deleted the feat/defender-config branch May 14, 2026 07:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants