Skip to content

Commit c13bc1a

Browse files
fix(v1.3.1): 5 security fixes for capabilities, AEP, and safety verifier
Critical: - enforcer.py: case-insensitive tool name mapping — Claude Code PascalCase tools (Bash, Read, Write etc) now resolve correctly instead of falling through to generic tool:{Name} High: - enforcer.py: mcp:tool_call added to CONTROL_FLOW_RESOURCES, blocking MCP tools when data provenance is UNTRUSTED - vaporizer.py: symlinks removed without following, preventing overwrite of files outside sandbox work directory - sandbox.py: process group kill on timeout via start_new_session (Unix) / CREATE_NEW_PROCESS_GROUP (Windows) prevents orphaned child processes Medium: - verifier.py: path traversal normalization (subdir/../.env -> .env) before scope matching in verify() All 532 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 81c4616 commit c13bc1a

7 files changed

Lines changed: 133 additions & 27 deletions

File tree

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.3.1] - 2026-04-10
9+
10+
### Fixed
11+
- **[Critical] Tool name case-insensitive mapping**`enforcer.py` now resolves
12+
Claude Code PascalCase tool names (`Bash`, `Read`, `Write`, `Edit`, `Agent`,
13+
`Glob`, `Grep`, `WebFetch`, `NotebookEdit`, `Skill`) correctly. Previously all
14+
PascalCase names fell through to `tool:{Name}`, bypassing capability enforcement.
15+
- **[High] MCP tools added to control-flow-sensitive set**`mcp:tool_call` is
16+
now in `_CONTROL_FLOW_RESOURCES`, blocking MCP tool execution when data
17+
provenance is UNTRUSTED. Also handles `mcp__*` prefixed tool names.
18+
- **[High] Symlink traversal in Vaporizer**`vaporizer.py` now detects symlinks
19+
and removes them without following, preventing overwrite of files outside the
20+
sandbox work directory.
21+
- **[High] Orphaned child process prevention**`ProcessSandbox` now uses
22+
`start_new_session` (Unix) / `CREATE_NEW_PROCESS_GROUP` (Windows) and kills the
23+
entire process group on timeout, preventing background processes from outliving
24+
the sandbox.
25+
- **[Medium] Path traversal normalization in SafetyVerifier**`verify()` now
26+
normalizes `..` segments in target paths before scope matching, so
27+
`subdir/../.env` correctly matches the `.env*` forbidden scope.
28+
829
## [1.3.0] - 2026-04-10
930

1031
### Added — Three new architectural layers for provable security guarantees

ai_guardian/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,4 @@
5656
# Similarity / semantic layer
5757
"check_similarity",
5858
]
59-
__version__ = "1.3.0"
59+
__version__ = "1.3.1"

