Skip to content

Commit ad9e68e

Browse files
authored
Merge pull request #9 from lua-ai-global/feat/tool-result-other-adapters
feat: tool-result scanning for langchain / openai-agents / genkit / l…
2 parents 6ee87a5 + 276e651 commit ad9e68e

9 files changed

Lines changed: 258 additions & 16 deletions

File tree

packages/governance/CHANGELOG.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,58 @@
11
# Changelog
22

3+
## [0.15.0] - 2026-04-30 — Tool-result scanning across the framework adapters
4+
5+
0.14 wired tool-result scanning into the Mastra processor and MCP adapter
6+
only. 0.15 rolls the same protection out to the four other adapters that
7+
already do tool wrapping at construction time:
8+
9+
- **LangChain**`tool.invoke` wrap (in both `governTool` and `governTools`)
10+
- **OpenAI Agents**`tool.invoke` AND `tool.execute` wraps
11+
- **Genkit**`tool.call` wrap
12+
- **LlamaIndex**`tool.call` wrap
13+
14+
For each, the wrapped invoke/call/execute now runs the tool's return value
15+
through `scanToolResult()` (the same shared signal-then-enforce helper
16+
the Mastra processor uses) at stage `tool_result` before returning. On
17+
block, a `{ blocked, reason, ruleId }` redacted detail object replaces
18+
the original output, so the LLM never ingests the poisoned content.
19+
20+
### Added — `scanToolResults` config flag on each adapter
21+
22+
```ts
23+
const { tools } = await governLangChainTools(gov, [searchTool], {
24+
agentName: "my-agent",
25+
scanToolResults: true, // default — opt-out via false
26+
toolResultInjectionThreshold: 0.5,
27+
});
28+
```
29+
30+
Default `true` (matches the Mastra processor default). Existing callers
31+
who upgrade to 0.15 get tool-result scanning automatically; set
32+
`scanToolResults: false` to skip — useful for test environments that
33+
mock tool returns.
34+
35+
### What didn't change
36+
37+
- **Anthropic / Mistral / Ollama** still use a caller-driven
38+
`handleToolUse` / `handleToolCall` pattern. Tool-result scanning here
39+
has to be integrated at the call site by the user — the SDK can't
40+
intercept transparently. Consider using `gov.scanToolResult()` in
41+
your handler manually.
42+
- **Vercel AI** — no native tool-wrapping path on this adapter today.
43+
Tracked as a follow-up; for now use `scanOutput` on model output.
44+
- **Bedrock** — entry-gate only; tool execution happens inside AWS,
45+
no post-execute hook is exposed by Bedrock Agents.
46+
- **Mastra middleware adapter** (`mastra.ts`, not the processor) — uses
47+
a different wrap shape; coverage to follow.
48+
49+
### Migration
50+
51+
Drop-in. No public type breakage. The new config fields are optional
52+
and additive. Existing tests that mock tool returns may need
53+
`scanToolResults: false` if they don't expect the helper's path engine
54+
to run on their fixtures.
55+
356
## [0.14.1] - 2026-04-30 — Field extraction on the `process` stage
457

558
`scope_boundary` and `network_allowlist` rules at stage `process` (the

packages/governance/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "governance-sdk",
3-
"version": "0.14.1",
3+
"version": "0.15.0",
44
"description": "AI Agent Governance for TypeScript — policy enforcement, scoring, compliance, and audit for AI agents",
55
"type": "module",
66
"main": "./dist/index.js",

packages/governance/src/plugins/genkit-types.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,20 @@ export interface GovernGenkitConfig {
8282
onApprovalRequired?: (decision: EnforcementDecision, toolName: string) => void;
8383
actionMapper?: (toolName: string) => PolicyAction;
8484
sessionTokenTracker?: () => number;
85+
/**
86+
* Master switch for tool-result scanning (governance-sdk 0.15+).
87+
* Default: `true`. Wrapped tools run their return values through the
88+
* policy engine at stage `tool_result` before returning. On block,
89+
* the redacted detail object replaces the original output so the
90+
* agent never ingests poisoned tool content. Set `false` to skip
91+
* — useful for test environments that mock tool returns.
92+
*/
93+
scanToolResults?: boolean;
94+
/**
95+
* Detection threshold for the local injection signal (0-1) that
96+
* `scanToolResult` populates on `ctx.mlInjectionScore`. Default 0.5.
97+
*/
98+
toolResultInjectionThreshold?: number;
8599
}
86100

87101
// ─── Results ────────────────────────────────────────────────

packages/governance/src/plugins/genkit.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export type {
3838

3939
import { handleOutcome, GovernanceBlockedError, GovernanceApprovalRequiredError } from "./outcome-handler.js";
4040
import type { OutcomeCallbacks } from "./outcome-handler.js";
41+
import { scanToolResult } from "../tool-result-scan.js";
4142

4243
// ─── Blocked Error ──────────────────────────────────────────
4344

@@ -99,10 +100,35 @@ function createAuditor(governance: GovernanceInstance, agentId: string) {
99100
});
100101
}
101102

