toolgovern-cli 0.1.4__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/PKG-INFO +5 -4
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/README.md +2 -1
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/pyproject.toml +2 -2
- toolgovern_cli-0.2.0/src/toolgovern/mcp_server.py +127 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/src/toolgovern/shared/paths.py +18 -3
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/tests/test_classifier_filesystem_scope.py +31 -0
- toolgovern_cli-0.2.0/tests/test_shared_paths.py +43 -0
- toolgovern_cli-0.1.4/src/toolgovern/mcp_server.py +0 -129
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/.gitignore +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/LICENSE +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/src/toolgovern/__init__.py +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/src/toolgovern/approval/__init__.py +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/src/toolgovern/approval/pending_registry.py +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/src/toolgovern/classifier/__init__.py +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/src/toolgovern/classifier/credential_access.py +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/src/toolgovern/classifier/cross_agent_inheritance.py +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/src/toolgovern/classifier/filesystem_scope.py +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/src/toolgovern/classifier/index.py +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/src/toolgovern/classifier/information_flow.py +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/src/toolgovern/classifier/network_egress.py +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/src/toolgovern/classifier/shell_risk.py +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/src/toolgovern/classifier/util.py +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/src/toolgovern/cli.py +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/src/toolgovern/mcp_trust/__init__.py +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/src/toolgovern/middleware/__init__.py +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/src/toolgovern/middleware/idempotency_cache.py +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/src/toolgovern/middleware/on_tool_call.py +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/src/toolgovern/policy/__init__.py +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/src/toolgovern/policy/load_policy.py +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/src/toolgovern/policy/validate_policy.py +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/src/toolgovern/py.typed +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/src/toolgovern/scoping/__init__.py +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/src/toolgovern/scoping/inheritance_enforcer.py +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/src/toolgovern/scoping/scope_declaration.py +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/src/toolgovern/shared/__init__.py +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/src/toolgovern/trace/__init__.py +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/src/toolgovern/trace/canonical_json.py +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/src/toolgovern/trace/trace_reader.py +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/src/toolgovern/trace/trace_writer.py +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/src/toolgovern/types.py +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/tests/__init__.py +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/tests/conftest.py +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/tests/test_classifier_credential_access.py +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/tests/test_classifier_cross_agent_inheritance.py +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/tests/test_classifier_index.py +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/tests/test_classifier_information_flow.py +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/tests/test_classifier_network_egress.py +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/tests/test_classifier_shell_risk.py +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/tests/test_cli.py +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/tests/test_mcp_trust.py +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/tests/test_middleware_on_tool_call.py +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/tests/test_pending_registry.py +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/tests/test_policy.py +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/tests/test_scoping_inheritance_enforcer.py +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/tests/test_scoping_scope_declaration.py +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/tests/test_trace_canonical_json.py +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/tests/test_trace_reader.py +0 -0
- {toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/tests/test_trace_writer.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: toolgovern-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Runtime governance middleware for AI agent tool calls -- gate shell, filesystem, network, and credential access before a tool executes.
|
|
5
5
|
Project-URL: Homepage, https://github.com/RudrenduPaul/toolgovern
|
|
6
6
|
Project-URL: Repository, https://github.com/RudrenduPaul/toolgovern
|
|
@@ -27,7 +27,7 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
27
27
|
Classifier: Topic :: Security
|
|
28
28
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
29
29
|
Requires-Python: >=3.9
|
|
30
|
-
Requires-Dist: cryptography<
|
|
30
|
+
Requires-Dist: cryptography<51,>=48.0.1
|
|
31
31
|
Requires-Dist: pyyaml<7,>=6.0
|
|
32
32
|
Provides-Extra: dev
|
|
33
33
|
Requires-Dist: build<2,>=1.0; extra == 'dev'
|
|
@@ -38,6 +38,7 @@ Requires-Dist: mcp[cli]>=2.0.0; extra == 'mcp'
|
|
|
38
38
|
Description-Content-Type: text/markdown
|
|
39
39
|
|
|
40
40
|
<!-- mcp-name: io.github.RudrenduPaul/toolgovern -->
|
|
41
|
+
|
|
41
42
|
# toolgovern (Python)
|
|
42
43
|
|
|
43
44
|
Gate every tool call an AI agent makes -- shell, filesystem, network, credential access -- before
|
|
@@ -339,7 +340,7 @@ run(args=["validate", "./toolgovern.policy.yml", "--json"])
|
|
|
339
340
|
This is a generic subprocess wrapper around the real CLI (not a second implementation of each
|
|
340
341
|
subcommand), so it stays in sync with `validate`, `audit`, and any future subcommand
|
|
341
342
|
automatically. This is distinct from toolgovern's `mcp_trust` module, which is a client-side
|
|
342
|
-
tool for verifying the trustworthiness of
|
|
343
|
+
tool for verifying the trustworthiness of _other_ MCP servers an agent connects to -- this
|
|
343
344
|
section is about toolgovern-cli exposing its own MCP server for agents to call.
|
|
344
345
|
|
|
345
346
|
## The signed audit trail
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<!-- mcp-name: io.github.RudrenduPaul/toolgovern -->
|
|
2
|
+
|
|
2
3
|
# toolgovern (Python)
|
|
3
4
|
|
|
4
5
|
Gate every tool call an AI agent makes -- shell, filesystem, network, credential access -- before
|
|
@@ -300,7 +301,7 @@ run(args=["validate", "./toolgovern.policy.yml", "--json"])
|
|
|
300
301
|
This is a generic subprocess wrapper around the real CLI (not a second implementation of each
|
|
301
302
|
subcommand), so it stays in sync with `validate`, `audit`, and any future subcommand
|
|
302
303
|
automatically. This is distinct from toolgovern's `mcp_trust` module, which is a client-side
|
|
303
|
-
tool for verifying the trustworthiness of
|
|
304
|
+
tool for verifying the trustworthiness of _other_ MCP servers an agent connects to -- this
|
|
304
305
|
section is about toolgovern-cli exposing its own MCP server for agents to call.
|
|
305
306
|
|
|
306
307
|
## The signed audit trail
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "toolgovern-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Runtime governance middleware for AI agent tool calls -- gate shell, filesystem, network, and credential access before a tool executes."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -31,7 +31,7 @@ classifiers = [
|
|
|
31
31
|
]
|
|
32
32
|
dependencies = [
|
|
33
33
|
"PyYAML>=6.0,<7",
|
|
34
|
-
"cryptography>=48.0.1,<
|
|
34
|
+
"cryptography>=48.0.1,<51",
|
|
35
35
|
]
|
|
36
36
|
|
|
37
37
|
[project.optional-dependencies]
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""MCP server (Python): exposes the toolgovern-cli command-line tool to agent
|
|
2
|
+
runtimes over stdio.
|
|
3
|
+
|
|
4
|
+
Requires the `mcp` extra (`pip install "toolgovern-cli[mcp]"`). Started via
|
|
5
|
+
the `toolgovern-mcp` console script (installed by `python/pyproject.toml`'s
|
|
6
|
+
`[project.scripts]`).
|
|
7
|
+
|
|
8
|
+
This is a generic subprocess wrapper, not a per-subcommand tool set: a
|
|
9
|
+
single `run` tool shells out to `python -m toolgovern.cli <args>` (invoked by
|
|
10
|
+
module rather than by looking up the `toolgovern-cli` binary on PATH, so it
|
|
11
|
+
works the same whether or not the console script entry point is installed)
|
|
12
|
+
and returns the result. Wrapping the CLI this way means the tool stays in
|
|
13
|
+
sync with `validate`, `audit`, and any future subcommand without a matching
|
|
14
|
+
MCP tool hand-written for each one.
|
|
15
|
+
|
|
16
|
+
Every failure path (the subprocess never starting, timing out, exiting
|
|
17
|
+
non-zero, or printing non-JSON stdout) is caught and returned as a
|
|
18
|
+
`{"error": ...}` dict. This tool handler must never raise -- an uncaught
|
|
19
|
+
exception here would surface as a raw MCP protocol error instead of a
|
|
20
|
+
readable result.
|
|
21
|
+
|
|
22
|
+
Uses `mcp.server.MCPServer`, the official SDK's current high-level server
|
|
23
|
+
class (`mcp` 2.0.0+) -- earlier `mcp` 1.x releases exposed the same
|
|
24
|
+
`.tool()`/`.run()` pattern under `mcp.server.fastmcp.FastMCP`, which was
|
|
25
|
+
removed in the 2.0.0 release.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import subprocess
|
|
32
|
+
import sys
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from mcp.server import MCPServer
|
|
36
|
+
|
|
37
|
+
_TIMEOUT_SECONDS = 60
|
|
38
|
+
|
|
39
|
+
_RUN_TOOL_DESCRIPTION = (
|
|
40
|
+
"Executes one toolgovern-cli subcommand (validate, audit, or init) and returns its "
|
|
41
|
+
"structured result. Call this to check a governance policy file for errors before "
|
|
42
|
+
"deploying it, to forensically inspect a signed trace log of the allow/deny/"
|
|
43
|
+
"require-approval decisions toolgovern's runtime tool-call gate already made for an "
|
|
44
|
+
"agent run, or to scaffold a starter policy/integration file for a detected framework "
|
|
45
|
+
"(open-multi-agent or LangGraph). This tool does not itself gate live tool calls -- that "
|
|
46
|
+
"happens inside the governed agent process via the toolgovern library -- it only "
|
|
47
|
+
"validates, audits, and scaffolds around that gate.\n\n"
|
|
48
|
+
"No API key or network access is required; everything runs locally against files you "
|
|
49
|
+
"supply. 'validate' and 'audit' are strictly read-only (they only read the policy or "
|
|
50
|
+
"trace file given). 'init' writes a new integration file to disk and will refuse to "
|
|
51
|
+
"overwrite an existing one unless '--force' is passed. 'audit --verify-chain' "
|
|
52
|
+
"cryptographically checks the trace file's hash chain for tampering; if the trace was "
|
|
53
|
+
"written with a TraceWriter secretKey, pass '--key-file <path>' to verify the "
|
|
54
|
+
"hmac-sha256 signatures, otherwise unkeyed sha256 entries verify without it. This "
|
|
55
|
+
"handler never raises -- a launch failure, timeout, non-zero exit, or non-JSON stdout is "
|
|
56
|
+
"always returned as {\"error\": ...} instead of an exception.\n\n"
|
|
57
|
+
"Parameter `args` is the literal argv you would type after `toolgovern-cli` on the "
|
|
58
|
+
"command line, as a list of strings. Real examples: run(args=[\"validate\", "
|
|
59
|
+
"\"./toolgovern.policy.yml\", \"--json\"]) to check a policy file is well-formed; "
|
|
60
|
+
"run(args=[\"audit\", \"./toolgovern-trace.jsonl\", \"--decision\", \"deny\", "
|
|
61
|
+
"\"--json\"]) to list every denied tool call in a trace log; run(args=[\"audit\", "
|
|
62
|
+
"\"./toolgovern-trace.jsonl\", \"--agent\", \"research-sub\", \"--since\", \"24h\", "
|
|
63
|
+
"\"--verify-chain\", \"--json\"]) to audit one agent's recent decisions with tamper "
|
|
64
|
+
"verification; run(args=[\"init\", \"langgraph\", \"--policy\", "
|
|
65
|
+
"\"./toolgovern.policy.yml\", \"--json\"]) to scaffold a LangGraph integration. Always "
|
|
66
|
+
"include '--json' -- without it the CLI prints human-formatted text instead of a "
|
|
67
|
+
"parseable object.\n\n"
|
|
68
|
+
"With '--json', the CLI emits exactly one JSON object to stdout (never split across "
|
|
69
|
+
"stdout/stderr): {\"ok\": true, \"command\": ..., \"data\": {...}} on success, where "
|
|
70
|
+
"'data' holds the full result (e.g. matched/total counts and TraceEntry rows for "
|
|
71
|
+
"'audit'), or {\"ok\": false, \"command\": ..., \"error\": ...} on failure. This Python "
|
|
72
|
+
"wrapper further normalizes that into {\"result\": <parsed JSON>} on a zero exit, or "
|
|
73
|
+
"{\"returncode\", \"stdout\", \"stderr\"} if stdout wasn't valid JSON (e.g. '--json' was "
|
|
74
|
+
"omitted), or {\"error\": ..., \"returncode\": ...} on a non-zero exit. Pass "
|
|
75
|
+
"run(args=[\"--help\"]) or run(args=[\"<subcommand>\", \"--help\"]) for the CLI's own "
|
|
76
|
+
"current usage text."
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def build_app() -> MCPServer:
|
|
81
|
+
app = MCPServer("toolgovern")
|
|
82
|
+
|
|
83
|
+
@app.tool(description=_RUN_TOOL_DESCRIPTION)
|
|
84
|
+
def run(args: list[str]) -> dict[str, Any]:
|
|
85
|
+
try:
|
|
86
|
+
result = subprocess.run(
|
|
87
|
+
[sys.executable, "-m", "toolgovern.cli", *args],
|
|
88
|
+
capture_output=True,
|
|
89
|
+
text=True,
|
|
90
|
+
timeout=_TIMEOUT_SECONDS,
|
|
91
|
+
)
|
|
92
|
+
except OSError as error:
|
|
93
|
+
return {"error": f"failed to launch the toolgovern-cli CLI: {error}"}
|
|
94
|
+
except subprocess.TimeoutExpired:
|
|
95
|
+
return {"error": f"toolgovern-cli timed out after {_TIMEOUT_SECONDS}s"}
|
|
96
|
+
|
|
97
|
+
stdout = result.stdout.strip()
|
|
98
|
+
stderr = result.stderr.strip()
|
|
99
|
+
|
|
100
|
+
if result.returncode != 0:
|
|
101
|
+
return {
|
|
102
|
+
"error": stderr or stdout or f"toolgovern-cli exited with code {result.returncode}",
|
|
103
|
+
"returncode": result.returncode,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if not stdout:
|
|
107
|
+
return {"returncode": result.returncode, "stdout": "", "stderr": stderr}
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
return {"result": json.loads(stdout)}
|
|
111
|
+
except json.JSONDecodeError:
|
|
112
|
+
# Not every subcommand supports --json (or the caller didn't
|
|
113
|
+
# pass it) -- return the raw text rather than treating this as
|
|
114
|
+
# an error.
|
|
115
|
+
return {"returncode": result.returncode, "stdout": stdout, "stderr": stderr}
|
|
116
|
+
|
|
117
|
+
return app
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def main() -> None:
|
|
121
|
+
"""Entry point for the `toolgovern-mcp` console script."""
|
|
122
|
+
app = build_app()
|
|
123
|
+
app.run(transport="stdio")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
if __name__ == "__main__":
|
|
127
|
+
main()
|
|
@@ -15,8 +15,18 @@ _BRACKETED_HOST_PATTERN = re.compile(r"^\[([^\]]+)\]")
|
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
def normalize_path(raw_path: str) -> str:
|
|
18
|
-
"""Collapses ``./``, trailing slashes, and duplicate slashes for stable prefix comparison.
|
|
18
|
+
"""Collapses ``./``, trailing slashes, and duplicate slashes for stable prefix comparison.
|
|
19
|
+
|
|
20
|
+
Backslashes are treated as path separators too -- the same as forward slashes -- regardless
|
|
21
|
+
of the host OS this code happens to run on. A declared scope must hold whether the target
|
|
22
|
+
path is destined for a Windows filesystem (where ``\\`` is the native separator) or is simply
|
|
23
|
+
an attacker-supplied string mixing separators to dodge a ``/``-only prefix check; without
|
|
24
|
+
this, a path like ``/allowed/sub\\..\\..\\..\\secrets`` compares as a literal child of
|
|
25
|
+
``/allowed`` even though it resolves outside it once backslashes are treated as separators
|
|
26
|
+
downstream.
|
|
27
|
+
"""
|
|
19
28
|
path = raw_path.strip()
|
|
29
|
+
path = path.replace("\\", "/")
|
|
20
30
|
if path.startswith("./"):
|
|
21
31
|
path = path[2:]
|
|
22
32
|
path = re.sub(r"/+", "/", path)
|
|
@@ -37,8 +47,13 @@ def is_path_within(candidate: str, prefix: str) -> bool:
|
|
|
37
47
|
|
|
38
48
|
|
|
39
49
|
def contains_path_traversal(raw_path: str) -> bool:
|
|
40
|
-
"""True if the path contains a ``..`` segment that could escape a scoped prefix via traversal.
|
|
41
|
-
|
|
50
|
+
"""True if the path contains a ``..`` segment that could escape a scoped prefix via traversal.
|
|
51
|
+
|
|
52
|
+
Splits on both ``/`` and ``\\`` (see ``normalize_path`` above for why backslash must count as
|
|
53
|
+
a separator here too) so ``sub\\..\\..\\..\\secrets`` is caught exactly like
|
|
54
|
+
``sub/../../../secrets``.
|
|
55
|
+
"""
|
|
56
|
+
return ".." in re.split(r"[/\\]", raw_path)
|
|
42
57
|
|
|
43
58
|
|
|
44
59
|
def normalize_host(host_like: str) -> str:
|
|
@@ -121,6 +121,37 @@ class TestPathTraversal:
|
|
|
121
121
|
decision, ids = _fired(ctx)
|
|
122
122
|
assert "TG02-path-traversal" in ids
|
|
123
123
|
|
|
124
|
+
# 2026-08-24 security fix: `contains_path_traversal`/`is_path_within`
|
|
125
|
+
# (python/src/toolgovern/shared/paths.py) used to split only on "/", so a backslash-delimited
|
|
126
|
+
# ".." segment (e.g. "sub\..\..\..\secrets") was never recognized as traversal -- the same
|
|
127
|
+
# string compared as a literal in-scope child of the declared prefix because it happened to
|
|
128
|
+
# start with the right characters, letting a sub-agent escape its declared filesystem scope
|
|
129
|
+
# undetected. Identical bug in the TypeScript and .NET ports; all three share this fix.
|
|
130
|
+
def test_fires_on_backslash_delimited_dotdot(self, ctx_factory):
|
|
131
|
+
ctx = ctx_factory(
|
|
132
|
+
{"path": "/workspace/sub\\..\\..\\..\\secrets", "operation": "write"},
|
|
133
|
+
scope=ScopeDeclaration(filesystem=["/workspace"]),
|
|
134
|
+
)
|
|
135
|
+
decision, ids = _fired(ctx)
|
|
136
|
+
assert decision == "deny"
|
|
137
|
+
assert "TG02-path-traversal" in ids
|
|
138
|
+
|
|
139
|
+
def test_fires_on_bare_backslash_traversal(self, ctx_factory):
|
|
140
|
+
ctx = ctx_factory(
|
|
141
|
+
{"path": "..\\..\\secrets", "operation": "read"},
|
|
142
|
+
scope=ScopeDeclaration(filesystem=["/workspace"]),
|
|
143
|
+
)
|
|
144
|
+
decision, ids = _fired(ctx)
|
|
145
|
+
assert "TG02-path-traversal" in ids
|
|
146
|
+
|
|
147
|
+
def test_fires_on_mixed_separator_traversal(self, ctx_factory):
|
|
148
|
+
ctx = ctx_factory(
|
|
149
|
+
{"path": "/workspace/sub/..\\../etc/passwd", "operation": "write"},
|
|
150
|
+
scope=ScopeDeclaration(filesystem=["/workspace"]),
|
|
151
|
+
)
|
|
152
|
+
decision, ids = _fired(ctx)
|
|
153
|
+
assert "TG02-path-traversal" in ids
|
|
154
|
+
|
|
124
155
|
|
|
125
156
|
class TestSymlinkEscape:
|
|
126
157
|
def test_fires_outside_scope(self, ctx_factory):
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Unit tests for toolgovern.shared.paths -- 2026-08-24 security fix.
|
|
2
|
+
|
|
3
|
+
Previously ``normalize_path``/``contains_path_traversal`` only split on "/", so a backslash-
|
|
4
|
+
delimited ".." segment was invisible to traversal detection, and a scope prefix like "/allowed"
|
|
5
|
+
would treat any candidate that merely started with the literal string "/allowed/" as in-scope even
|
|
6
|
+
when the remainder was pure backslash-delimited traversal
|
|
7
|
+
(``/allowed/sub\\..\\..\\..\\secrets``). This undercut the "sub-agent can't reach outside its
|
|
8
|
+
declared scope" guarantee identically across the TypeScript, Python, and .NET ports.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from toolgovern.shared.paths import contains_path_traversal, is_path_within, normalize_path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_normalize_path_converts_backslashes_to_forward_slashes():
|
|
15
|
+
assert normalize_path("workspace\\sub\\file.txt") == "workspace/sub/file.txt"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_normalize_path_collapses_mixed_separators_consistently():
|
|
19
|
+
assert normalize_path("./workspace\\sub/../file.txt") == "workspace/sub/../file.txt"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_contains_path_traversal_detects_backslash_delimited_dotdot():
|
|
23
|
+
assert contains_path_traversal("sub\\..\\..\\..\\secrets") is True
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_contains_path_traversal_detects_mixed_separator_dotdot():
|
|
27
|
+
assert contains_path_traversal("sub/..\\../secrets") is True
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_contains_path_traversal_false_for_clean_backslash_path():
|
|
31
|
+
assert contains_path_traversal("workspace\\sub\\file.txt") is False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_contains_path_traversal_still_detects_forward_slash_case():
|
|
35
|
+
assert contains_path_traversal("../../etc/passwd") is True
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_is_path_within_still_recognizes_nested_path_as_in_scope():
|
|
39
|
+
assert is_path_within("/allowed/sub/dir/file.txt", "/allowed") is True
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_is_path_within_still_rejects_unrelated_path():
|
|
43
|
+
assert is_path_within("/other/dir/file.txt", "/allowed") is False
|
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
"""MCP server (Python): exposes the toolgovern-cli command-line tool to agent
|
|
2
|
-
runtimes over stdio.
|
|
3
|
-
|
|
4
|
-
Requires the `mcp` extra (`pip install "toolgovern-cli[mcp]"`). Started via
|
|
5
|
-
the `toolgovern-mcp` console script (installed by `python/pyproject.toml`'s
|
|
6
|
-
`[project.scripts]`).
|
|
7
|
-
|
|
8
|
-
This is a generic subprocess wrapper, not a per-subcommand tool set: a
|
|
9
|
-
single `run` tool shells out to `python -m toolgovern.cli <args>` (invoked by
|
|
10
|
-
module rather than by looking up the `toolgovern-cli` binary on PATH, so it
|
|
11
|
-
works the same whether or not the console script entry point is installed)
|
|
12
|
-
and returns the result. Wrapping the CLI this way means the tool stays in
|
|
13
|
-
sync with `validate`, `audit`, and any future subcommand without a matching
|
|
14
|
-
MCP tool hand-written for each one.
|
|
15
|
-
|
|
16
|
-
Every failure path (the subprocess never starting, timing out, exiting
|
|
17
|
-
non-zero, or printing non-JSON stdout) is caught and returned as a
|
|
18
|
-
`{"error": ...}` dict. This tool handler must never raise -- an uncaught
|
|
19
|
-
exception here would surface as a raw MCP protocol error instead of a
|
|
20
|
-
readable result.
|
|
21
|
-
|
|
22
|
-
Uses `mcp.server.MCPServer`, the official SDK's current high-level server
|
|
23
|
-
class (`mcp` 2.0.0+) -- earlier `mcp` 1.x releases exposed the same
|
|
24
|
-
`.tool()`/`.run()` pattern under `mcp.server.fastmcp.FastMCP`, which was
|
|
25
|
-
removed in the 2.0.0 release.
|
|
26
|
-
"""
|
|
27
|
-
|
|
28
|
-
from __future__ import annotations
|
|
29
|
-
|
|
30
|
-
import json
|
|
31
|
-
import subprocess
|
|
32
|
-
import sys
|
|
33
|
-
from typing import Any
|
|
34
|
-
|
|
35
|
-
from mcp.server import MCPServer
|
|
36
|
-
|
|
37
|
-
_TIMEOUT_SECONDS = 60
|
|
38
|
-
|
|
39
|
-
_STATIC_FALLBACK_DESCRIPTION = (
|
|
40
|
-
"Run the toolgovern-cli command-line tool with the given argument list "
|
|
41
|
-
"and return its output. toolgovern-cli validates governance policy "
|
|
42
|
-
"files and audits signed local trace logs of allow/deny/require-"
|
|
43
|
-
"approval decisions made by toolgovern's runtime tool-call gate. Pass "
|
|
44
|
-
"the same arguments you would give the `toolgovern-cli` command on the "
|
|
45
|
-
'command line, e.g. run(args=["validate", "./toolgovern.policy.yml", '
|
|
46
|
-
'"--json"]).'
|
|
47
|
-
)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def _get_cli_help() -> str:
|
|
51
|
-
"""Runs `python -m toolgovern.cli --help` to source the tool
|
|
52
|
-
description from the CLI's real, current `--help` text. Returns "" on
|
|
53
|
-
any failure so the caller can fall back to the static description
|
|
54
|
-
instead of crashing at import time."""
|
|
55
|
-
try:
|
|
56
|
-
result = subprocess.run(
|
|
57
|
-
[sys.executable, "-m", "toolgovern.cli", "--help"],
|
|
58
|
-
capture_output=True,
|
|
59
|
-
text=True,
|
|
60
|
-
timeout=_TIMEOUT_SECONDS,
|
|
61
|
-
)
|
|
62
|
-
except (OSError, subprocess.TimeoutExpired):
|
|
63
|
-
return ""
|
|
64
|
-
return (result.stdout or result.stderr).strip()
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def _build_run_description() -> str:
|
|
68
|
-
help_text = _get_cli_help()
|
|
69
|
-
if not help_text:
|
|
70
|
-
return _STATIC_FALLBACK_DESCRIPTION
|
|
71
|
-
return (
|
|
72
|
-
"Run the toolgovern-cli command-line tool with the given argument "
|
|
73
|
-
f"list and return its output.\n\n{help_text}"
|
|
74
|
-
)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
# Populated once at import time from the real, installed CLI -- not
|
|
78
|
-
# hand-maintained, so it can't silently drift from actual `--help` output.
|
|
79
|
-
_RUN_TOOL_DESCRIPTION = _build_run_description()
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def build_app() -> MCPServer:
|
|
83
|
-
app = MCPServer("toolgovern")
|
|
84
|
-
|
|
85
|
-
@app.tool(description=_RUN_TOOL_DESCRIPTION)
|
|
86
|
-
def run(args: list[str]) -> dict[str, Any]:
|
|
87
|
-
try:
|
|
88
|
-
result = subprocess.run(
|
|
89
|
-
[sys.executable, "-m", "toolgovern.cli", *args],
|
|
90
|
-
capture_output=True,
|
|
91
|
-
text=True,
|
|
92
|
-
timeout=_TIMEOUT_SECONDS,
|
|
93
|
-
)
|
|
94
|
-
except OSError as error:
|
|
95
|
-
return {"error": f"failed to launch the toolgovern-cli CLI: {error}"}
|
|
96
|
-
except subprocess.TimeoutExpired:
|
|
97
|
-
return {"error": f"toolgovern-cli timed out after {_TIMEOUT_SECONDS}s"}
|
|
98
|
-
|
|
99
|
-
stdout = result.stdout.strip()
|
|
100
|
-
stderr = result.stderr.strip()
|
|
101
|
-
|
|
102
|
-
if result.returncode != 0:
|
|
103
|
-
return {
|
|
104
|
-
"error": stderr or stdout or f"toolgovern-cli exited with code {result.returncode}",
|
|
105
|
-
"returncode": result.returncode,
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
if not stdout:
|
|
109
|
-
return {"returncode": result.returncode, "stdout": "", "stderr": stderr}
|
|
110
|
-
|
|
111
|
-
try:
|
|
112
|
-
return {"result": json.loads(stdout)}
|
|
113
|
-
except json.JSONDecodeError:
|
|
114
|
-
# Not every subcommand supports --json (or the caller didn't
|
|
115
|
-
# pass it) -- return the raw text rather than treating this as
|
|
116
|
-
# an error.
|
|
117
|
-
return {"returncode": result.returncode, "stdout": stdout, "stderr": stderr}
|
|
118
|
-
|
|
119
|
-
return app
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
def main() -> None:
|
|
123
|
-
"""Entry point for the `toolgovern-mcp` console script."""
|
|
124
|
-
app = build_app()
|
|
125
|
-
app.run(transport="stdio")
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if __name__ == "__main__":
|
|
129
|
-
main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/src/toolgovern/classifier/credential_access.py
RENAMED
|
File without changes
|
{toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/src/toolgovern/classifier/cross_agent_inheritance.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/src/toolgovern/middleware/idempotency_cache.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/src/toolgovern/scoping/inheritance_enforcer.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{toolgovern_cli-0.1.4 → toolgovern_cli-0.2.0}/tests/test_classifier_cross_agent_inheritance.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|