Skip to content

Commit 0c79f65

Browse files
author
bgagent
committed
feat(project): add post hook screening in agent
1 parent 783e2b7 commit 0c79f65

14 files changed

Lines changed: 701 additions & 46 deletions

File tree

.gitleaks.toml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
title = "sample-autonomous-cloud-coding-agents"
2+
3+
[extend]
4+
useDefault = true
5+
6+
[[allowlists]]
7+
description = "PEM-shaped fixtures for output_scanner / hook tests (not real keys)."
8+
targetRules = ["private-key"]
9+
paths = [
10+
"^agent/tests/test_hooks\\.py$",
11+
"^agent/tests/test_output_scanner\\.py$",
12+
]

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ repos:
2222

2323
- repo: local
2424
hooks:
25-
- id: gitleaks-staged
25+
- id: gitleaks
2626
name: gitleaks (staged)
2727
entry: bash -lc 'cd "$(git rev-parse --show-toplevel)" && mise run security:secrets:staged'
2828
language: system

agent/src/hooks.py

Lines changed: 86 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
1-
"""PreToolUse hook callback for Cedar policy enforcement.
1+
"""PreToolUse and PostToolUse hook callbacks for policy enforcement.
22
3-
Integrates the PolicyEngine with the Claude Agent SDK's hook system
4-
to enforce tool-use policies at runtime.
3+
Integrates the PolicyEngine (Cedar, pre-execution) and the output scanner
4+
(regex, post-execution) with the Claude Agent SDK's hook system to enforce
5+
tool-use policies at runtime.
56
"""
67

78
from __future__ import annotations
89

910
import json
1011
from typing import TYPE_CHECKING, Any
1112

13+
from output_scanner import scan_tool_output
1214
from shell import log
1315

1416
if TYPE_CHECKING:
@@ -82,6 +84,70 @@ async def pre_tool_use_hook(
8284
}
8385

8486

87+
async def post_tool_use_hook(
88+
hook_input: Any,
89+
tool_use_id: str | None,
90+
hook_context: Any,
91+
*,
92+
trajectory: _TrajectoryWriter | None = None,
93+
) -> dict:
94+
"""PostToolUse hook: screen tool output for secrets/PII.
95+
96+
Returns a dict with hookSpecificOutput. When sensitive content is
97+
detected the response includes ``updatedMCPToolOutput`` containing the
98+
redacted version (steered enforcement — content is sanitized, not
99+
blocked).
100+
"""
101+
_PASS_THROUGH: dict = {"hookSpecificOutput": {"hookEventName": "PostToolUse"}}
102+
_FAIL_CLOSED: dict = {
103+
"hookSpecificOutput": {
104+
"hookEventName": "PostToolUse",
105+
"updatedMCPToolOutput": "[Output redacted: screening error — fail-closed]",
106+
}
107+
}
108+
109+
if not isinstance(hook_input, dict):
110+
log("WARN", "PostToolUse hook received non-dict input — passing through")
111+
return _PASS_THROUGH
112+
113+
tool_name = hook_input.get("tool_name", "unknown")
114+
115+
if "tool_response" not in hook_input:
116+
log("WARN", f"PostToolUse hook: missing 'tool_response' key for {tool_name}")
117+
return _PASS_THROUGH
118+
119+
tool_response = hook_input["tool_response"]
120+
121+
# Normalise non-string responses
122+
if not isinstance(tool_response, str):
123+
tool_response = str(tool_response)
124+
125+
try:
126+
result = scan_tool_output(tool_response)
127+
except Exception as exc:
128+
log("ERROR", f"Output scanner failed for {tool_name}: {type(exc).__name__}: {exc}")
129+
if trajectory:
130+
trajectory.write_output_screening_decision(
131+
tool_name, [f"SCANNER_ERROR: {type(exc).__name__}"], redacted=True, duration_ms=0.0
132+
)
133+
return _FAIL_CLOSED
134+
135+
if result.has_sensitive_content:
136+
if trajectory:
137+
trajectory.write_output_screening_decision(
138+
tool_name, result.findings, redacted=True, duration_ms=result.duration_ms
139+
)
140+
log("POLICY", f"OUTPUT REDACTED: {tool_name}{', '.join(result.findings)}")
141+
return {
142+
"hookSpecificOutput": {
143+
"hookEventName": "PostToolUse",
144+
"updatedMCPToolOutput": result.redacted_content,
145+
}
146+
}
147+
148+
return _PASS_THROUGH
149+
150+
85151
def build_hook_matchers(
86152
engine: PolicyEngine,
87153
trajectory: _TrajectoryWriter | None = None,
@@ -99,6 +165,7 @@ def build_hook_matchers(
99165
HookInput,
100166
HookJSONOutput,
101167
HookMatcher,
168+
PostToolUseHookSpecificOutput,
102169
SyncHookJSONOutput,
103170
)
104171

@@ -110,8 +177,23 @@ async def _pre(
110177
result = await pre_tool_use_hook(
111178
hook_input, tool_use_id, ctx, engine=engine, trajectory=trajectory
112179
)
113-
return SyncHookJSONOutput(**result) # type: ignore[typeddict-item]
180+
return SyncHookJSONOutput(**result)
181+
182+
async def _post(
183+
hook_input: HookInput, tool_use_id: str | None, ctx: HookContext
184+
) -> HookJSONOutput:
185+
try:
186+
result = await post_tool_use_hook(hook_input, tool_use_id, ctx, trajectory=trajectory)
187+
return SyncHookJSONOutput(**result)
188+
except Exception as exc:
189+
log("ERROR", f"PostToolUse wrapper crashed: {type(exc).__name__}: {exc}")
190+
fail_closed: PostToolUseHookSpecificOutput = {
191+
"hookEventName": "PostToolUse",
192+
"updatedMCPToolOutput": "[Output redacted: hook error — fail-closed]",
193+
}
194+
return SyncHookJSONOutput(hookSpecificOutput=fail_closed)
114195

115196
return {
116197
"PreToolUse": [HookMatcher(matcher=None, hooks=[_pre])],
198+
"PostToolUse": [HookMatcher(matcher=None, hooks=[_post])],
117199
}

agent/src/output_scanner.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"""Regex-based secret and PII scanner for tool output screening.
2+
3+
Scans tool outputs for sensitive content (secrets, tokens, private keys,
4+
connection strings) and produces redacted versions suitable for re-injection
5+
into agent context. Patterns are compiled once at module level.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import re
11+
import time
12+
from dataclasses import dataclass, field
13+
14+
# ---------------------------------------------------------------------------
15+
# Scan result
16+
# ---------------------------------------------------------------------------
17+
18+
19+
@dataclass(frozen=True)
20+
class ScanResult:
21+
"""Result of scanning tool output for sensitive content."""
22+
23+
has_sensitive_content: bool
24+
redacted_content: str
25+
findings: list[str] = field(default_factory=list)
26+
duration_ms: float = 0.0
27+
28+
29+
# ---------------------------------------------------------------------------
30+
# Pattern registry
31+
# ---------------------------------------------------------------------------
32+
33+
_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
34+
# AWS access key IDs
35+
("AWS_KEY", re.compile(r"AKIA[0-9A-Z]{16}")),
36+
# AWS secret access keys (40-char base64 near common keywords)
37+
(
38+
"AWS_SECRET",
39+
re.compile(
40+
r"(?:aws_secret_access_key|SecretAccessKey|AWS_SECRET_ACCESS_KEY)"
41+
r"[\s=:\"']+([A-Za-z0-9/+=]{40})",
42+
re.IGNORECASE,
43+
),
44+
),
45+
# GitHub tokens (PAT, OAuth, App, user-to-server, fine-grained)
46+
("GITHUB_TOKEN", re.compile(r"(?:ghp|gho|ghs|ghu)_[a-zA-Z0-9]{36}")),
47+
("GITHUB_PAT", re.compile(r"github_pat_[a-zA-Z0-9_]{22,}")),
48+
# Private keys (PEM blocks)
49+
(
50+
"PRIVATE_KEY",
51+
re.compile(
52+
r"-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----"
53+
r"[\s\S]*?"
54+
r"-----END (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----"
55+
),
56+
),
57+
# Generic Bearer / token patterns (min 20-char token to avoid false positives
58+
# on natural English like "bearer of good news")
59+
("BEARER_TOKEN", re.compile(r"Bearer\s+[a-zA-Z0-9\-._~+/]{20,}=*", re.IGNORECASE)),
60+
# Connection strings with embedded passwords (protocol name capped at 20
61+
# chars to avoid quadratic backtracking on long alphabetic strings)
62+
(
63+
"CONNECTION_STRING",
64+
re.compile(r"[a-zA-Z][a-zA-Z0-9+.-]{0,20}://[^:]+:[^@]+@[^\s\"']+"),
65+
),
66+
]
67+
68+
69+
# ---------------------------------------------------------------------------
70+
# Public API
71+
# ---------------------------------------------------------------------------
72+
73+
# Scan only the first 5 MB of tool output to bound regex execution time.
74+
_MAX_SCAN_LENGTH = 5_000_000
75+
76+
77+
def scan_tool_output(content: str | None) -> ScanResult:
78+
"""Scan *content* for secrets/PII and return a ``ScanResult``.
79+
80+
Non-string values should be converted to ``str`` before calling.
81+
``None`` and empty strings short-circuit to a clean result.
82+
Content exceeding ``_MAX_SCAN_LENGTH`` is truncated before scanning.
83+
"""
84+
if not content:
85+
return ScanResult(has_sensitive_content=False, redacted_content=content or "")
86+
87+
if len(content) > _MAX_SCAN_LENGTH:
88+
content = content[:_MAX_SCAN_LENGTH]
89+
90+
start = time.monotonic()
91+
findings: list[str] = []
92+
redacted = content
93+
94+
for label, pattern in _PATTERNS:
95+
if pattern.search(redacted):
96+
findings.append(f"{label} detected")
97+
redacted = pattern.sub(f"[REDACTED-{label}]", redacted)
98+
99+
elapsed_ms = (time.monotonic() - start) * 1000
100+
return ScanResult(
101+
has_sensitive_content=len(findings) > 0,
102+
redacted_content=redacted,
103+
findings=findings,
104+
duration_ms=elapsed_ms,
105+
)

agent/src/telemetry.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,21 @@ def write_policy_decision(
271271
}
272272
)
273273

274+
def write_output_screening_decision(
275+
self, tool_name: str, findings: list[str], redacted: bool, duration_ms: float
276+
) -> None:
277+
"""Write an OUTPUT_SCREENING event for a post-tool-use output scan."""
278+
self._put_event(
279+
{
280+
"event": "OUTPUT_SCREENING",
281+
"task_id": self._task_id,
282+
"tool_name": tool_name,
283+
"findings": findings,
284+
"redacted": redacted,
285+
"duration_ms": duration_ms,
286+
}
287+
)
288+
274289

275290
# Values under these keys may contain tool stderr, paths, or incidental secrets.
276291
_METRICS_REDACT_KEYS = frozenset({"error"})

0 commit comments

Comments
 (0)