ai_guardian/aep/sandbox.py

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -109,24 +109,36 @@ def execute(
109109
safe_env = self._build_env(env)
110110
shell_cmd = self._shell_command(code)
111111

112+
# Use start_new_session (Unix) / CREATE_NEW_PROCESS_GROUP (Windows)
113+
# so we can kill the entire process group on timeout, preventing
114+
# child processes from outliving the sandbox.
115+
is_win = platform.system() == "Windows"
116+
popen_kwargs: dict = {
117+
"cwd": str(work_dir),
118+
"env": safe_env,
119+
"stdout": subprocess.PIPE,
120+
"stderr": subprocess.PIPE,
121+
}
122+
if not is_win:
123+
popen_kwargs["start_new_session"] = True
124+
else:
125+
popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
126+
112127
t0 = time.perf_counter()
128+
proc_obj = subprocess.Popen(shell_cmd, **popen_kwargs)
113129
try:
114-
proc = subprocess.run(
115-
shell_cmd,
116-
capture_output=True,
117-
text=True,
118-
timeout=timeout,
119-
cwd=str(work_dir),
120-
env=safe_env,
121-
)
130+
stdout_b, stderr_b = proc_obj.communicate(timeout=timeout)
122131
elapsed_ms = (time.perf_counter() - t0) * 1000.0
123-
stdout = proc.stdout
124-
stderr = proc.stderr
125-
exit_code = proc.returncode
126-
except subprocess.TimeoutExpired as exc:
132+
stdout = stdout_b.decode(errors="replace")
133+
stderr = stderr_b.decode(errors="replace")
134+
exit_code = proc_obj.returncode
135+
except subprocess.TimeoutExpired:
136+
# Kill the entire process group to prevent orphaned children
137+
self._kill_process_tree(proc_obj, is_win)
138+
stdout_b, stderr_b = proc_obj.communicate(timeout=5)
127139
elapsed_ms = (time.perf_counter() - t0) * 1000.0
128-
stdout = (exc.stdout or b"").decode(errors="replace") if isinstance(exc.stdout, bytes) else (exc.stdout or "")
129-
stderr = (exc.stderr or b"").decode(errors="replace") if isinstance(exc.stderr, bytes) else (exc.stderr or "")
140+
stdout = stdout_b.decode(errors="replace") if stdout_b else ""
141+
stderr = stderr_b.decode(errors="replace") if stderr_b else ""
130142
exit_code = -1
131143

132144
# Detect files created during execution.
@@ -145,6 +157,26 @@ def execute(
145157
# Internals
146158
# ------------------------------------------------------------------
147159

160+
@staticmethod
161+
def _kill_process_tree(proc: subprocess.Popen, is_win: bool) -> None:
162+
"""Kill the process and all its children."""
163+
import signal
164+
165+
try:
166+
if is_win:
167+
# On Windows, kill the process tree via taskkill
168+
subprocess.run(
169+
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
170+
capture_output=True,
171+
timeout=5,
172+
)
173+
else:
174+
# On Unix, kill the entire process group
175+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
176+
except (ProcessLookupError, OSError, subprocess.TimeoutExpired):
177+
# Process already exited or group doesn't exist
178+
proc.kill()
179+
148180
def _build_env(self, extra: dict[str, str] | None) -> dict[str, str]:
149181
"""Build a stripped environment from the current process env."""
150182
base = {k: v for k, v in os.environ.items() if k in self._safe_keys}

ai_guardian/aep/vaporizer.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,9 +132,17 @@ def verify_destruction(self, work_dir: Path, keep: list[str] | None = None) -> b
132132
def _secure_delete(self, path: Path) -> bool:
133133
"""Overwrite *path* with random data, then unlink.
134134
135+
Symlinks are unlinked without following (we do NOT overwrite the
136+
symlink target, which may reside outside the work directory).
137+
135138
Returns ``True`` on success, ``False`` if the file could not be
136139
removed (e.g. locked on Windows).
137140
"""
141+
# Safety: never follow symlinks — just remove the link itself.
142+
if path.is_symlink():
143+
logger.warning("Removing symlink without following: %s -> %s", path, os.readlink(path))
144+
return self._unlink_with_retry(path)
145+
138146
try:
139147
size = path.stat().st_size
140148
# Overwrite with cryptographically random bytes.
@@ -203,12 +211,16 @@ def _normalise_keep(keep: list[str] | None) -> set[str]:
203211

204212
@staticmethod
205213
def _list_files(directory: Path) -> set[str]:
206-
"""Return relative file paths (forward slashes) under *directory*."""
214+
"""Return relative file paths (forward slashes) under *directory*.
215+
216+
Includes symlinks as entries but does NOT follow them into
217+
directories outside *directory* (prevents symlink traversal attacks).
218+
"""
207219
result: set[str] = set()
208220
if not directory.exists():
209221
return result
210222
for p in directory.rglob("*"):
211-
if p.is_file():
223+
if p.is_symlink() or p.is_file():
212224
result.add(str(p.relative_to(directory)).replace("\\", "/"))
213225
return result
214226

ai_guardian/capabilities/enforcer.py

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,20 @@
1818
from ai_guardian.guard import Guard
1919

2020

21-
# Tools that affect control flow must never be driven by untrusted data
22-
_CONTROL_FLOW_RESOURCES = frozenset({"shell:exec", "agent:spawn", "code:eval"})
23-
24-
# Mapping from tool names to (resource_type, target_key) pairs
21+
# Tools that affect control flow must never be driven by untrusted data.
22+
# MCP tool calls are included because MCP tools can execute arbitrary
23+
# actions on remote servers (file I/O, network, code execution).
24+
_CONTROL_FLOW_RESOURCES = frozenset({
25+
"shell:exec",
26+
"agent:spawn",
27+
"code:eval",
28+
"mcp:tool_call",
29+
})
30+
31+
# Mapping from tool names (lowercase) to (resource_type, target_key) pairs.
32+
# Lookup is case-insensitive; see _map_tool().
2533
_TOOL_RESOURCE_MAP: dict[str, tuple[str, str]] = {
34+
# Generic / SDK names
2635
"read_file": ("file:read", "path"),
2736
"write_file": ("file:write", "path"),
2837
"edit_file": ("file:write", "file_path"),
@@ -33,6 +42,17 @@
3342
"http_request": ("network:fetch", "url"),
3443
"fetch": ("network:fetch", "url"),
3544
"mcp_call": ("mcp:tool_call", "tool_name"),
45+
# Claude Code tool names (PascalCase -> lowercase keys)
46+
"read": ("file:read", "file_path"),
47+
"write": ("file:write", "file_path"),
48+
"edit": ("file:write", "file_path"),
49+
"glob": ("file:search", "pattern"),
50+
"grep": ("file:search", "pattern"),
51+
"webfetch": ("network:fetch", "url"),
52+
"websearch": ("network:search", "query"),
53+
"agent": ("agent:spawn", "prompt"),
54+
"notebookedit": ("file:write", "file_path"),
55+
"skill": ("agent:spawn", "skill"),
3656
}
3757

3858

@@ -76,14 +96,25 @@ def __repr__(self) -> str:
7696
def _map_tool(tool_name: str) -> tuple[str, str]:
7797
"""Map a tool name to its (resource_type, target_key) pair.
7898
79-
Falls back to a generic "tool:{name}" resource with "input" as the
80-
target key for unknown tools.
99+
Lookup is **case-insensitive** so both ``"Bash"`` (Claude Code) and
100+
``"bash"`` (generic) resolve to ``("shell:exec", "command")``.
101+
102+
MCP tools (``mcp__*``) are mapped to ``mcp:tool_call``.
103+
104+
Falls back to a generic ``"tool:{name}"`` resource with ``"input"``
105+
as the target key for unknown tools.
81106
"""
82-
if tool_name in _TOOL_RESOURCE_MAP:
83-
return _TOOL_RESOURCE_MAP[tool_name]
107+
lower = tool_name.lower()
108+
109+
if lower in _TOOL_RESOURCE_MAP:
110+
return _TOOL_RESOURCE_MAP[lower]
111+
112+
# MCP tools: mcp__server__tool_name -> mcp:tool_call
113+
if lower.startswith("mcp__") or lower.startswith("mcp_"):
114+
return "mcp:tool_call", "input"
84115

85116
for prefix, (resource, _) in _TOOL_RESOURCE_MAP.items():
86-
if tool_name.startswith(prefix):
117+
if lower.startswith(prefix):
87118
return resource, "input"
88119

89120
return f"tool:{tool_name}", "input"

ai_guardian/safety/verifier.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,16 @@ def verify(
212212
"""
213213
ctx = context or {}
214214

215+
# Pre-check: normalize target path to defeat traversal attacks
216+
# (e.g. "subdir/../.env" -> ".env") before scope matching.
217+
normalized_target = target.replace("\\", "/")
218+
if ".." in normalized_target:
219+
from pathlib import PurePosixPath
220+
221+
normalized_target = str(PurePosixPath(normalized_target))
222+
# Also update target for downstream matching
223+
target = normalized_target
224+
215225
for spec in self._specs:
216226
violations: list[str] = []
217227
checked_invariants: list[dict] = []

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "aig-guardian"
7-
version = "1.3.0"
7+
version = "1.3.1"
88
description = "AI agent security with provable guarantees: capability-based access control (CaMeL-inspired), atomic execution pipelines, and safety specification verification. 165+ patterns, 25 threat categories, OWASP LLM Top 10 + MITRE ATLAS. Zero-dependency core."
99
readme = "README.md"
1010
license = { file = "LICENSE" }

0 commit comments

Comments
 (0)