Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
5727cfd
feat: add defender config option to StackOneToolSet
hiskudin Mar 19, 2026
c177cf9
fix: address PR review comments on defender config
hiskudin Mar 19, 2026
9ffe124
feat: rework defender config with useProjectSettings, null disable, a…
hiskudin Mar 19, 2026
6c6c2ea
fix: resolve TypeScript errors and update tests for new defender defa…
hiskudin Mar 19, 2026
30cfcd5
fix: rewrite defenderFields as if-else to fix oxfmt formatting
hiskudin Mar 19, 2026
6db2871
feat: warn when defender config is omitted at construction time
hiskudin Mar 20, 2026
d748d13
refactor: ran format:oxfmt
hiskudin Mar 20, 2026
830c95e
feat: nest defender fields under defender_config object
hiskudin Mar 23, 2026
299284e
fix: add defender_config to mock handler body type
hiskudin Mar 23, 2026
5a4fd01
fix: remove unused export from defenderConfigRequestSchema
hiskudin Mar 23, 2026
034c115
fix(defender): resolve review issues — remove deprecated field, add m…
hiskudin Apr 14, 2026
324eda6
refactor: ran format:oxfmt
hiskudin Apr 14, 2026
f918baf
fix(defender): clone DEFAULT_DEFENDER_CONFIG on assignment to prevent…
hiskudin Apr 14, 2026
afc911f
Merge branch 'main' into feat/defender-config
hiskudin Apr 15, 2026
a5b5372
docs: add Defender section to README
hiskudin Apr 15, 2026
b1dc533
refactor: ran format:oxfmt
hiskudin Apr 15, 2026
75aefd6
Merge branch 'main' into feat/defender-config
hiskudin Apr 16, 2026
cb27cab
feat(defender): implement buildDefenderFields function to map SDK con…
hiskudin Apr 16, 2026
ec286ed
Merge branch 'main' into feat/defender-config
hiskudin May 11, 2026
bdfb570
refactor(defender): simplify buildDefenderFields with early null retu…
hiskudin May 11, 2026
02c4c0e
feat(defender)!: switch default behavior to defer to project dashboard
hiskudin May 12, 2026
0c74bd4
feat(defender): add defenderMode getter and once-per-process override…
hiskudin May 12, 2026
60f7d6d
docs(defender): add live RPC section + document defenderMetadata resp…
hiskudin May 12, 2026
5660317
docs(examples): correct defender-config description after live-sectio…
hiskudin May 12, 2026
1c6324d
docs(defender-config example): show env-var override invocations
hiskudin May 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,87 @@ import { StackOneToolSet } from '@stackone/ai';
const toolset = new StackOneToolSet({ baseUrl: 'https://api.example-dev.com' });
```

### Defender

The SDK includes built-in prompt injection protection via [StackOne Defender](https://www.npmjs.com/package/@stackone/defender). It runs on every tool call result before the content reaches your LLM, detecting and sanitizing injection attacks hidden in external data (emails, documents, CRM notes, etc.).

**By default, the SDK defers to your project's dashboard defender setting.** Pass an explicit `defender` config to override the project setting per toolset.

| `defender` option | Effective behavior |
| --------------------------------- | --------------------------------------------------------- |
| omitted _(default)_ | Project dashboard setting controls — SDK adds nothing |
| `{ useProjectSettings: true }` | Same as omitting; explicit, self-documenting form |
| `{ enabled, blockHighRisk, ... }` | SDK-level config wins, overrides the project setting |
| `null` | Defender forcibly disabled, overrides the project setting |

When passing an explicit object, missing fields fall back to `DEFAULT_DEFENDER_CONFIG` (exported from `@stackone/ai`): `enabled: true`, `blockHighRisk: false`, both tiers on.

#### Configuration modes

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

// Default — defer to project dashboard setting
const toolset = new StackOneToolSet({ apiKey: '...' });

// Same as default, explicit form
const toolset = new StackOneToolSet({
apiKey: '...',
defender: { useProjectSettings: true },
});

// Explicitly disabled — overrides any project setting
const toolset = new StackOneToolSet({
apiKey: '...',
defender: null,
});

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

// Fully explicit SDK-level config
const toolset = new StackOneToolSet({
apiKey: '...',
defender: {
enabled: true,
blockHighRisk: true, // throw on HIGH or CRITICAL risk
useTier1Classification: true, // pattern-based (regex, role markers)
useTier2Classification: true, // ML-based (ONNX model)
},
});
```

