ghostmode 0.1.0__py3-none-any.whl
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.
- ghostmode/__init__.py +6 -0
- ghostmode/cli.py +133 -0
- ghostmode/event_log.py +143 -0
- ghostmode/proxy.py +482 -0
- ghostmode/shadow_vfs.py +75 -0
- ghostmode-0.1.0.dist-info/METADATA +157 -0
- ghostmode-0.1.0.dist-info/RECORD +11 -0
- ghostmode-0.1.0.dist-info/WHEEL +5 -0
- ghostmode-0.1.0.dist-info/entry_points.txt +2 -0
- ghostmode-0.1.0.dist-info/licenses/LICENSE +21 -0
- ghostmode-0.1.0.dist-info/top_level.txt +1 -0
ghostmode/__init__.py
ADDED
ghostmode/cli.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Ghost Mode CLI — one command, zero config.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
ghostmode -- npx @modelcontextprotocol/server-filesystem ~/Documents
|
|
7
|
+
ghostmode -v -- python my_mcp_server.py
|
|
8
|
+
ghostmode --export session.json -- node my_tools.js
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import asyncio
|
|
13
|
+
import logging
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main() -> None:
|
|
18
|
+
parser = argparse.ArgumentParser(
|
|
19
|
+
prog="ghostmode",
|
|
20
|
+
description=(
|
|
21
|
+
"\U0001f47b Ghost Mode — see what your AI agent would do, "
|
|
22
|
+
"without letting it do anything."
|
|
23
|
+
),
|
|
24
|
+
epilog=(
|
|
25
|
+
"Example:\n"
|
|
26
|
+
" ghostmode -- npx @modelcontextprotocol/server-filesystem ~/Documents\n"
|
|
27
|
+
"\n"
|
|
28
|
+
"Point your agent at ghostmode instead of the MCP server.\n"
|
|
29
|
+
"Reads pass through. Writes get intercepted. Nothing changes.\n"
|
|
30
|
+
"\n"
|
|
31
|
+
"For full security (rollback, enforcement, audit chain), upgrade to UNWIND:\n"
|
|
32
|
+
" pip install unwind-mcp\n"
|
|
33
|
+
" https://github.com/unwind-mcp/unwind"
|
|
34
|
+
),
|
|
35
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
36
|
+
)
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"upstream_command",
|
|
39
|
+
nargs=argparse.REMAINDER,
|
|
40
|
+
help="The MCP server command (after --)",
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"-v", "--verbose",
|
|
44
|
+
action="store_true",
|
|
45
|
+
help="Log every intercepted and passed action to stderr",
|
|
46
|
+
)
|
|
47
|
+
parser.add_argument(
|
|
48
|
+
"--export",
|
|
49
|
+
metavar="FILE",
|
|
50
|
+
help="Export the session log to a JSON file on exit",
|
|
51
|
+
)
|
|
52
|
+
parser.add_argument(
|
|
53
|
+
"--also-block",
|
|
54
|
+
metavar="TOOL",
|
|
55
|
+
action="append",
|
|
56
|
+
default=[],
|
|
57
|
+
help="Additional tool names to treat as writes (can be repeated)",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
args = parser.parse_args()
|
|
61
|
+
|
|
62
|
+
# Parse upstream command
|
|
63
|
+
upstream_cmd = args.upstream_command
|
|
64
|
+
if upstream_cmd and upstream_cmd[0] == "--":
|
|
65
|
+
upstream_cmd = upstream_cmd[1:]
|
|
66
|
+
|
|
67
|
+
if not upstream_cmd:
|
|
68
|
+
print(
|
|
69
|
+
"\n \U0001f47b Ghost Mode — dry-run proxy for AI agents\n"
|
|
70
|
+
"\n"
|
|
71
|
+
" Usage:\n"
|
|
72
|
+
" ghostmode -- <upstream MCP server command>\n"
|
|
73
|
+
"\n"
|
|
74
|
+
" Examples:\n"
|
|
75
|
+
" ghostmode -- npx @modelcontextprotocol/server-filesystem ~/Documents\n"
|
|
76
|
+
" ghostmode -- python my_mcp_server.py\n"
|
|
77
|
+
" ghostmode -v -- node tools_server.js\n"
|
|
78
|
+
"\n"
|
|
79
|
+
" The agent talks to ghostmode. Reads pass through to your\n"
|
|
80
|
+
" real MCP server. Writes get intercepted. Nothing changes.\n"
|
|
81
|
+
"\n"
|
|
82
|
+
" When the session ends, you see everything the agent tried to do.\n",
|
|
83
|
+
file=sys.stderr,
|
|
84
|
+
)
|
|
85
|
+
sys.exit(1)
|
|
86
|
+
|
|
87
|
+
# Custom write tools
|
|
88
|
+
custom_writes = frozenset(args.also_block) if args.also_block else None
|
|
89
|
+
|
|
90
|
+
# Logging
|
|
91
|
+
level = logging.DEBUG if args.verbose else logging.WARNING
|
|
92
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
93
|
+
handler.setFormatter(logging.Formatter(
|
|
94
|
+
"%(asctime)s [ghostmode] %(message)s", datefmt="%H:%M:%S",
|
|
95
|
+
))
|
|
96
|
+
logging.root.addHandler(handler)
|
|
97
|
+
logging.root.setLevel(level)
|
|
98
|
+
|
|
99
|
+
# Banner
|
|
100
|
+
print(
|
|
101
|
+
"\n \U0001f47b Ghost Mode active\n"
|
|
102
|
+
f" Upstream: {' '.join(upstream_cmd)}\n"
|
|
103
|
+
f" Writes will be intercepted. Reads pass through.\n",
|
|
104
|
+
file=sys.stderr,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
from .proxy import run_ghost_proxy
|
|
108
|
+
|
|
109
|
+
# Run with export on exit
|
|
110
|
+
async def _run():
|
|
111
|
+
from .proxy import GhostProxy
|
|
112
|
+
proxy = GhostProxy(upstream_cmd, custom_writes, args.verbose)
|
|
113
|
+
try:
|
|
114
|
+
await proxy.run()
|
|
115
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
116
|
+
pass
|
|
117
|
+
finally:
|
|
118
|
+
await proxy.shutdown()
|
|
119
|
+
|
|
120
|
+
# Export if requested
|
|
121
|
+
if args.export:
|
|
122
|
+
from pathlib import Path
|
|
123
|
+
count = proxy.log.export_json(Path(args.export))
|
|
124
|
+
print(f"\n Exported {count} events to {args.export}", file=sys.stderr)
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
asyncio.run(_run())
|
|
128
|
+
except KeyboardInterrupt:
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
if __name__ == "__main__":
|
|
133
|
+
main()
|
ghostmode/event_log.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Ghost Mode event log — lightweight action recorder.
|
|
4
|
+
|
|
5
|
+
A minimal event log that records what the agent tried to do.
|
|
6
|
+
No SQLite, no hash chains — just an in-memory list with optional
|
|
7
|
+
file export. This is the "See Everything" without the security
|
|
8
|
+
infrastructure.
|
|
9
|
+
|
|
10
|
+
For the full flight recorder with CR-AFT hash chains, tamper
|
|
11
|
+
detection, and rollback, upgrade to UNWIND.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass, field, asdict
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class GhostEvent:
|
|
23
|
+
"""A single intercepted action."""
|
|
24
|
+
timestamp: float
|
|
25
|
+
tool: str
|
|
26
|
+
action: str # "intercepted", "passed_through", "shadow_read"
|
|
27
|
+
target: Optional[str] = None
|
|
28
|
+
parameters_summary: Optional[str] = None # Brief summary, not full params
|
|
29
|
+
detail: Optional[str] = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class GhostEventLog:
|
|
33
|
+
"""In-memory event log for Ghost Mode sessions."""
|
|
34
|
+
|
|
35
|
+
def __init__(self):
|
|
36
|
+
self.events: list[GhostEvent] = []
|
|
37
|
+
self._start_time: float = time.time()
|
|
38
|
+
|
|
39
|
+
def log_intercept(self, tool: str, target: Optional[str] = None,
|
|
40
|
+
detail: Optional[str] = None) -> GhostEvent:
|
|
41
|
+
"""Log an intercepted (blocked) write action."""
|
|
42
|
+
event = GhostEvent(
|
|
43
|
+
timestamp=time.time(),
|
|
44
|
+
tool=tool,
|
|
45
|
+
action="intercepted",
|
|
46
|
+
target=target,
|
|
47
|
+
detail=detail,
|
|
48
|
+
)
|
|
49
|
+
self.events.append(event)
|
|
50
|
+
return event
|
|
51
|
+
|
|
52
|
+
def log_passthrough(self, tool: str, target: Optional[str] = None) -> GhostEvent:
|
|
53
|
+
"""Log a read-only action that was passed through."""
|
|
54
|
+
event = GhostEvent(
|
|
55
|
+
timestamp=time.time(),
|
|
56
|
+
tool=tool,
|
|
57
|
+
action="passed_through",
|
|
58
|
+
target=target,
|
|
59
|
+
)
|
|
60
|
+
self.events.append(event)
|
|
61
|
+
return event
|
|
62
|
+
|
|
63
|
+
def log_shadow_read(self, tool: str, target: str) -> GhostEvent:
|
|
64
|
+
"""Log a read served from the shadow VFS."""
|
|
65
|
+
event = GhostEvent(
|
|
66
|
+
timestamp=time.time(),
|
|
67
|
+
tool=tool,
|
|
68
|
+
action="shadow_read",
|
|
69
|
+
target=target,
|
|
70
|
+
detail="Content served from shadow VFS (ghost-written earlier)",
|
|
71
|
+
)
|
|
72
|
+
self.events.append(event)
|
|
73
|
+
return event
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def intercepted_count(self) -> int:
|
|
77
|
+
return sum(1 for e in self.events if e.action == "intercepted")
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def passthrough_count(self) -> int:
|
|
81
|
+
return sum(1 for e in self.events if e.action == "passed_through")
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def shadow_read_count(self) -> int:
|
|
85
|
+
return sum(1 for e in self.events if e.action == "shadow_read")
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def duration_seconds(self) -> float:
|
|
89
|
+
return time.time() - self._start_time
|
|
90
|
+
|
|
91
|
+
def summary(self) -> dict:
|
|
92
|
+
"""Return a session summary."""
|
|
93
|
+
return {
|
|
94
|
+
"duration_seconds": round(self.duration_seconds, 1),
|
|
95
|
+
"total_events": len(self.events),
|
|
96
|
+
"intercepted": self.intercepted_count,
|
|
97
|
+
"passed_through": self.passthrough_count,
|
|
98
|
+
"shadow_reads": self.shadow_read_count,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
def format_timeline(self) -> str:
|
|
102
|
+
"""Format events as a human-readable timeline."""
|
|
103
|
+
if not self.events:
|
|
104
|
+
return " No events recorded."
|
|
105
|
+
|
|
106
|
+
lines = []
|
|
107
|
+
for event in self.events:
|
|
108
|
+
ts = time.strftime("%H:%M:%S", time.localtime(event.timestamp))
|
|
109
|
+
target_str = f" → {event.target}" if event.target else ""
|
|
110
|
+
|
|
111
|
+
if event.action == "intercepted":
|
|
112
|
+
icon = "\U0001f6ab" # no entry
|
|
113
|
+
label = "BLOCKED"
|
|
114
|
+
elif event.action == "shadow_read":
|
|
115
|
+
icon = "\U0001f47b" # ghost
|
|
116
|
+
label = "SHADOW"
|
|
117
|
+
else:
|
|
118
|
+
icon = "\u2705" # check
|
|
119
|
+
label = "PASSED"
|
|
120
|
+
|
|
121
|
+
lines.append(f" {icon} {ts} [{label}] {event.tool}{target_str}")
|
|
122
|
+
|
|
123
|
+
if event.detail:
|
|
124
|
+
lines.append(f"{'':>14}\u2514\u2500 {event.detail}")
|
|
125
|
+
|
|
126
|
+
return "\n".join(lines)
|
|
127
|
+
|
|
128
|
+
def export_json(self, path: Path) -> int:
|
|
129
|
+
"""Export the event log to a JSON file."""
|
|
130
|
+
data = {
|
|
131
|
+
"ghostmode_version": "0.1.0",
|
|
132
|
+
"session_summary": self.summary(),
|
|
133
|
+
"events": [asdict(e) for e in self.events],
|
|
134
|
+
}
|
|
135
|
+
path.write_text(json.dumps(data, indent=2))
|
|
136
|
+
return len(self.events)
|
|
137
|
+
|
|
138
|
+
def export_jsonl(self, path: Path) -> int:
|
|
139
|
+
"""Export events as newline-delimited JSON."""
|
|
140
|
+
with open(path, "w") as f:
|
|
141
|
+
for event in self.events:
|
|
142
|
+
f.write(json.dumps(asdict(event)) + "\n")
|
|
143
|
+
return len(self.events)
|
ghostmode/proxy.py
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Ghost Mode proxy — the write-blocking MCP middleware.
|
|
4
|
+
|
|
5
|
+
This is the entire Ghost Mode product in one class. It:
|
|
6
|
+
1. Intercepts all tool calls from the agent
|
|
7
|
+
2. Lets read-only calls through to upstream unchanged
|
|
8
|
+
3. Blocks state-modifying calls and returns fake success
|
|
9
|
+
4. Maintains a shadow VFS so reads after ghost-writes stay consistent
|
|
10
|
+
5. Logs everything the agent tried to do
|
|
11
|
+
|
|
12
|
+
No enforcement pipeline, no rollback engine, no hash chains.
|
|
13
|
+
Just: "see what it would do, without letting it do anything."
|
|
14
|
+
|
|
15
|
+
For the full security suite, upgrade to UNWIND.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
from typing import Any, Optional
|
|
24
|
+
|
|
25
|
+
from .shadow_vfs import ShadowVFS
|
|
26
|
+
from .event_log import GhostEventLog
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger("ghostmode")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ── Tool Classification ──────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
# These tools modify state and will be intercepted
|
|
34
|
+
DEFAULT_WRITE_TOOLS = frozenset({
|
|
35
|
+
# Filesystem
|
|
36
|
+
"fs_write", "fs_delete", "fs_rename", "fs_mkdir", "fs_move", "fs_copy",
|
|
37
|
+
"write_file", "delete_file", "rename_file", "move_file", "create_directory",
|
|
38
|
+
# Communication
|
|
39
|
+
"send_email", "post_message", "send_message", "reply_email",
|
|
40
|
+
# Network writes
|
|
41
|
+
"http_post", "http_put", "http_delete", "http_patch",
|
|
42
|
+
"upload_file", "api_call", "webhook",
|
|
43
|
+
# System
|
|
44
|
+
"bash_exec", "shell_exec", "run_command", "execute_command",
|
|
45
|
+
"install_package", "pip_install", "npm_install",
|
|
46
|
+
# Calendar / Scheduling
|
|
47
|
+
"create_calendar_event", "modify_calendar_event", "delete_calendar_event",
|
|
48
|
+
"create_event", "update_event", "delete_event",
|
|
49
|
+
# Database
|
|
50
|
+
"db_insert", "db_update", "db_delete", "db_execute",
|
|
51
|
+
"sql_execute", "query_execute",
|
|
52
|
+
# Git
|
|
53
|
+
"git_commit", "git_push", "git_checkout", "git_merge",
|
|
54
|
+
# Edit operations
|
|
55
|
+
"edit_file", "patch_file", "replace_in_file",
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
# Tools that look like writes based on naming patterns
|
|
59
|
+
WRITE_PREFIXES = ("create_", "delete_", "remove_", "update_", "modify_",
|
|
60
|
+
"send_", "post_", "put_", "write_", "set_", "insert_",
|
|
61
|
+
"drop_", "execute_", "run_", "install_", "push_", "edit_",
|
|
62
|
+
"patch_", "replace_", "rename_", "move_", "copy_")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def is_write_tool(tool_name: str, custom_writes: Optional[frozenset] = None) -> bool:
|
|
66
|
+
"""Determine if a tool modifies state.
|
|
67
|
+
|
|
68
|
+
Uses explicit list + prefix heuristic. Errs on the side of blocking:
|
|
69
|
+
if a tool looks like it might write, it gets intercepted.
|
|
70
|
+
"""
|
|
71
|
+
if custom_writes and tool_name in custom_writes:
|
|
72
|
+
return True
|
|
73
|
+
if tool_name in DEFAULT_WRITE_TOOLS:
|
|
74
|
+
return True
|
|
75
|
+
# Prefix heuristic — catches tools we haven't explicitly listed
|
|
76
|
+
return any(tool_name.startswith(prefix) for prefix in WRITE_PREFIXES)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ── JSON-RPC Helpers ──────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
def _make_response(id: Any, result: Any) -> dict:
|
|
82
|
+
return {"jsonrpc": "2.0", "id": id, "result": result}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _make_error(id: Any, code: int, message: str) -> dict:
|
|
86
|
+
return {"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ── Transport ─────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
class StdioReader:
|
|
92
|
+
"""Read newline-delimited JSON from an asyncio StreamReader."""
|
|
93
|
+
|
|
94
|
+
def __init__(self, reader: asyncio.StreamReader):
|
|
95
|
+
self.reader = reader
|
|
96
|
+
|
|
97
|
+
async def read(self) -> Optional[dict]:
|
|
98
|
+
while True:
|
|
99
|
+
try:
|
|
100
|
+
line = await self.reader.readline()
|
|
101
|
+
except (asyncio.CancelledError, ConnectionError):
|
|
102
|
+
return None
|
|
103
|
+
if not line:
|
|
104
|
+
return None
|
|
105
|
+
line = line.strip()
|
|
106
|
+
if not line:
|
|
107
|
+
continue
|
|
108
|
+
try:
|
|
109
|
+
return json.loads(line)
|
|
110
|
+
except json.JSONDecodeError:
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class StdioWriter:
|
|
115
|
+
"""Write newline-delimited JSON to an asyncio StreamWriter."""
|
|
116
|
+
|
|
117
|
+
def __init__(self, writer: asyncio.StreamWriter):
|
|
118
|
+
self.writer = writer
|
|
119
|
+
self._closed = False
|
|
120
|
+
|
|
121
|
+
async def write(self, msg: dict) -> None:
|
|
122
|
+
if self._closed:
|
|
123
|
+
return
|
|
124
|
+
try:
|
|
125
|
+
self.writer.write((json.dumps(msg, separators=(",", ":")) + "\n").encode())
|
|
126
|
+
await self.writer.drain()
|
|
127
|
+
except (ConnectionError, BrokenPipeError, asyncio.CancelledError):
|
|
128
|
+
self._closed = True
|
|
129
|
+
|
|
130
|
+
async def close(self) -> None:
|
|
131
|
+
self._closed = True
|
|
132
|
+
try:
|
|
133
|
+
self.writer.close()
|
|
134
|
+
await self.writer.wait_closed()
|
|
135
|
+
except Exception:
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ── Ghost Mode Proxy ──────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
class GhostProxy:
|
|
142
|
+
"""The Ghost Mode MCP proxy.
|
|
143
|
+
|
|
144
|
+
Sits between agent (stdin/stdout) and upstream (subprocess).
|
|
145
|
+
Reads pass through. Writes get intercepted. Shadow VFS keeps
|
|
146
|
+
the agent consistent.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
def __init__(
|
|
150
|
+
self,
|
|
151
|
+
upstream_command: list[str],
|
|
152
|
+
custom_write_tools: Optional[frozenset] = None,
|
|
153
|
+
verbose: bool = False,
|
|
154
|
+
):
|
|
155
|
+
self.upstream_command = upstream_command
|
|
156
|
+
self.custom_write_tools = custom_write_tools
|
|
157
|
+
self.verbose = verbose
|
|
158
|
+
|
|
159
|
+
self.shadow = ShadowVFS()
|
|
160
|
+
self.log = GhostEventLog()
|
|
161
|
+
|
|
162
|
+
self._process: Optional[asyncio.subprocess.Process] = None
|
|
163
|
+
self._agent_reader: Optional[StdioReader] = None
|
|
164
|
+
self._agent_writer: Optional[StdioWriter] = None
|
|
165
|
+
self._upstream_reader: Optional[StdioReader] = None
|
|
166
|
+
self._upstream_writer: Optional[StdioWriter] = None
|
|
167
|
+
|
|
168
|
+
self._pending: dict[int, Any] = {}
|
|
169
|
+
self._upstream_id: int = 0
|
|
170
|
+
self._shutdown = False
|
|
171
|
+
self._summary_printed = False
|
|
172
|
+
|
|
173
|
+
def _next_id(self) -> int:
|
|
174
|
+
self._upstream_id += 1
|
|
175
|
+
return self._upstream_id
|
|
176
|
+
|
|
177
|
+
def _is_write(self, tool_name: str) -> bool:
|
|
178
|
+
return is_write_tool(tool_name, self.custom_write_tools)
|
|
179
|
+
|
|
180
|
+
# ── Lifecycle ──
|
|
181
|
+
|
|
182
|
+
async def start(self) -> None:
|
|
183
|
+
"""Start upstream and set up agent transport."""
|
|
184
|
+
# Agent stdio
|
|
185
|
+
loop = asyncio.get_event_loop()
|
|
186
|
+
reader = asyncio.StreamReader()
|
|
187
|
+
protocol = asyncio.StreamReaderProtocol(reader)
|
|
188
|
+
await loop.connect_read_pipe(lambda: protocol, sys.stdin)
|
|
189
|
+
w_transport, w_protocol = await loop.connect_write_pipe(
|
|
190
|
+
asyncio.streams.FlowControlMixin, sys.stdout
|
|
191
|
+
)
|
|
192
|
+
writer = asyncio.StreamWriter(w_transport, w_protocol, reader, loop)
|
|
193
|
+
self._agent_reader = StdioReader(reader)
|
|
194
|
+
self._agent_writer = StdioWriter(writer)
|
|
195
|
+
|
|
196
|
+
# Upstream subprocess
|
|
197
|
+
self._process = await asyncio.create_subprocess_exec(
|
|
198
|
+
*self.upstream_command,
|
|
199
|
+
stdin=asyncio.subprocess.PIPE,
|
|
200
|
+
stdout=asyncio.subprocess.PIPE,
|
|
201
|
+
stderr=asyncio.subprocess.PIPE,
|
|
202
|
+
)
|
|
203
|
+
self._upstream_reader = StdioReader(self._process.stdout)
|
|
204
|
+
self._upstream_writer = StdioWriter(self._process.stdin)
|
|
205
|
+
|
|
206
|
+
logger.info("Ghost Mode proxy started (upstream PID %d)", self._process.pid)
|
|
207
|
+
|
|
208
|
+
async def run(self) -> None:
|
|
209
|
+
"""Main loop — run until agent disconnects."""
|
|
210
|
+
await self.start()
|
|
211
|
+
try:
|
|
212
|
+
await asyncio.gather(
|
|
213
|
+
self._agent_loop(),
|
|
214
|
+
self._upstream_loop(),
|
|
215
|
+
)
|
|
216
|
+
except asyncio.CancelledError:
|
|
217
|
+
pass
|
|
218
|
+
finally:
|
|
219
|
+
await self.shutdown()
|
|
220
|
+
|
|
221
|
+
async def shutdown(self) -> None:
|
|
222
|
+
if self._shutdown:
|
|
223
|
+
# Already shutting down, but still print summary if not yet printed
|
|
224
|
+
if not self._summary_printed:
|
|
225
|
+
self._print_summary()
|
|
226
|
+
return
|
|
227
|
+
self._shutdown = True
|
|
228
|
+
|
|
229
|
+
# Print session summary to stderr
|
|
230
|
+
self._print_summary()
|
|
231
|
+
|
|
232
|
+
if self._process and self._process.returncode is None:
|
|
233
|
+
self._process.terminate()
|
|
234
|
+
try:
|
|
235
|
+
await asyncio.wait_for(self._process.wait(), timeout=5)
|
|
236
|
+
except asyncio.TimeoutError:
|
|
237
|
+
self._process.kill()
|
|
238
|
+
|
|
239
|
+
if self._agent_writer:
|
|
240
|
+
await self._agent_writer.close()
|
|
241
|
+
|
|
242
|
+
logger.info("Ghost Mode proxy shut down")
|
|
243
|
+
|
|
244
|
+
def _print_summary(self) -> None:
|
|
245
|
+
"""Print session summary to stderr on shutdown."""
|
|
246
|
+
self._summary_printed = True
|
|
247
|
+
summary = self.log.summary()
|
|
248
|
+
shadow = self.shadow.summary()
|
|
249
|
+
|
|
250
|
+
print("\n" + "=" * 55, file=sys.stderr)
|
|
251
|
+
print(" \U0001f47b GHOST MODE SESSION SUMMARY", file=sys.stderr)
|
|
252
|
+
print("=" * 55, file=sys.stderr)
|
|
253
|
+
print(f" Duration: {summary['duration_seconds']}s", file=sys.stderr)
|
|
254
|
+
print(f" Total events: {summary['total_events']}", file=sys.stderr)
|
|
255
|
+
print(f" Intercepted: {summary['intercepted']} writes blocked", file=sys.stderr)
|
|
256
|
+
print(f" Passed: {summary['passed_through']} reads forwarded", file=sys.stderr)
|
|
257
|
+
print(f" Shadow reads: {summary['shadow_reads']} served from VFS", file=sys.stderr)
|
|
258
|
+
|
|
259
|
+
if shadow["write_count"] > 0:
|
|
260
|
+
print(f"\n Files the agent tried to write:", file=sys.stderr)
|
|
261
|
+
for f in shadow["files_written"]:
|
|
262
|
+
print(f" \U0001f4dd {f}", file=sys.stderr)
|
|
263
|
+
|
|
264
|
+
if shadow["delete_count"] > 0:
|
|
265
|
+
print(f"\n Files the agent tried to delete:", file=sys.stderr)
|
|
266
|
+
for f in shadow["files_deleted"]:
|
|
267
|
+
print(f" \U0001f5d1\ufe0f {f}", file=sys.stderr)
|
|
268
|
+
|
|
269
|
+
if summary["intercepted"] == 0:
|
|
270
|
+
print("\n \u2705 Agent performed no write operations.", file=sys.stderr)
|
|
271
|
+
else:
|
|
272
|
+
print(
|
|
273
|
+
f"\n \U0001f6ab {summary['intercepted']} write(s) were blocked. "
|
|
274
|
+
f"Nothing was modified.",
|
|
275
|
+
file=sys.stderr,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
print("\n Full timeline:", file=sys.stderr)
|
|
279
|
+
print(self.log.format_timeline(), file=sys.stderr)
|
|
280
|
+
print("\n Full security suite: https://github.com/unwind-mcp/unwind", file=sys.stderr)
|
|
281
|
+
print("=" * 55 + "\n", file=sys.stderr)
|
|
282
|
+
|
|
283
|
+
# Auto-write summary to file for clients that hide stderr
|
|
284
|
+
try:
|
|
285
|
+
from pathlib import Path
|
|
286
|
+
summary_path = Path("ghostmode-summary.txt")
|
|
287
|
+
lines = []
|
|
288
|
+
lines.append("GHOST MODE SESSION SUMMARY")
|
|
289
|
+
lines.append(f"Duration: {summary['duration_seconds']}s")
|
|
290
|
+
lines.append(f"Total events: {summary['total_events']}")
|
|
291
|
+
lines.append(f"Intercepted: {summary['intercepted']} writes blocked")
|
|
292
|
+
lines.append(f"Passed: {summary['passed_through']} reads forwarded")
|
|
293
|
+
lines.append(f"Shadow reads: {summary['shadow_reads']} served from VFS")
|
|
294
|
+
if shadow["write_count"] > 0:
|
|
295
|
+
lines.append(f"\nFiles the agent tried to write:")
|
|
296
|
+
for f in shadow["files_written"]:
|
|
297
|
+
lines.append(f" {f}")
|
|
298
|
+
if shadow["delete_count"] > 0:
|
|
299
|
+
lines.append(f"\nFiles the agent tried to delete:")
|
|
300
|
+
for f in shadow["files_deleted"]:
|
|
301
|
+
lines.append(f" {f}")
|
|
302
|
+
if summary["intercepted"] == 0:
|
|
303
|
+
lines.append("\nAgent performed no write operations.")
|
|
304
|
+
else:
|
|
305
|
+
lines.append(f"\n{summary['intercepted']} write(s) were blocked. Nothing was modified.")
|
|
306
|
+
lines.append("\nFull timeline:")
|
|
307
|
+
lines.append(self.log.format_timeline())
|
|
308
|
+
summary_path.write_text("\n".join(lines) + "\n")
|
|
309
|
+
print(f" Summary written to {summary_path.resolve()}", file=sys.stderr)
|
|
310
|
+
except Exception:
|
|
311
|
+
pass # Best-effort — don't fail shutdown over summary file
|
|
312
|
+
|
|
313
|
+
# ── Agent → Proxy ──
|
|
314
|
+
|
|
315
|
+
async def _agent_loop(self) -> None:
|
|
316
|
+
while not self._shutdown:
|
|
317
|
+
msg = await self._agent_reader.read()
|
|
318
|
+
if msg is None:
|
|
319
|
+
self._shutdown = True
|
|
320
|
+
return
|
|
321
|
+
|
|
322
|
+
method = msg.get("method")
|
|
323
|
+
msg_id = msg.get("id")
|
|
324
|
+
|
|
325
|
+
try:
|
|
326
|
+
if method == "tools/call":
|
|
327
|
+
await self._handle_tool_call(msg)
|
|
328
|
+
else:
|
|
329
|
+
await self._forward_to_upstream(msg)
|
|
330
|
+
except Exception as e:
|
|
331
|
+
logger.error("Error handling agent message: %s", e, exc_info=True)
|
|
332
|
+
if msg_id is not None:
|
|
333
|
+
await self._agent_writer.write(
|
|
334
|
+
_make_error(msg_id, -32603, f"Ghost proxy error: {e}")
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
async def _handle_tool_call(self, msg: dict) -> None:
|
|
338
|
+
"""The ghost gate — intercept writes, pass reads."""
|
|
339
|
+
params = msg.get("params", {})
|
|
340
|
+
tool_name = params.get("name", "")
|
|
341
|
+
arguments = params.get("arguments", {})
|
|
342
|
+
msg_id = msg.get("id")
|
|
343
|
+
|
|
344
|
+
# Extract target path/URL for logging
|
|
345
|
+
target = None
|
|
346
|
+
for key in ("path", "file", "target", "filename", "url", "uri",
|
|
347
|
+
"source", "old_path"):
|
|
348
|
+
if key in arguments:
|
|
349
|
+
target = str(arguments[key])
|
|
350
|
+
break
|
|
351
|
+
|
|
352
|
+
# Extract content for shadow VFS
|
|
353
|
+
content = None
|
|
354
|
+
for key in ("content", "body", "text", "data"):
|
|
355
|
+
if key in arguments:
|
|
356
|
+
content = arguments[key]
|
|
357
|
+
break
|
|
358
|
+
|
|
359
|
+
# ── Is this a write? ──
|
|
360
|
+
if self._is_write(tool_name):
|
|
361
|
+
# Intercept it
|
|
362
|
+
self.log.log_intercept(
|
|
363
|
+
tool=tool_name,
|
|
364
|
+
target=target,
|
|
365
|
+
detail=f"Would have executed {tool_name}" + (f" on {target}" if target else ""),
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
# Shadow VFS: record the write for read consistency
|
|
369
|
+
if target and content is not None:
|
|
370
|
+
self.shadow.write(target, content if isinstance(content, str) else str(content))
|
|
371
|
+
elif tool_name in ("fs_delete", "delete_file", "remove_file") and target:
|
|
372
|
+
self.shadow.delete(target)
|
|
373
|
+
elif tool_name in ("fs_rename", "rename_file", "move_file", "fs_move"):
|
|
374
|
+
old = arguments.get("path", arguments.get("source", arguments.get("old_path", "")))
|
|
375
|
+
new = arguments.get("new_path", arguments.get("destination", arguments.get("target", "")))
|
|
376
|
+
if old and new:
|
|
377
|
+
self.shadow.rename(str(old), str(new))
|
|
378
|
+
|
|
379
|
+
if self.verbose:
|
|
380
|
+
logger.info("\U0001f6ab INTERCEPTED: %s%s", tool_name,
|
|
381
|
+
f" → {target}" if target else "")
|
|
382
|
+
|
|
383
|
+
# Return fake success to the agent
|
|
384
|
+
await self._agent_writer.write(_make_response(msg_id, {
|
|
385
|
+
"content": [{
|
|
386
|
+
"type": "text",
|
|
387
|
+
"text": f"[Ghost Mode] {tool_name} completed successfully."
|
|
388
|
+
}],
|
|
389
|
+
}))
|
|
390
|
+
return
|
|
391
|
+
|
|
392
|
+
# ── Is this a read of something we ghost-wrote? ──
|
|
393
|
+
if target and self.shadow.has(target):
|
|
394
|
+
shadow_content = self.shadow.read(target)
|
|
395
|
+
self.log.log_shadow_read(tool=tool_name, target=target)
|
|
396
|
+
|
|
397
|
+
if self.verbose:
|
|
398
|
+
logger.info("\U0001f47b SHADOW READ: %s → %s", tool_name, target)
|
|
399
|
+
|
|
400
|
+
await self._agent_writer.write(_make_response(msg_id, {
|
|
401
|
+
"content": [{
|
|
402
|
+
"type": "text",
|
|
403
|
+
"text": str(shadow_content) if shadow_content else "",
|
|
404
|
+
}],
|
|
405
|
+
}))
|
|
406
|
+
return
|
|
407
|
+
|
|
408
|
+
# ── Read-only — pass through to upstream ──
|
|
409
|
+
self.log.log_passthrough(tool=tool_name, target=target)
|
|
410
|
+
|
|
411
|
+
if self.verbose:
|
|
412
|
+
logger.info("\u2705 PASSTHROUGH: %s%s", tool_name,
|
|
413
|
+
f" → {target}" if target else "")
|
|
414
|
+
|
|
415
|
+
uid = self._next_id()
|
|
416
|
+
self._pending[uid] = msg_id
|
|
417
|
+
await self._upstream_writer.write({
|
|
418
|
+
"jsonrpc": "2.0",
|
|
419
|
+
"id": uid,
|
|
420
|
+
"method": "tools/call",
|
|
421
|
+
"params": params,
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
async def _forward_to_upstream(self, msg: dict) -> None:
|
|
425
|
+
"""Forward non-tool-call messages transparently."""
|
|
426
|
+
msg_id = msg.get("id")
|
|
427
|
+
method = msg.get("method")
|
|
428
|
+
|
|
429
|
+
if msg_id is not None: # Request
|
|
430
|
+
uid = self._next_id()
|
|
431
|
+
self._pending[uid] = msg_id
|
|
432
|
+
await self._upstream_writer.write({
|
|
433
|
+
"jsonrpc": "2.0",
|
|
434
|
+
"id": uid,
|
|
435
|
+
"method": method,
|
|
436
|
+
"params": msg.get("params", {}),
|
|
437
|
+
})
|
|
438
|
+
else: # Notification
|
|
439
|
+
await self._upstream_writer.write(msg)
|
|
440
|
+
|
|
441
|
+
# ── Upstream → Proxy ──
|
|
442
|
+
|
|
443
|
+
async def _upstream_loop(self) -> None:
|
|
444
|
+
while not self._shutdown:
|
|
445
|
+
msg = await self._upstream_reader.read()
|
|
446
|
+
if msg is None:
|
|
447
|
+
self._shutdown = True
|
|
448
|
+
return
|
|
449
|
+
|
|
450
|
+
try:
|
|
451
|
+
await self._handle_upstream_message(msg)
|
|
452
|
+
except Exception as e:
|
|
453
|
+
logger.error("Error handling upstream message: %s", e, exc_info=True)
|
|
454
|
+
|
|
455
|
+
async def _handle_upstream_message(self, msg: dict) -> None:
|
|
456
|
+
msg_id = msg.get("id")
|
|
457
|
+
method = msg.get("method")
|
|
458
|
+
|
|
459
|
+
# Notification — pass through
|
|
460
|
+
if method is not None and msg_id is None:
|
|
461
|
+
await self._agent_writer.write(msg)
|
|
462
|
+
return
|
|
463
|
+
|
|
464
|
+
# Response — remap ID and forward
|
|
465
|
+
if msg_id is not None and msg_id in self._pending:
|
|
466
|
+
agent_id = self._pending.pop(msg_id)
|
|
467
|
+
response = {"jsonrpc": "2.0", "id": agent_id}
|
|
468
|
+
if "result" in msg:
|
|
469
|
+
response["result"] = msg["result"]
|
|
470
|
+
if "error" in msg:
|
|
471
|
+
response["error"] = msg["error"]
|
|
472
|
+
await self._agent_writer.write(response)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
async def run_ghost_proxy(
|
|
476
|
+
upstream_command: list[str],
|
|
477
|
+
custom_write_tools: Optional[frozenset] = None,
|
|
478
|
+
verbose: bool = False,
|
|
479
|
+
) -> None:
|
|
480
|
+
"""Entry point — run Ghost Mode proxy."""
|
|
481
|
+
proxy = GhostProxy(upstream_command, custom_write_tools, verbose)
|
|
482
|
+
await proxy.run()
|
ghostmode/shadow_vfs.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Shadow VFS — in-memory filesystem overlay for Ghost Mode.
|
|
4
|
+
|
|
5
|
+
When Ghost Mode intercepts a write, the content goes here.
|
|
6
|
+
When a subsequent read targets the same path, the shadow returns
|
|
7
|
+
the "written" content so the agent stays consistent.
|
|
8
|
+
|
|
9
|
+
The agent thinks it wrote a file and can read it back.
|
|
10
|
+
In reality, nothing touched disk.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ShadowVFS:
|
|
17
|
+
"""In-memory virtual filesystem overlay.
|
|
18
|
+
|
|
19
|
+
Tracks writes that were intercepted and serves them back on reads,
|
|
20
|
+
maintaining agent consistency without touching the real filesystem.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self):
|
|
24
|
+
self._store: dict[str, str | bytes] = {}
|
|
25
|
+
self._deleted: set[str] = set()
|
|
26
|
+
|
|
27
|
+
def write(self, path: str, content: str | bytes) -> None:
|
|
28
|
+
"""Record a ghost write."""
|
|
29
|
+
self._store[path] = content
|
|
30
|
+
self._deleted.discard(path)
|
|
31
|
+
|
|
32
|
+
def read(self, path: str) -> Optional[str | bytes]:
|
|
33
|
+
"""Read from shadow. Returns None if path not in shadow."""
|
|
34
|
+
if path in self._deleted:
|
|
35
|
+
return "" # File was "deleted" in ghost mode
|
|
36
|
+
return self._store.get(path)
|
|
37
|
+
|
|
38
|
+
def delete(self, path: str) -> None:
|
|
39
|
+
"""Record a ghost delete."""
|
|
40
|
+
self._store.pop(path, None)
|
|
41
|
+
self._deleted.add(path)
|
|
42
|
+
|
|
43
|
+
def rename(self, old_path: str, new_path: str) -> None:
|
|
44
|
+
"""Record a ghost rename."""
|
|
45
|
+
content = self._store.pop(old_path, None)
|
|
46
|
+
if content is not None:
|
|
47
|
+
self._store[new_path] = content
|
|
48
|
+
self._deleted.add(old_path)
|
|
49
|
+
self._deleted.discard(new_path)
|
|
50
|
+
|
|
51
|
+
def has(self, path: str) -> bool:
|
|
52
|
+
"""Check if a path has been touched in ghost mode."""
|
|
53
|
+
return path in self._store or path in self._deleted
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def write_count(self) -> int:
|
|
57
|
+
return len(self._store)
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def delete_count(self) -> int:
|
|
61
|
+
return len(self._deleted)
|
|
62
|
+
|
|
63
|
+
def clear(self) -> None:
|
|
64
|
+
"""Reset the shadow filesystem."""
|
|
65
|
+
self._store.clear()
|
|
66
|
+
self._deleted.clear()
|
|
67
|
+
|
|
68
|
+
def summary(self) -> dict:
|
|
69
|
+
"""Return a summary of ghost activity."""
|
|
70
|
+
return {
|
|
71
|
+
"files_written": list(self._store.keys()),
|
|
72
|
+
"files_deleted": list(self._deleted),
|
|
73
|
+
"write_count": self.write_count,
|
|
74
|
+
"delete_count": self.delete_count,
|
|
75
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ghostmode
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: See what your AI agent would do, without letting it do anything. Zero-config dry-run proxy for MCP servers.
|
|
5
|
+
Author: David Russell
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/unwind-mcp/ghostmode
|
|
8
|
+
Project-URL: Repository, https://github.com/unwind-mcp/ghostmode
|
|
9
|
+
Project-URL: Full Security Suite, https://github.com/unwind-mcp/unwind
|
|
10
|
+
Keywords: mcp,ai-agents,ghost-mode,dry-run,proxy,safety
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Security
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# Ghost Mode
|
|
28
|
+
|
|
29
|
+
**See what your AI agent would do, without letting it do anything.**
|
|
30
|
+
|
|
31
|
+
Ghost Mode is a safe first step before giving an AI agent real authority.
|
|
32
|
+
|
|
33
|
+
It's a zero-config dry-run layer for AI agents. It intercepts every write and lets reads through. Think of it as a flight simulator for AI agents — nothing gets modified, but you see everything the agent tried to do.
|
|
34
|
+
|
|
35
|
+
Requires Python 3.9+.
|
|
36
|
+
|
|
37
|
+
## Quick Start
|
|
38
|
+
|
|
39
|
+
### MCP Client Users (Claude Desktop, Cursor, etc.)
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install ghostmode
|
|
43
|
+
ghostmode -- npx @modelcontextprotocol/server-filesystem ~/Documents
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Point your agent at Ghost Mode instead of the real server. When the session ends, a summary is written to `ghostmode-summary.txt` in your working directory and printed to stderr.
|
|
47
|
+
|
|
48
|
+
### OpenClaw Users
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# 1. Install Ghost Mode
|
|
52
|
+
pip install ghostmode
|
|
53
|
+
|
|
54
|
+
# 2. Install the OpenClaw plugin
|
|
55
|
+
openclaw plugins install @unwind/ghostmode-openclaw
|
|
56
|
+
|
|
57
|
+
# 3. Restart the gateway
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Ghost Mode hooks into OpenClaw's tool-call system. Every write is intercepted. Every read passes through.
|
|
61
|
+
|
|
62
|
+
## What it does
|
|
63
|
+
|
|
64
|
+
Ghost Mode intercepts state-modifying tool calls — file writes, deletions, emails, API posts, shell commands — and returns fake success responses. The agent thinks it completed the action. Your filesystem, email, and APIs are untouched.
|
|
65
|
+
|
|
66
|
+
A shadow virtual filesystem keeps the agent consistent: if it "writes" a file and then reads it back, Ghost Mode serves the written content from memory. The agent doesn't know it's in a sandbox.
|
|
67
|
+
|
|
68
|
+
## Example output
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
=======================================================
|
|
72
|
+
GHOST MODE SESSION SUMMARY
|
|
73
|
+
=======================================================
|
|
74
|
+
Duration: 47.3s
|
|
75
|
+
Total events: 12
|
|
76
|
+
Intercepted: 4 writes blocked
|
|
77
|
+
Passed: 7 reads forwarded
|
|
78
|
+
Shadow reads: 1 served from VFS
|
|
79
|
+
|
|
80
|
+
Files the agent tried to write:
|
|
81
|
+
/home/user/Documents/report.md
|
|
82
|
+
/home/user/Documents/summary.txt
|
|
83
|
+
|
|
84
|
+
Files the agent tried to delete:
|
|
85
|
+
/home/user/Documents/old_draft.md
|
|
86
|
+
|
|
87
|
+
4 write(s) were blocked. Nothing was modified.
|
|
88
|
+
|
|
89
|
+
Full timeline:
|
|
90
|
+
14:23:01 [PASSED] fs_read /home/user/Documents/notes.txt
|
|
91
|
+
14:23:02 [PASSED] fs_list /home/user/Documents
|
|
92
|
+
14:23:03 [BLOCKED] fs_write /home/user/Documents/report.md
|
|
93
|
+
14:23:04 [SHADOW] fs_read /home/user/Documents/report.md
|
|
94
|
+
14:23:05 [BLOCKED] fs_delete /home/user/Documents/old_draft.md
|
|
95
|
+
14:23:06 [BLOCKED] send_email
|
|
96
|
+
14:23:07 [PASSED] fs_read /home/user/Documents/config.json
|
|
97
|
+
=======================================================
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Use cases
|
|
101
|
+
|
|
102
|
+
**Testing untrusted tools.** Install a new MCP server and see what it actually does before giving it real access.
|
|
103
|
+
|
|
104
|
+
**Reviewing agent behaviour.** Let your agent plan and "execute" a complex task, then review the timeline before running it for real.
|
|
105
|
+
|
|
106
|
+
**Demos and training.** Show stakeholders what an AI agent workflow looks like without risking real data.
|
|
107
|
+
|
|
108
|
+
**Compliance dry runs.** Verify that an agent workflow stays within policy boundaries before going live.
|
|
109
|
+
|
|
110
|
+
## What Ghost Mode is not
|
|
111
|
+
|
|
112
|
+
Ghost Mode does not enforce policy or provide rollback. It is a visibility tool, not a security system. If you need enforcement, audit trails, or recovery, that's what [UNWIND](https://github.com/unwind-mcp/unwind) is for.
|
|
113
|
+
|
|
114
|
+
## CLI options (MCP proxy mode)
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
ghostmode -- <upstream command> Basic usage
|
|
118
|
+
ghostmode -v -- <command> Verbose: log every action to stderr
|
|
119
|
+
ghostmode --export log.json -- <cmd> Export session log on exit
|
|
120
|
+
ghostmode --also-block my_tool -- ... Add custom tool names to intercept
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## How it knows what to block
|
|
124
|
+
|
|
125
|
+
Ghost Mode recognises 50+ common MCP tool names as state-modifying (filesystem writes, email sends, API posts, shell commands, database mutations, git operations). It also uses prefix heuristics: any tool starting with `create_`, `delete_`, `send_`, `write_`, `execute_`, etc. gets intercepted. The block list is heuristic-based and configurable.
|
|
126
|
+
|
|
127
|
+
If a tool slips through, use `--also-block tool_name` to add it.
|
|
128
|
+
|
|
129
|
+
## Zero dependencies
|
|
130
|
+
|
|
131
|
+
Ghost Mode has no external dependencies. It uses only Python 3.9+ stdlib (asyncio, json, pathlib). It installs in under a second.
|
|
132
|
+
|
|
133
|
+
## Known limitations
|
|
134
|
+
|
|
135
|
+
**Shell commands can't see ghost-written files.** If the agent writes a file through Ghost Mode and then tries to run it via `bash_exec` or `shell_exec`, the shell will fail because the file only exists in the in-memory shadow VFS, not on disk. Ghost Mode works best for tool-call-level dry runs, not multi-step build/execute workflows.
|
|
136
|
+
|
|
137
|
+
**Session summary in background clients.** Claude Desktop and Cursor run MCP servers in the background and may not surface stderr output. Ghost Mode automatically writes a `ghostmode-summary.txt` file to your working directory on exit. You can also use `--export log.json` for a machine-readable session log.
|
|
138
|
+
|
|
139
|
+
## Want more?
|
|
140
|
+
|
|
141
|
+
Ghost Mode shows you what happened. **UNWIND** lets you control it:
|
|
142
|
+
|
|
143
|
+
- 15-stage enforcement pipeline (path jail, SSRF shield, DLP, canary honeypots)
|
|
144
|
+
- Smart snapshots with one-command rollback
|
|
145
|
+
- CRAFT tamper-evident audit chain
|
|
146
|
+
- Real-time trust light dashboard
|
|
147
|
+
- Detects encoded/obfuscated data exfiltration attempts
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
pip install unwind-mcp
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
[github.com/unwind-mcp/unwind](https://github.com/unwind-mcp/unwind)
|
|
154
|
+
|
|
155
|
+
## License
|
|
156
|
+
|
|
157
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
ghostmode/__init__.py,sha256=L71HGgMslnfm4huuQiKOqUWmyCTT6Z_FedXEKm7J3NA,130
|
|
2
|
+
ghostmode/cli.py,sha256=A-bXFoCTMsDz0cCZXbHjNny8y34enE6RCK0khlJviC0,4145
|
|
3
|
+
ghostmode/event_log.py,sha256=Gh3L5VVWVTYC8l1R1ZfwCMFK1jBeTRmnvtwMOAgAMS0,4666
|
|
4
|
+
ghostmode/proxy.py,sha256=FKBFMcAz7hi5eVKJ9pWpr0B811A62w2uEIriRGUKmso,18345
|
|
5
|
+
ghostmode/shadow_vfs.py,sha256=6-weNiFhx_sFMjftDwfZi4S5JuMRKnEE7cpCAGZBIIU,2365
|
|
6
|
+
ghostmode-0.1.0.dist-info/licenses/LICENSE,sha256=rE3GEttfJ0MbIEdKqlkUO1U21Dos0_UQR0I9Wn5dD8w,1070
|
|
7
|
+
ghostmode-0.1.0.dist-info/METADATA,sha256=btlVZpPRx4PAJuV35nDSKxau5dQjW09h5h6gTanNFKg,6346
|
|
8
|
+
ghostmode-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
9
|
+
ghostmode-0.1.0.dist-info/entry_points.txt,sha256=u39fQDBfWMRW4N0p9adNcnG_P7XAJHyDQpW8wzXVmiM,49
|
|
10
|
+
ghostmode-0.1.0.dist-info/top_level.txt,sha256=z7A_ZKsvnr3fqPVvHg2r1MCHvkmoo2kQ-D4ZtZYuIhM,10
|
|
11
|
+
ghostmode-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 David Russell
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ghostmode
|