Skip to content

Commit f6f82a4

Browse files
committed
Add secrets detection tool pre-invoke hook
Signed-off-by: lucarlig <luca.carlig@ibm.com>
1 parent 5077ff9 commit f6f82a4

10 files changed

Lines changed: 151 additions & 3 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

plugins/rust/python-package/secrets_detection/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "secrets_detection"
3-
version = "0.3.1"
3+
version = "0.3.2"
44
edition.workspace = true
55
authors.workspace = true
66
license.workspace = true

plugins/rust/python-package/secrets_detection/cpex_secrets_detection/plugin-manifest.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
description: "Detect likely credentials and secrets in prompt input, tool output, and resource content"
22
author: "ContextForge Contributors"
3-
version: "0.3.1"
3+
version: "0.3.2"
44
kind: "cpex_secrets_detection.secrets_detection.SecretsDetectionPlugin"
55
available_hooks:
66
- "prompt_pre_fetch"
7+
- "tool_pre_invoke"
78
- "tool_post_invoke"
89
- "resource_post_fetch"
910
default_configs:

plugins/rust/python-package/secrets_detection/cpex_secrets_detection/secrets_detection.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ def __init__(self, config) -> None:
2020
async def prompt_pre_fetch(self, payload, context):
2121
return self._core.prompt_pre_fetch(payload, context)
2222

23+
async def tool_pre_invoke(self, payload, context):
24+
return self._core.tool_pre_invoke(payload, context)
25+
2326
async def tool_post_invoke(self, payload, context):
2427
return self._core.tool_post_invoke(payload, context)
2528