103+
/**
104+
* Build a result-scan closure bound to this governance instance + agent.
105+
* Returned function: takes the tool's raw output, runs it through the
106+
* policy engine at stage="tool_result", returns either the original
107+
* output (allow) or a redacted detail object (block / require_approval).
108+
*
109+
* No-op when `config.scanToolResults === false`. Default-on so any
110+
* Genkit user upgrading to SDK 0.15+ gets injection scanning of tool
111+
* returns automatically — same default as the Mastra processor.
112+
*/
113+
function createResultScanner(
114+
governance: GovernanceInstance, agentId: string, config: GovernGenkitConfig,
115+
) {
116+
return async (toolName: string, args: Record<string, unknown> | undefined, output: unknown): Promise<unknown> => {
117+
if (config.scanToolResults === false) return output;
118+
const scanned = await scanToolResult({
119+
governance, agentId, agentName: config.agentName, tool: toolName,
120+
args, result: output,
121+
injectionThreshold: config.toolResultInjectionThreshold,
122+
});
123+
return scanned.result;
124+
};
125+
}
126+
102127
function wrapTool(
103128
tool: GenkitTool,
104129
enforce: ReturnType<typeof createEnforcer>,
105130
audit: ReturnType<typeof createAuditor>,
131+
scanResult: ReturnType<typeof createResultScanner>,
106132
): GenkitTool {
107133
return {
108134
...tool,
@@ -111,8 +137,11 @@ function wrapTool(
111137
const decision = await enforce(tool.name, inputRecord);
112138
try {
113139
const output = await tool.call(input, options);
140+
// Scan tool result before returning to the agent loop. On block
141+
// the LLM gets a redacted detail object in place of the original.
142+
const finalOutput = await scanResult(tool.name, inputRecord, output);
114143
await audit(tool.name, "success");
115-
return output;
144+
return finalOutput;
116145
} catch (error) {
117146
await audit(tool.name, "failure", { error: error instanceof Error ? error.message : String(error) });
118147
throw error;
@@ -134,9 +163,10 @@ export async function governGenkitTools(
134163

135164
const enforce = createEnforcer(governance, result.id, config);
136165
const audit = createAuditor(governance, result.id);
166+
const scanResult = createResultScanner(governance, result.id, config);
137167

138168
return {
139-
tools: tools.map((tool) => wrapTool(tool, enforce, audit)),
169+
tools: tools.map((tool) => wrapTool(tool, enforce, audit, scanResult)),
140170
agentId: result.id,
141171
score: result.score,
142172
level: result.level,

packages/governance/src/plugins/langchain.ts

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import type {
4646
import type { AgentRegistration, AgentFramework } from "../types";
4747
import { handleOutcome, GovernanceBlockedError, GovernanceApprovalRequiredError } from "./outcome-handler.js";
4848
import type { OutcomeCallbacks } from "./outcome-handler.js";
49+
import { scanToolResult } from "../tool-result-scan.js";
4950

5051
// ─── Types ──────────────────────────────────────────────────────
5152

@@ -115,6 +116,15 @@ export interface GovernToolConfig {
115116
onApprovalRequired?: (decision: EnforcementDecision, toolName: string) => void;
116117
actionMapper?: (toolName: string) => PolicyAction;
117118
sessionTokenTracker?: () => number;
119+
/**
120+
* Master switch for tool-result scanning (governance-sdk 0.15+).
121+
* Default: `true`. Wrapped tools run their return values through the
122+
* policy engine at stage `tool_result` before returning to the agent
123+
* loop. On block, the redacted detail object replaces the original.
124+
*/
125+
scanToolResults?: boolean;
126+
/** Detection threshold for the local injection signal (0-1). Default 0.5. */
127+
toolResultInjectionThreshold?: number;
118128
}
119129

120130
export interface GovernedResult {
@@ -196,6 +206,32 @@ function createAuditor(governance: GovernanceInstance, agentId: string) {
196206
};
197207
}
198208

209+
/**
210+
* Build a result-scan closure bound to this governance + agent. Runs
211+
* the tool's raw output through the policy engine at stage `tool_result`
212+
* and returns either the original (allow) or a redacted detail object
213+
* (block / require_approval). No-op when `config.scanToolResults === false`.
214+
*/
215+
function createResultScanner(
216+
governance: GovernanceInstance,
217+
agentId: string,
218+
config: GovernToolConfig,
219+
) {
220+
return async (
221+
toolName: string,
222+
args: Record<string, unknown> | undefined,
223+
output: unknown,
224+
): Promise<unknown> => {
225+
if (config.scanToolResults === false) return output;
226+
const scanned = await scanToolResult({
227+
governance, agentId, agentName: config.agentName, tool: toolName,
228+
args, result: output,
229+
injectionThreshold: config.toolResultInjectionThreshold,
230+
});
231+
return scanned.result;
232+
};
233+
}
234+
199235
// ─── Govern a Single Tool ───────────────────────────────────────
200236

201237
/**
@@ -211,20 +247,31 @@ export async function governTool<T extends LangChainTool>(
211247
const result = await registerAgent(governance, config, [tool.name]);
212248
const enforce = createEnforcer(governance, result.id, result.level, config);
213249
const audit = createAuditor(governance, result.id);
250+
const scanResult = createResultScanner(governance, result.id, config);
214251

215252
const governed = {
216253
...tool,
217254
agentId: result.id,
218255
score: result.score,
219256
level: result.level,
220257
governance,
221-
invoke: async (input: unknown, config?: LangChainRunnableConfig): Promise<unknown> => {
258+
invoke: async (input: unknown, runConfig?: LangChainRunnableConfig): Promise<unknown> => {
222259
await enforce(tool.name, input);
223260

224261
try {
225-
const output = await tool.invoke(input, config);
262+
const output = await tool.invoke(input, runConfig);
263+
// Guard the cast — LangChain DynamicTool inputs are commonly
264+
// strings. An unchecked cast would set ctx.input to a string
265+
// (typed as Record<string, unknown>), and condition evaluators
266+
// reading properties off it would silently get undefined and
267+
// never match. Mirror the guard createEnforcer uses on its own
268+
// input field.
269+
const argRecord = typeof input === "object" && input !== null
270+
? input as Record<string, unknown>
271+
: undefined;
272+
const finalOutput = await scanResult(tool.name, argRecord, output);
226273
await audit(tool.name, "success");
227-
return output;
274+
return finalOutput;
228275
} catch (error) {
229276
await audit(tool.name, "failure", {
230277
error: error instanceof Error ? error.message : String(error),
@@ -253,16 +300,27 @@ export async function governTools<T extends LangChainTool>(
253300
const result = await registerAgent(governance, config, toolNames);
254301
const enforce = createEnforcer(governance, result.id, result.level, config);
255302
const audit = createAuditor(governance, result.id);
303+
const scanResult = createResultScanner(governance, result.id, config);
256304

257305
const governed = tools.map((tool) => ({
258306
...tool,
259-
invoke: async (input: unknown, config?: LangChainRunnableConfig): Promise<unknown> => {
307+
invoke: async (input: unknown, runConfig?: LangChainRunnableConfig): Promise<unknown> => {
260308
await enforce(tool.name, input);
261309

262310
try {
263-
const output = await tool.invoke(input, config);
311+
const output = await tool.invoke(input, runConfig);
312+
// Guard the cast — LangChain DynamicTool inputs are commonly
313+
// strings. An unchecked cast would set ctx.input to a string
314+
// (typed as Record<string, unknown>), and condition evaluators
315+
// reading properties off it would silently get undefined and
316+
// never match. Mirror the guard createEnforcer uses on its own
317+
// input field.
318+
const argRecord = typeof input === "object" && input !== null
319+
? input as Record<string, unknown>
320+
: undefined;
321+
const finalOutput = await scanResult(tool.name, argRecord, output);
264322
await audit(tool.name, "success");
265-
return output;
323+
return finalOutput;
266324
} catch (error) {
267325
await audit(tool.name, "failure", {
268326
error: error instanceof Error ? error.message : String(error),

packages/governance/src/plugins/llamaindex-types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,15 @@ export interface GovernLlamaIndexConfig {
8282
onApprovalRequired?: (decision: EnforcementDecision, toolName: string) => void;
8383
actionMapper?: (toolName: string) => PolicyAction;
8484
sessionTokenTracker?: () => number;
85+
/**
86+
* Master switch for tool-result scanning (governance-sdk 0.15+).
87+
* Default: `true`. Wrapped tools run their return values through the
88+
* policy engine at stage `tool_result` before returning to the agent
89+
* loop. On block, the redacted detail object replaces the original.
90+
*/
91+
scanToolResults?: boolean;
92+
/** Detection threshold for the local injection signal (0-1). Default 0.5. */
93+
toolResultInjectionThreshold?: number;
8594
}
8695

8796
// ─── Results ────────────────────────────────────────────────

packages/governance/src/plugins/llamaindex.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export type {
4040

4141
import { handleOutcome, GovernanceBlockedError, GovernanceApprovalRequiredError } from "./outcome-handler.js";
4242
import type { OutcomeCallbacks } from "./outcome-handler.js";
43+
import { scanToolResult } from "../tool-result-scan.js";
4344

4445
// ─── Pre/post LLM wrapper ───────────────────────────────────
4546
// See ./llamaindex-llm.ts for docs + examples.
@@ -99,10 +100,42 @@ function createAuditor(governance: GovernanceInstance, agentId: string) {
99100
});
100101
}
101102

103+
/**
104+
* Build a result-scan closure bound to this governance + agent. Runs the
105+
* tool's raw output through the policy engine at stage `tool_result` and
106+
* returns either the original (allow) or a redacted detail object (block).
107+
* No-op when `config.scanToolResults === false`. Default-on.
108+
*/
109+
function createResultScanner(
110+
governance: GovernanceInstance, agentId: string, config: GovernLlamaIndexConfig,
111+
) {
112+
return async (toolName: string, args: Record<string, unknown> | undefined, output: LlamaIndexJSONValue): Promise<LlamaIndexJSONValue> => {
113+
if (config.scanToolResults === false) return output;
114+
const scanned = await scanToolResult({
115+
governance, agentId, agentName: config.agentName, tool: toolName,
116+
args, result: output,
117+
injectionThreshold: config.toolResultInjectionThreshold,
118+
});
119+
// BlockedToolResult.ruleId is `string | null`, but LlamaIndexJSONValue
120+
// explicitly excludes `null` per the SDK contract. Coerce on block so
121+
// downstream LlamaIndex JSON walkers don't trip on the null property.
122+
if (scanned.blocked) {
123+
const blocked = scanned.result as { blocked: true; reason: string; ruleId: string | null };
124+
return {
125+
blocked: true,
126+
reason: blocked.reason,
127+
ruleId: blocked.ruleId ?? "unknown",
128+
};
129+
}
130+
return scanned.result as LlamaIndexJSONValue;
131+
};
132+
}
133+
102134
function wrapTool(
103135
tool: LlamaIndexTool,
104136
enforce: ReturnType<typeof createEnforcer>,
105137
audit: ReturnType<typeof createAuditor>,
138+
scanResult: ReturnType<typeof createResultScanner>,
106139
): LlamaIndexTool {
107140
if (!tool.call) return tool;
108141
const toolName = tool.metadata.name;
@@ -112,8 +145,9 @@ function wrapTool(
112145
const decision = await enforce(toolName, input);
113146
try {
114147
const output = await tool.call!(input);
148+
const finalOutput = await scanResult(toolName, input, output);
115149
await audit(toolName, "success");
116-
return output;
150+
return finalOutput;
117151
} catch (error) {
118152
await audit(toolName, "failure", { error: error instanceof Error ? error.message : String(error) });
119153
throw error;
@@ -135,9 +169,10 @@ export async function governLlamaIndexTools(
135169

136170
const enforce = createEnforcer(governance, result.id, config);
137171
const audit = createAuditor(governance, result.id);
172+
const scanResult = createResultScanner(governance, result.id, config);
138173

139174
return {
140-
tools: tools.map((tool) => wrapTool(tool, enforce, audit)),
175+
tools: tools.map((tool) => wrapTool(tool, enforce, audit, scanResult)),
141176
agentId: result.id,
142177
score: result.score,
143178
level: result.level,
@@ -160,9 +195,10 @@ export async function governLlamaIndexAgent(
160195

161196
const enforce = createEnforcer(governance, result.id, config);
162197
const audit = createAuditor(governance, result.id);
198+
const scanResult = createResultScanner(governance, result.id, config);
163199

164200
return {
165-
agent: { ...agent, tools: agent.tools.map((tool) => wrapTool(tool, enforce, audit)) },
201+
agent: { ...agent, tools: agent.tools.map((tool) => wrapTool(tool, enforce, audit, scanResult)) },
166202
agentId: result.id,
167203
score: result.score,
168204
level: result.level,

packages/governance/src/plugins/openai-agents-types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,15 @@ export interface GovernAgentConfig {
122122
onApprovalRequired?: (decision: EnforcementDecision, toolName: string) => void;
123123
actionMapper?: (toolName: string) => PolicyAction;
124124
sessionTokenTracker?: () => number;
125+
/**
126+
* Master switch for tool-result scanning (governance-sdk 0.15+).
127+
* Default: `true`. Wrapped tools run their return values through the
128+
* policy engine at stage `tool_result` before returning to the agent
129+
* loop. On block, the redacted detail object replaces the original.
130+
*/
131+
scanToolResults?: boolean;
132+
/** Detection threshold for the local injection signal (0-1). Default 0.5. */
133+
toolResultInjectionThreshold?: number;
125134
}
126135

127136
// ─── Results ────────────────────────────────────────────────

0 commit comments

Comments
 (0)