#### Inspecting and observing the resolved mode

Use the `defenderMode` getter to check how a toolset will behave at runtime:

```typescript
const toolset = new StackOneToolSet({ apiKey: '...', defender: null });
toolset.defenderMode; // 'disabled' | 'explicit' | 'project'
```

When the SDK overrides the project dashboard (mode `disabled` or `explicit`), it emits a yellow `console.warn` line once per process per distinct override shape so the override is visible at runtime without spamming logs. Pass `NO_COLOR=1` to suppress color, or `FORCE_COLOR=1` to force it when piping output. The `project` mode is silent.

#### Risk levels

Defender assigns a risk level to each scanned result:

| Level | Meaning |
| ---------- | --------------------------------------------------- |
| `low` | No threats detected |
| `medium` | Suspicious patterns detected, role markers stripped |
| `high` | Injection patterns found, content redacted |
| `critical` | Severe injection attempt with multiple indicators |

When `blockHighRisk: false` (default), `high` and `critical` results are annotated and returned — the LLM sees the sanitized content. When `blockHighRisk: true`, those results are blocked entirely.

[View full example](examples/defender-config.ts)

For more detail on how the detection pipeline works, see the [`@stackone/defender`](https://www.npmjs.com/package/@stackone/defender) package.

### Testing with dryRun

You can use the `dryRun` option to return the api arguments from a tool call without making the actual api call:
Expand Down
4 changes: 4 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ Covers five approaches to finding the right tools at runtime: direct fetch with

Walks through every way to configure API keys and account IDs: reading from environment variables, passing them explicitly to the constructor, setting multiple accounts with `setAccounts()`, overriding per-tool collection or per individual tool, and fetching tools for multiple accounts in one call.

### [`defender-config.ts`](./defender-config.ts) -- Defender Configuration

Demonstrates the four ways to configure prompt-injection detection on a `StackOneToolSet`: omit (defer to project dashboard), `{ useProjectSettings: true }` (explicit form of the default), `null` (force off), and explicit config objects. Shows the `defenderMode` getter and the once-per-process warning the SDK emits when it overrides the dashboard. Sections 1–7 are construction-only; section 8 makes a live RPC call when `STACKONE_API_KEY` is set so you can inspect the `defenderMetadata` shape the backend returns.

## Environment Variables

| Variable | Required | Used By |
Expand Down
182 changes: 182 additions & 0 deletions examples/defender-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/**
* Defender configuration patterns.
*
* Sections 1–7 are construction-only: the four configuration modes,
* the `defenderMode` getter, the once-per-process override warning,
* and the runtime validation error. No API key required.
*
* Section 8 makes a live RPC call so you can see the defender
* annotations the backend returns. It's gated on `STACKONE_API_KEY`
* and skips with a friendly message if you don't have one set.
*
* Run with:
* pnpm run:example examples/defender-config.ts
*
* Or override the connector and tool for section 8:
* TOOL_NAME=calendly_get_current_user TOOL_BODY_JSON='{}' pnpm run:example examples/defender-config.ts
* TOOL_NAME=hibob_list_employees TOOL_BODY_JSON='{"page_size": 5}' pnpm run:example examples/defender-config.ts
*
* Live section env vars:
* STACKONE_API_KEY (required to run section 8; read from .env via run:example)
* STACKONE_ACCOUNT_ID (required for tool execution unless your key has a default account)
* TOOL_NAME (defaults to gmail_list_messages)
* TOOL_BODY_JSON (JSON body, defaults to `{}`)
*/

import process from 'node:process';
import type { JsonObject } from '@stackone/ai';
import { DEFAULT_DEFENDER_CONFIG, StackOneToolSet, ToolSetConfigError } from '@stackone/ai';

const heading = (label: string): void => {
console.log(`\n=== ${label} ===`);
};

// --- 1. Default — defer to project dashboard ---
const defaultMode = (): void => {
heading('1. Default (omit defender) — defer to dashboard');
const toolset = new StackOneToolSet({ apiKey: 'demo-key' });
console.log(` defenderMode: ${toolset.defenderMode}`);
console.log(' SDK adds no defender_config to the RPC payload.');
};

// --- 2. Explicit form of the default ---
const explicitProject = (): void => {
heading('2. defender: { useProjectSettings: true } — same as default');
const toolset = new StackOneToolSet({
apiKey: 'demo-key',
defender: { useProjectSettings: true },
});
console.log(` defenderMode: ${toolset.defenderMode}`);
};

// --- 3. Force off — overrides dashboard ---
const disabled = (): void => {
heading('3. defender: null — forcibly disabled (overrides dashboard)');
const toolset = new StackOneToolSet({ apiKey: 'demo-key', defender: null });
console.log(` defenderMode: ${toolset.defenderMode}`);
console.log(' SDK sends defender_config with all fields false.');
};

// --- 4. Spread defaults + tweak one field ---
const explicitOptIn = (): void => {
heading('4. Spread DEFAULT_DEFENDER_CONFIG + override one field');
const toolset = new StackOneToolSet({
apiKey: 'demo-key',
defender: { ...DEFAULT_DEFENDER_CONFIG, blockHighRisk: true },
});
console.log(` defenderMode: ${toolset.defenderMode}`);
};

// --- 5. Repeat the same shape — dedupe should suppress the warning ---
const repeatedExplicit = (): void => {
heading('5. Repeat the same explicit shape — warning suppressed');
const toolset = new StackOneToolSet({
apiKey: 'demo-key',
defender: { ...DEFAULT_DEFENDER_CONFIG, blockHighRisk: true },
});
console.log(` defenderMode: ${toolset.defenderMode}`);
};

// --- 6. Different explicit shape — fresh warning fires ---
const differentExplicit = (): void => {
heading('6. Different explicit shape — fresh warning');
const toolset = new StackOneToolSet({
apiKey: 'demo-key',
defender: {
enabled: true,
blockHighRisk: false,
useTier1Classification: true,
useTier2Classification: false,
},
});
console.log(` defenderMode: ${toolset.defenderMode}`);
};

// --- 7. Runtime validation ---
const invalidCombo = (): void => {
heading('7. useProjectSettings: true + other fields → throws');
try {
new StackOneToolSet({
apiKey: 'demo-key',
// @ts-expect-error - intentionally testing invalid runtime input
defender: { useProjectSettings: true, enabled: true },
});
console.log(' (no throw — unexpected!)');
} catch (err) {
if (err instanceof ToolSetConfigError) {
console.log(` caught ToolSetConfigError: ${err.message}`);
} else {
throw err;
}
}
};

// --- 8. Live tool call — inspect defender annotations in the real response ---
const liveCall = async (): Promise<void> => {
heading('8. Live tool call — inspect defender annotations');

if (!process.env.STACKONE_API_KEY) {
console.log(' Skipping — set STACKONE_API_KEY to run this section.');
console.log(' Optional: STACKONE_ACCOUNT_ID, TOOL_NAME, TOOL_BODY_JSON.');
return;
}

const toolName = process.env.TOOL_NAME ?? 'gmail_list_messages';
let body: JsonObject;
try {
body = JSON.parse(process.env.TOOL_BODY_JSON ?? '{}') as JsonObject;
} catch (err) {
console.log(` Invalid TOOL_BODY_JSON: ${(err as Error).message}`);
return;
}

const toolset = new StackOneToolSet({
defender: { ...DEFAULT_DEFENDER_CONFIG, blockHighRisk: false },
});

console.log(` Fetching tools and calling ${toolName}...`);
const tools = await toolset.fetchTools();
const tool = tools.toArray().find((t) => t.name === toolName);
if (!tool) {
console.log(` Tool "${toolName}" not in this account.`);
return;
}

const result = await tool.execute({ body });

// The backend surfaces defender annotations alongside the tool data:
//
// {
// data: <tool result>,
// defenderMetadata: {
// applied: boolean, // false if defender ran but did nothing
// result: {
// allowed: boolean, // false → backend blocked (with blockHighRisk: true)
// riskLevel: 'low' | 'medium' | 'high' | 'critical',
// fieldsSanitized: string[],
// patternsByField: Record<string, string[]>,
// detections: unknown[],
// tier2SkipReason?: string, // only when Tier 2 didn't run (e.g. no strings)
// latencyMs: number,
// }
// }
// }
const metadata = (result as { defenderMetadata?: unknown }).defenderMetadata;
if (metadata) {
console.log(' defenderMetadata:', JSON.stringify(metadata, null, 2));
} else {
console.log(' (no defenderMetadata in response — defender may not have run)');
}
};

// --- Run all sections ---
defaultMode();
explicitProject();
disabled();
explicitOptIn();
repeatedExplicit();
differentExplicit();
invalidCombo();
await liveCall();

console.log('\nDone — defender patterns demonstrated.');
25 changes: 24 additions & 1 deletion mocks/handlers.stackone-rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ export const stackoneRpcHandlers = [
const body = (await request.json()) as {
action?: string;
body?: Record<string, unknown>;
defender_config?: {
enabled?: boolean;
block_high_risk?: boolean;
use_tier1_classification?: boolean;
use_tier2_classification?: boolean;
};
headers?: Record<string, string>;
path?: Record<string, string>;
query?: Record<string, string>;
Expand Down Expand Up @@ -70,17 +76,34 @@ export const stackoneRpcHandlers = [
);
}

// Default response for other actions
// Synthetic defender annotations
const defenderMetadata = body.defender_config
? {
applied: body.defender_config.enabled !== false,
result: {
allowed: true,
riskLevel: 'low',
fieldsSanitized: [],
patternsByField: {},
detections: [],
latencyMs: 0,
},
}
: undefined;

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

export { DEFAULT_DEFENDER_CONFIG } from './types';

export type {
AISDKToolDefinition,
AISDKToolResult,
DefenderConfig,
DefenderMode,
ExecuteConfig,
ExecuteOptions,
JsonObject,
Expand Down
29 changes: 29 additions & 0 deletions src/rpc-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,32 @@ test('should send x-account-id as HTTP header', async () => {
bodyHeader: 'test-account-123',
});
});

test('should forward defender_config in request payload', async () => {
const client = new RpcClient({
serverURL: TEST_BASE_URL,
security: { username: 'test-api-key' },
});

const response = await client.actions.rpcAction({
action: 'custom_action',
defender_config: { enabled: true, block_high_risk: false },
});

expect(response.data).toMatchObject({
received: { defender_config: { enabled: true, block_high_risk: false } },
});
});

test('should omit defender_config from payload when not provided', async () => {
const client = new RpcClient({
serverURL: TEST_BASE_URL,
security: { username: 'test-api-key' },
});

const response = await client.actions.rpcAction({
action: 'custom_action',
});

expect((response.data as Record<string, unknown>).received).not.toHaveProperty('defender_config');
});
5 changes: 4 additions & 1 deletion src/rpc-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,13 @@ export class RpcClient {
const requestBody = {
action: validatedRequest.action,
body: validatedRequest.body,
...(validatedRequest.defender_config !== undefined && {
defender_config: validatedRequest.defender_config,
}),
headers: validatedRequest.headers,
path: validatedRequest.path,
query: validatedRequest.query,
} as const satisfies RpcActionRequest;
} satisfies RpcActionRequest;

// Forward StackOne-specific headers as HTTP headers
const requestHeaders = validatedRequest.headers;
Expand Down
Loading
Loading