plugins/rust/python-package/secrets_detection/cpex_secrets_detection/secrets_detection_rust/__init__.pyi

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ __all__ = [
1212
class SecretsDetectionPluginCore:
1313
def __new__(cls, config: typing.Any) -> SecretsDetectionPluginCore: ...
1414
def prompt_pre_fetch(self, payload: typing.Any, _context: typing.Any) -> typing.Any: ...
15+
def tool_pre_invoke(self, payload: typing.Any, _context: typing.Any) -> typing.Any: ...
1516
def tool_post_invoke(self, payload: typing.Any, _context: typing.Any) -> typing.Any: ...
1617
def resource_post_fetch(self, payload: typing.Any, _context: typing.Any) -> typing.Any: ...
1718

plugins/rust/python-package/secrets_detection/src/plugin.rs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,66 @@ impl SecretsDetectionPluginCore {
8282
default_result(py, "PromptPrehookResult")
8383
}
8484

85+
pub fn tool_pre_invoke(
86+
&self,
87+
py: Python<'_>,
88+
payload: &Bound<'_, PyAny>,
89+
_context: &Bound<'_, PyAny>,
90+
) -> PyResult<Py<PyAny>> {
91+
let arguments = payload.getattr("arguments")?;
92+
let (count, redacted_arguments, findings) = scan_container(py, &arguments, &self.config)?;
93+
if self.should_block(count) {
94+
let modified_payload = if self.config.redact && count > 0 {
95+
copy_with_update(
96+
py,
97+
payload,
98+
[("arguments", redacted_arguments.clone().unbind())],
99+
)?
100+
} else {
101+
payload.clone().unbind()
102+
};
103+
return blocked_result(
104+
py,
105+
"ToolPreInvokeResult",
106+
"Potential secrets detected in tool arguments",
107+
count,
108+
findings.as_any(),
109+
modified_payload,
110+
);
111+
}
112+
113+
if self.config.redact && count > 0 {
114+
let modified_payload =
115+
copy_with_update(py, payload, [("arguments", redacted_arguments.unbind())])?;
116+
return build_framework_object(
117+
py,
118+
"ToolPreInvokeResult",
119+
[
120+
("modified_payload", modified_payload),
121+
(
122+
"metadata",
123+
redaction_metadata(py, count)?.into_any().unbind(),
124+
),
125+
],
126+
);
127+
}
128+
129+
if count > 0 {
130+
return build_framework_object(
131+
py,
132+
"ToolPreInvokeResult",
133+
[(
134+
"metadata",
135+
findings_metadata(py, count, findings.as_any())?
136+
.into_any()
137+
.unbind(),
138+
)],
139+
);
140+
}
141+
142+
default_result(py, "ToolPreInvokeResult")
143+
}
144+
85145
pub fn tool_post_invoke(
86146
&self,
87147
py: Python<'_>,

plugins/tests/secrets_detection/helpers.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
ResourceHookType,
1414
ResourcePostFetchPayload,
1515
ToolHookType,
16+
ToolPreInvokePayload,
1617
ToolPostInvokePayload,
1718
)
1819

@@ -32,6 +33,7 @@
3233
"RootModel",
3334
"SecretsDetectionPlugin",
3435
"ToolHookType",
36+
"ToolPreInvokePayload",
3537
"ToolPostInvokePayload",
3638
"make_config",
3739
"make_context",

plugins/tests/secrets_detection/test_hook_dispatch.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ async def manager(
2828
config_path = tmp_path / "secrets_detection.yaml"
2929
configured_hooks = hooks or [
3030
PromptHookType.PROMPT_PRE_FETCH.value,
31+
ToolHookType.TOOL_PRE_INVOKE.value,
3132
ToolHookType.TOOL_POST_INVOKE.value,
3233
ResourceHookType.RESOURCE_POST_FETCH.value,
3334
]
@@ -189,6 +190,28 @@ async def test_tool_post_invoke_blocks_without_redaction_via_plugin_manager(
189190
finally:
190191
await manager.shutdown()
191192

193+
async def test_tool_pre_invoke_blocks_without_redaction_via_plugin_manager(
194+
self, tmp_path
195+
):
196+
manager = await self.manager(
197+
tmp_path, {"block_on_detection": True, "redact": False}
198+
)
199+
try:
200+
payload = ToolPreInvokePayload(
201+
name="echo",
202+
arguments={"message": "AWS_ACCESS_KEY_ID=AKIAFAKE12345EXAMPLE"},
203+
)
204+
result, _ = await manager.invoke_hook(
205+
ToolHookType.TOOL_PRE_INVOKE,
206+
payload,
207+
global_context=self.global_context(),
208+
)
209+
assert result.continue_processing is False
210+
assert result.violation.code == "SECRETS_DETECTED"
211+
assert result.modified_payload == payload
212+
finally:
213+
await manager.shutdown()
214+
192215
async def test_resource_post_fetch_blocks_without_redaction_via_plugin_manager(
193216
self, tmp_path
194217
):
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from pathlib import Path
2+
3+
import yaml
4+
5+
6+
def test_manifest_declares_tool_pre_invoke_hook():
7+
manifest_path = (
8+
Path(__file__).resolve().parents[3]
9+
/ "plugins"
10+
/ "rust"
11+
/ "python-package"
12+
/ "secrets_detection"
13+
/ "cpex_secrets_detection"
14+
/ "plugin-manifest.yaml"
15+
)
16+
17+
manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8"))
18+
19+
assert "tool_pre_invoke" in manifest["available_hooks"]

plugins/tests/secrets_detection/test_plugin_hooks.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,45 @@ async def test_prompt_pre_fetch_blocks_without_redaction(self):
5050
assert result.violation.code == "SECRETS_DETECTED"
5151
assert result.modified_payload == payload
5252

53+
async def test_tool_pre_invoke_redacts_arguments_without_blocking(self, plugin):
54+
payload = ToolPreInvokePayload(
55+
name="echo",
56+
arguments={"message": "AWS_ACCESS_KEY_ID=AKIAFAKE12345EXAMPLE"},
57+
)
58+
59+
result = await plugin.tool_pre_invoke(payload, make_context())
60+
61+
assert result.continue_processing is True
62+
assert result.violation is None
63+
assert result.modified_payload is not None
64+
assert result.modified_payload is not payload
65+
assert (
66+
result.modified_payload.arguments["message"]
67+
== "AWS_ACCESS_KEY_ID=[REDACTED]"
68+
)
69+
assert (
70+
payload.arguments["message"]
71+
== "AWS_ACCESS_KEY_ID=AKIAFAKE12345EXAMPLE"
72+
)
73+
assert result.metadata == {"secrets_redacted": True, "count": 1}
74+
75+
async def test_tool_pre_invoke_blocks_without_redaction(self):
76+
plugin = SecretsDetectionPlugin(make_config(block_on_detection=True, redact=False))
77+
payload = ToolPreInvokePayload(
78+
name="echo",
79+
arguments={"message": "AWS_ACCESS_KEY_ID=AKIAFAKE12345EXAMPLE"},
80+
)
81+
82+
result = await plugin.tool_pre_invoke(payload, make_context())
83+
84+
assert result.continue_processing is False
85+
assert result.violation is not None
86+
assert result.violation.code == "SECRETS_DETECTED"
87+
assert result.violation.description == (
88+
"Potential secrets detected in tool arguments"
89+
)
90+
assert result.modified_payload == payload
91+
5392
async def test_prompt_pre_fetch_blocks_with_redaction_without_leaking_secret(self):
5493
plugin = SecretsDetectionPlugin(make_config(block_on_detection=True, redact=True))
5594
payload = PromptPrehookPayload(

0 commit comments

Comments
 (0)