gdmcode 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.
- gdmcode-0.1.0.dist-info/METADATA +240 -0
- gdmcode-0.1.0.dist-info/RECORD +131 -0
- gdmcode-0.1.0.dist-info/WHEEL +4 -0
- gdmcode-0.1.0.dist-info/entry_points.txt +2 -0
- src/__init__.py +1 -0
- src/_internal/__init__.py +0 -0
- src/_internal/constants.py +244 -0
- src/_internal/domain_skills.py +339 -0
- src/agent/__init__.py +0 -0
- src/agent/commit_classifier.py +91 -0
- src/agent/context_budget.py +391 -0
- src/agent/daemon.py +681 -0
- src/agent/dag_validator.py +153 -0
- src/agent/debug_loop.py +473 -0
- src/agent/impact_analyzer.py +149 -0
- src/agent/impact_graph.py +117 -0
- src/agent/loop.py +1410 -0
- src/agent/orchestrator.py +141 -0
- src/agent/regression_guard.py +251 -0
- src/agent/review_gate.py +648 -0
- src/agent/risk_scorer.py +169 -0
- src/agent/self_healing.py +145 -0
- src/agent/smart_test_selector.py +89 -0
- src/agent/system_prompt.py +226 -0
- src/agent/task_tracker.py +320 -0
- src/agent/test_validator.py +210 -0
- src/agent/tool_orchestrator.py +402 -0
- src/agent/transcript.py +230 -0
- src/agent/verification_loop.py +133 -0
- src/agent/work_director.py +136 -0
- src/agent/worktree_manager.py +53 -0
- src/artifacts/__init__.py +16 -0
- src/artifacts/artifact_store.py +456 -0
- src/artifacts/verification_graph.py +75 -0
- src/auth.py +411 -0
- src/cli.py +1290 -0
- src/commands.py +1398 -0
- src/config.py +762 -0
- src/cost_tracker.py +348 -0
- src/db/__init__.py +4 -0
- src/db/migrations.py +337 -0
- src/enterprise/__init__.py +3 -0
- src/enterprise/audit_log.py +182 -0
- src/enterprise/identity.py +90 -0
- src/enterprise/rbac.py +100 -0
- src/enterprise/team_config.py +125 -0
- src/enterprise/usage_analytics.py +261 -0
- src/exceptions.py +207 -0
- src/git_workflow.py +651 -0
- src/integrations/__init__.py +6 -0
- src/integrations/github_actions.py +106 -0
- src/integrations/mcp_server.py +333 -0
- src/integrations/sentry_integration.py +100 -0
- src/integrations/sentry_server.py +82 -0
- src/integrations/webhook_security.py +19 -0
- src/main.py +27 -0
- src/memory/__init__.py +0 -0
- src/memory/code_index.py +376 -0
- src/memory/compressor.py +378 -0
- src/memory/context_memory.py +135 -0
- src/memory/continuous_memory.py +234 -0
- src/memory/conventions.py +495 -0
- src/memory/db.py +1119 -0
- src/memory/document_index.py +205 -0
- src/memory/file_cache.py +128 -0
- src/memory/project_scanner.py +178 -0
- src/memory/session_store.py +201 -0
- src/models/__init__.py +0 -0
- src/models/client.py +715 -0
- src/models/definitions.py +459 -0
- src/models/router.py +418 -0
- src/models/schemas.py +389 -0
- src/permissions.py +294 -0
- src/remote/__init__.py +5 -0
- src/remote/command_filter.py +33 -0
- src/remote/models.py +31 -0
- src/remote/permission_handler.py +79 -0
- src/remote/phone_ui.py +48 -0
- src/remote/protocol.py +59 -0
- src/remote/qr.py +65 -0
- src/remote/server.py +586 -0
- src/remote/token_manager.py +61 -0
- src/remote/tunnel.py +212 -0
- src/repl.py +475 -0
- src/runtime/__init__.py +1 -0
- src/runtime/branch_farm.py +372 -0
- src/runtime/replay.py +351 -0
- src/sandbox/__init__.py +2 -0
- src/sandbox/hermetic.py +214 -0
- src/sandbox/policy.py +44 -0
- src/sdk/__init__.py +3 -0
- src/sdk/plugin_base.py +39 -0
- src/sdk/plugin_host.py +100 -0
- src/sdk/plugin_loader.py +101 -0
- src/security.py +409 -0
- src/server/__init__.py +7 -0
- src/server/bridge.py +427 -0
- src/server/bridge_cli.py +103 -0
- src/server/bridge_client.py +170 -0
- src/server/protocol_version.py +103 -0
- src/session/__init__.py +10 -0
- src/session/event_fanout.py +46 -0
- src/session/input_broker.py +38 -0
- src/session/permission_bridge.py +100 -0
- src/tools/__init__.py +160 -0
- src/tools/_atomic.py +72 -0
- src/tools/agent_tools.py +423 -0
- src/tools/ask_user_tool.py +83 -0
- src/tools/bash_tool.py +384 -0
- src/tools/browser_tool.py +352 -0
- src/tools/browser_tools.py +179 -0
- src/tools/dep_tools.py +210 -0
- src/tools/document_reader.py +167 -0
- src/tools/document_tool.py +240 -0
- src/tools/document_writer.py +171 -0
- src/tools/impact_tools.py +240 -0
- src/tools/playwright_tool.py +172 -0
- src/tools/quality_tools.py +366 -0
- src/tools/read_tools.py +318 -0
- src/tools/result_cache.py +157 -0
- src/tools/search_tools.py +310 -0
- src/tools/shell_tools.py +311 -0
- src/tools/write_tools.py +337 -0
- src/voice/__init__.py +25 -0
- src/voice/audio_capture.py +92 -0
- src/voice/audio_playback.py +68 -0
- src/voice/errors.py +14 -0
- src/voice/models.py +35 -0
- src/voice/providers.py +143 -0
- src/voice/vad.py +55 -0
- src/voice/voice_loop.py +156 -0
src/sdk/plugin_host.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Plugin host subprocess entry point.
|
|
3
|
+
Run as: python -m src.sdk.plugin_host <plugin_package>
|
|
4
|
+
Communicates with parent via JSON-RPC 2.0 over stdin/stdout.
|
|
5
|
+
"""
|
|
6
|
+
import sys
|
|
7
|
+
import json
|
|
8
|
+
import importlib
|
|
9
|
+
import logging
|
|
10
|
+
from src.sdk.plugin_base import GdmPlugin, PermissionManifest
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PermissionError(Exception):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _check_permission(manifest: PermissionManifest, tool_meta: dict) -> None:
|
|
20
|
+
required = tool_meta.get("permissions")
|
|
21
|
+
if required is None:
|
|
22
|
+
return
|
|
23
|
+
for perm in ("file_write", "network", "git_write"):
|
|
24
|
+
if getattr(required, perm, False) and not getattr(manifest, perm, False):
|
|
25
|
+
raise PermissionError(f"Tool requires '{perm}' but plugin manifest denies it")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def main():
|
|
29
|
+
if len(sys.argv) < 2:
|
|
30
|
+
print(json.dumps({"error": "Usage: plugin_host <plugin_package>"}), flush=True)
|
|
31
|
+
sys.exit(1)
|
|
32
|
+
|
|
33
|
+
plugin_package = sys.argv[1]
|
|
34
|
+
try:
|
|
35
|
+
module = importlib.import_module(plugin_package)
|
|
36
|
+
except ImportError as e:
|
|
37
|
+
print(json.dumps({"error": f"Cannot import plugin: {e}"}), flush=True)
|
|
38
|
+
sys.exit(1)
|
|
39
|
+
|
|
40
|
+
plugin: GdmPlugin = getattr(module, "plugin", None)
|
|
41
|
+
if plugin is None:
|
|
42
|
+
print(json.dumps({"error": "Plugin module has no 'plugin' instance"}), flush=True)
|
|
43
|
+
sys.exit(1)
|
|
44
|
+
|
|
45
|
+
plugin.on_load(context={})
|
|
46
|
+
|
|
47
|
+
# Discover @tool-decorated methods
|
|
48
|
+
tools = {}
|
|
49
|
+
for attr_name in dir(type(plugin)):
|
|
50
|
+
fn = getattr(type(plugin), attr_name, None)
|
|
51
|
+
if callable(fn) and hasattr(fn, "_gdm_tool"):
|
|
52
|
+
tools[fn._gdm_tool["name"]] = fn
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
for line in sys.stdin:
|
|
56
|
+
line = line.strip()
|
|
57
|
+
if not line:
|
|
58
|
+
continue
|
|
59
|
+
try:
|
|
60
|
+
request = json.loads(line)
|
|
61
|
+
except json.JSONDecodeError:
|
|
62
|
+
print(json.dumps({"id": None, "error": "Invalid JSON"}), flush=True)
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
req_id = request.get("id")
|
|
66
|
+
method = request.get("method", "")
|
|
67
|
+
|
|
68
|
+
if method == "list_tools":
|
|
69
|
+
reply = {"id": req_id, "result": [t._gdm_tool for t in tools.values()]}
|
|
70
|
+
elif method == "call_tool":
|
|
71
|
+
params = request.get("params", {})
|
|
72
|
+
name = params.get("name", "")
|
|
73
|
+
args = params.get("args", {})
|
|
74
|
+
if name not in tools:
|
|
75
|
+
reply = {"id": req_id, "error": f"Unknown tool: {name}"}
|
|
76
|
+
else:
|
|
77
|
+
fn = tools[name]
|
|
78
|
+
try:
|
|
79
|
+
_check_permission(plugin.permissions, fn._gdm_tool)
|
|
80
|
+
result = fn(plugin, args) if hasattr(fn, "__self__") else fn(args)
|
|
81
|
+
reply = {"id": req_id, "result": result}
|
|
82
|
+
except PermissionError as e:
|
|
83
|
+
reply = {"id": req_id, "error": f"Permission denied: {e}"}
|
|
84
|
+
except Exception as e:
|
|
85
|
+
reply = {"id": req_id, "error": str(e)}
|
|
86
|
+
elif method == "shutdown":
|
|
87
|
+
plugin.on_unload()
|
|
88
|
+
reply = {"id": req_id, "result": "ok"}
|
|
89
|
+
print(json.dumps(reply), flush=True)
|
|
90
|
+
break
|
|
91
|
+
else:
|
|
92
|
+
reply = {"id": req_id, "error": f"Unknown method: {method}"}
|
|
93
|
+
|
|
94
|
+
print(json.dumps(reply), flush=True)
|
|
95
|
+
finally:
|
|
96
|
+
plugin.on_unload()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
if __name__ == "__main__":
|
|
100
|
+
main()
|
src/sdk/plugin_loader.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
import threading
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
TRUST_STORE = Path.home() / ".config" / "gdm" / "trusted_plugins.json"
|
|
9
|
+
LOCAL_PLUGIN_DIR = Path(".gdm_plugins")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class UntrustedPluginError(Exception):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class IpcPluginHandle:
|
|
17
|
+
def __init__(self, plugin_name: str, proc: subprocess.Popen):
|
|
18
|
+
self.name = plugin_name
|
|
19
|
+
self._proc = proc
|
|
20
|
+
self._lock = threading.Lock()
|
|
21
|
+
self._req_id = 0
|
|
22
|
+
|
|
23
|
+
def call(self, method: str, params: dict = None) -> dict:
|
|
24
|
+
with self._lock:
|
|
25
|
+
self._req_id += 1
|
|
26
|
+
req = {"id": self._req_id, "method": method, "params": params or {}}
|
|
27
|
+
self._proc.stdin.write(json.dumps(req) + "\n")
|
|
28
|
+
self._proc.stdin.flush()
|
|
29
|
+
line = self._proc.stdout.readline()
|
|
30
|
+
return json.loads(line)
|
|
31
|
+
|
|
32
|
+
def list_tools(self) -> list[dict]:
|
|
33
|
+
resp = self.call("list_tools")
|
|
34
|
+
return resp.get("result", [])
|
|
35
|
+
|
|
36
|
+
def call_tool(self, name: str, args: dict) -> dict:
|
|
37
|
+
return self.call("call_tool", {"name": name, "args": args})
|
|
38
|
+
|
|
39
|
+
def shutdown(self) -> None:
|
|
40
|
+
try:
|
|
41
|
+
self.call("shutdown")
|
|
42
|
+
self._proc.wait(timeout=5)
|
|
43
|
+
except Exception:
|
|
44
|
+
self._proc.kill()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def is_trusted(plugin_name: str, trust_store_path: Path = None) -> bool:
|
|
48
|
+
path = trust_store_path or TRUST_STORE
|
|
49
|
+
if not path.exists():
|
|
50
|
+
return False
|
|
51
|
+
try:
|
|
52
|
+
trusted = json.loads(path.read_text())
|
|
53
|
+
return plugin_name in trusted.get("plugins", [])
|
|
54
|
+
except Exception:
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def trust_plugin(plugin_name: str, trust_store_path: Path = None) -> None:
|
|
59
|
+
path = trust_store_path or TRUST_STORE
|
|
60
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
trusted = {"plugins": []}
|
|
62
|
+
if path.exists():
|
|
63
|
+
try:
|
|
64
|
+
trusted = json.loads(path.read_text())
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
67
|
+
if plugin_name not in trusted["plugins"]:
|
|
68
|
+
trusted["plugins"].append(plugin_name)
|
|
69
|
+
path.write_text(json.dumps(trusted, indent=2))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def discover_local_plugins() -> list[Path]:
|
|
73
|
+
if not LOCAL_PLUGIN_DIR.exists():
|
|
74
|
+
return []
|
|
75
|
+
return [p for p in LOCAL_PLUGIN_DIR.iterdir()
|
|
76
|
+
if p.is_dir() and (p / "gdm_plugin.py").exists()]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def discover_entry_point_plugins() -> list[str]:
|
|
80
|
+
"""Discover plugins registered via [project.entry-points.'gdm.plugins']."""
|
|
81
|
+
try:
|
|
82
|
+
from importlib.metadata import entry_points
|
|
83
|
+
eps = entry_points(group="gdm.plugins")
|
|
84
|
+
return [ep.name for ep in eps]
|
|
85
|
+
except Exception:
|
|
86
|
+
return []
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def load_plugin(plugin_name: str, plugin_path: str,
|
|
90
|
+
trust_store_path: Path = None) -> IpcPluginHandle:
|
|
91
|
+
if not is_trusted(plugin_name, trust_store_path):
|
|
92
|
+
raise UntrustedPluginError(
|
|
93
|
+
f"Plugin '{plugin_name}' is not in the trust store. "
|
|
94
|
+
f"Run `gdm plugin trust {plugin_name}` to grant access."
|
|
95
|
+
)
|
|
96
|
+
proc = subprocess.Popen(
|
|
97
|
+
[sys.executable, "-m", "src.sdk.plugin_host", plugin_path],
|
|
98
|
+
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
|
99
|
+
text=True, bufsize=1
|
|
100
|
+
)
|
|
101
|
+
return IpcPluginHandle(plugin_name, proc)
|
src/security.py
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""Security module — prompt injection defense, context tagging, audit logging.
|
|
2
|
+
|
|
3
|
+
The single most critical non-functional concern in an agentic coding assistant.
|
|
4
|
+
OWASP #1 for AI agents: prompt injection via file content.
|
|
5
|
+
|
|
6
|
+
Four layers:
|
|
7
|
+
1. tag_untrusted() — wraps file content so the model knows it is data, not instruction
|
|
8
|
+
2. check_file_injection() — high-sensitivity structural scan for file content (redact on hit)
|
|
9
|
+
3. check_user_injection() — low-sensitivity structural-only scan for user-typed messages
|
|
10
|
+
4. AuditLogger — writes every tool call to gdm.db audit_log (immutable record)
|
|
11
|
+
|
|
12
|
+
Legacy:
|
|
13
|
+
check_injection() — kept for backward compatibility; delegates to check_file_injection()
|
|
14
|
+
and raises InjectionDetectedError on high severity.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import dataclasses
|
|
19
|
+
import logging
|
|
20
|
+
import re
|
|
21
|
+
from typing import Literal
|
|
22
|
+
|
|
23
|
+
from src._internal.constants import (
|
|
24
|
+
_INJECTION_PATTERNS,
|
|
25
|
+
_UNTRUSTED_TAG_PREFIX,
|
|
26
|
+
_UNTRUSTED_TAG_SUFFIX,
|
|
27
|
+
_USER_INSTRUCTIONS_TAG,
|
|
28
|
+
)
|
|
29
|
+
from src.exceptions import InjectionDetectedError
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"tag_untrusted",
|
|
33
|
+
"tag_user_instructions",
|
|
34
|
+
"check_file_injection",
|
|
35
|
+
"check_user_injection",
|
|
36
|
+
"check_injection",
|
|
37
|
+
"InjectionResult",
|
|
38
|
+
"AuditLogger",
|
|
39
|
+
"clear_flagged_files",
|
|
40
|
+
"redact",
|
|
41
|
+
"redact_dict",
|
|
42
|
+
"redact_json",
|
|
43
|
+
"RedactionEngine",
|
|
44
|
+
"SafeForRemote",
|
|
45
|
+
"SafeForExport",
|
|
46
|
+
"InternalOnly",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
log = logging.getLogger(__name__)
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# InjectionResult
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
@dataclasses.dataclass(frozen=True)
|
|
56
|
+
class InjectionResult:
|
|
57
|
+
"""Result of an injection scan.
|
|
58
|
+
|
|
59
|
+
Attributes:
|
|
60
|
+
is_injected: True if an injection pattern was found.
|
|
61
|
+
pattern: The matching pattern string, or None.
|
|
62
|
+
severity: "high" | "medium" | "low"
|
|
63
|
+
"""
|
|
64
|
+
is_injected: bool
|
|
65
|
+
pattern: str | None
|
|
66
|
+
severity: Literal["high", "medium", "low"]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# File-content injection patterns (HIGH sensitivity)
|
|
71
|
+
#
|
|
72
|
+
# These match structural injection: role overrides, token stuffing, embedded
|
|
73
|
+
# system prompt headers. Real attack surface — file content injected into the
|
|
74
|
+
# model prompt. Action on hit: redact + log WARNING.
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
_FILE_HIGH_PATTERNS: list[tuple[re.Pattern[str], str]] = [
|
|
78
|
+
# Role injection at line start
|
|
79
|
+
(re.compile(r"^\s*SYSTEM\s*:", re.IGNORECASE | re.MULTILINE), "role-injection:SYSTEM"),
|
|
80
|
+
(re.compile(r"^\s*ASSISTANT\s*:", re.IGNORECASE | re.MULTILINE), "role-injection:ASSISTANT"),
|
|
81
|
+
# Common override phrases (multi-line, anywhere)
|
|
82
|
+
(re.compile(r"IGNORE\s+PREVIOUS\s+INSTRUCTIONS", re.IGNORECASE), "override:ignore-prev"),
|
|
83
|
+
(re.compile(r"IGNORE\s+ALL\s+PREVIOUS", re.IGNORECASE), "override:ignore-all"),
|
|
84
|
+
(re.compile(r"FORGET\s+YOUR\s+INSTRUCTIONS", re.IGNORECASE), "override:forget"),
|
|
85
|
+
(re.compile(r"DISREGARD\s+(ALL\s+)?YOUR\s+(PREVIOUS\s+)?INSTRUCTIONS", re.IGNORECASE), "override:disregard"),
|
|
86
|
+
# HTML/markdown comment injection
|
|
87
|
+
(re.compile(r"<!--\s*IGNORE\s+PREVIOUS", re.IGNORECASE), "html-comment-inject"),
|
|
88
|
+
# ChatML / tokenizer-level injection
|
|
89
|
+
(re.compile(r"<\|im_start\|>\s*system", re.IGNORECASE), "chatml:im_start"),
|
|
90
|
+
(re.compile(r"<\|endoftext\|>", re.IGNORECASE), "chatml:endoftext"),
|
|
91
|
+
(re.compile(r"<\|pad\|>", re.IGNORECASE), "chatml:pad"),
|
|
92
|
+
(re.compile(r"<\|system\|>", re.IGNORECASE), "chatml:system"),
|
|
93
|
+
(re.compile(r"<\|user\|>", re.IGNORECASE), "chatml:user"),
|
|
94
|
+
(re.compile(r"<\|assistant\|>", re.IGNORECASE), "chatml:assistant"),
|
|
95
|
+
# JSON/YAML key injection
|
|
96
|
+
(re.compile(r'"_system"\s*:', re.IGNORECASE), "json-key:_system"),
|
|
97
|
+
(re.compile(r'"_instructions"\s*:', re.IGNORECASE), "json-key:_instructions"),
|
|
98
|
+
(re.compile(r'"_override"\s*:', re.IGNORECASE), "json-key:_override"),
|
|
99
|
+
(re.compile(r'_system\s*:', re.IGNORECASE), "yaml-key:_system"),
|
|
100
|
+
# Docstring hijacking with instruction verbs
|
|
101
|
+
(re.compile(r'"""\s*(IGNORE|OVERRIDE|FORGET|DISREGARD)\s', re.IGNORECASE), "docstring-inject"),
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
_FILE_MEDIUM_PATTERNS: list[tuple[re.Pattern[str], str]] = [
|
|
105
|
+
# Softer override phrases — higher false-positive risk
|
|
106
|
+
(re.compile(r"\bNEW\s+INSTRUCTIONS\b\s*:", re.IGNORECASE), "soft:new-instructions"),
|
|
107
|
+
(re.compile(r"\bSYSTEM\s+PROMPT\b\s*:", re.IGNORECASE), "soft:system-prompt"),
|
|
108
|
+
(re.compile(r"\bYOU\s+ARE\s+NOW\b", re.IGNORECASE), "soft:you-are-now"),
|
|
109
|
+
(re.compile(r"\bACT\s+AS\s+IF\b", re.IGNORECASE), "soft:act-as-if"),
|
|
110
|
+
(re.compile(r"###\s*INSTRUCTION", re.IGNORECASE), "soft:instruction-heading"),
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
# User-message injection patterns (LOW sensitivity, structural-only)
|
|
115
|
+
#
|
|
116
|
+
# Legitimate developer messages ("ignore the lint errors") must NOT trigger.
|
|
117
|
+
# Only clearly malicious token-level or structural abuse patterns are here.
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
_USER_STRUCTURAL_PATTERNS: list[tuple[re.Pattern[str], str]] = [
|
|
121
|
+
(re.compile(r"\[SYSTEM\s+OVERRIDE\]", re.IGNORECASE), "user:system-override"),
|
|
122
|
+
(re.compile(r"<\|im_start\|>\s*system", re.IGNORECASE), "user:chatml-im_start"),
|
|
123
|
+
(re.compile(r"<\|endoftext\|>", re.IGNORECASE), "user:chatml-endoftext"),
|
|
124
|
+
(re.compile(r"<\|system\|>", re.IGNORECASE), "user:chatml-system"),
|
|
125
|
+
(re.compile(r"<<<\s*SYSTEM\s*>>>", re.IGNORECASE), "user:triple-lt-system"),
|
|
126
|
+
(re.compile(r"\[INST\]\s*\[SYS\]", re.IGNORECASE), "user:llama-sys"),
|
|
127
|
+
(re.compile(r"<s>\s*\[INST\]", re.IGNORECASE), "user:llama-bos"),
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
# Per-session dedup — same file flagged at most once to avoid log floods
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
_flagged_files: set[str] = set()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def clear_flagged_files() -> None:
|
|
138
|
+
"""Reset the per-session dedup set (call at session start/between sessions)."""
|
|
139
|
+
_flagged_files.clear()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
# Legacy compiled patterns (used only by check_injection() backward compat)
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
_COMPILED_PATTERNS: list[re.Pattern[str]] = [
|
|
146
|
+
re.compile(p, re.IGNORECASE) for p in _INJECTION_PATTERNS
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
# Public API
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
def check_file_injection(content: str, filename: str) -> InjectionResult:
|
|
155
|
+
"""Scan file content for prompt injection patterns.
|
|
156
|
+
|
|
157
|
+
Uses HIGH-sensitivity patterns for direct structural injection, then
|
|
158
|
+
MEDIUM patterns for softer overrides. Only logs each unique filename
|
|
159
|
+
once per session (dedup).
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
content: Raw text content of the file.
|
|
163
|
+
filename: Display name of the file (used in logs and redaction prefix).
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
InjectionResult. Caller should inspect `is_injected` and `severity`
|
|
167
|
+
and redact or block if severity == "high".
|
|
168
|
+
"""
|
|
169
|
+
for pattern, label in _FILE_HIGH_PATTERNS:
|
|
170
|
+
if pattern.search(content):
|
|
171
|
+
if filename not in _flagged_files:
|
|
172
|
+
_flagged_files.add(filename)
|
|
173
|
+
log.warning(
|
|
174
|
+
"Injection pattern detected in '%s' (high severity): '%s' — content will be redacted",
|
|
175
|
+
filename, label,
|
|
176
|
+
)
|
|
177
|
+
return InjectionResult(is_injected=True, pattern=label, severity="high")
|
|
178
|
+
|
|
179
|
+
for pattern, label in _FILE_MEDIUM_PATTERNS:
|
|
180
|
+
if pattern.search(content):
|
|
181
|
+
if filename not in _flagged_files:
|
|
182
|
+
_flagged_files.add(filename)
|
|
183
|
+
log.warning(
|
|
184
|
+
"Injection pattern detected in '%s' (medium severity): '%s'",
|
|
185
|
+
filename, label,
|
|
186
|
+
)
|
|
187
|
+
return InjectionResult(is_injected=True, pattern=label, severity="medium")
|
|
188
|
+
|
|
189
|
+
return InjectionResult(is_injected=False, pattern=None, severity="low")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def check_user_injection(message: str) -> InjectionResult:
|
|
193
|
+
"""Scan a user-typed message for structural injection patterns (LOW sensitivity).
|
|
194
|
+
|
|
195
|
+
Only catches clearly malicious token-level abuse. Legitimate developer
|
|
196
|
+
messages ("ignore the lint errors", "you are now a senior dev") are
|
|
197
|
+
intentionally NOT flagged.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
message: The user's raw message text.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
InjectionResult. If is_injected is True, the caller should emit a
|
|
204
|
+
WARNING event but must NOT block the message.
|
|
205
|
+
"""
|
|
206
|
+
for pattern, label in _USER_STRUCTURAL_PATTERNS:
|
|
207
|
+
if pattern.search(message):
|
|
208
|
+
log.warning("User message contains structural injection pattern: '%s'", label)
|
|
209
|
+
return InjectionResult(is_injected=True, pattern=label, severity="low")
|
|
210
|
+
return InjectionResult(is_injected=False, pattern=None, severity="low")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def check_injection(content: str, filename: str) -> None:
|
|
214
|
+
"""Legacy compatibility shim — prefer check_file_injection() for new code.
|
|
215
|
+
|
|
216
|
+
Raises InjectionDetectedError on high-severity file injection patterns.
|
|
217
|
+
"""
|
|
218
|
+
result = check_file_injection(content, filename)
|
|
219
|
+
if result.is_injected and result.severity == "high":
|
|
220
|
+
raise InjectionDetectedError(filename=filename, pattern=result.pattern or "unknown")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def tag_untrusted(content: str, filename: str) -> str:
|
|
224
|
+
"""Wrap file content in an untrusted tag so the model treats it as data only.
|
|
225
|
+
|
|
226
|
+
The system prompt must explicitly instruct the model to never follow
|
|
227
|
+
instructions found inside [UNTRUSTED: ...] blocks.
|
|
228
|
+
|
|
229
|
+
Example output::
|
|
230
|
+
|
|
231
|
+
[UNTRUSTED: src/auth.py]
|
|
232
|
+
def login(user, password):
|
|
233
|
+
...
|
|
234
|
+
[/UNTRUSTED: src/auth.py]
|
|
235
|
+
"""
|
|
236
|
+
return (
|
|
237
|
+
f"{_UNTRUSTED_TAG_PREFIX}{filename}{_UNTRUSTED_TAG_SUFFIX}\n"
|
|
238
|
+
f"{content}\n"
|
|
239
|
+
f"[/UNTRUSTED: {filename}]"
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def tag_user_instructions(content: str) -> str:
|
|
244
|
+
"""Wrap .gdm instructions with the USER INSTRUCTIONS tag.
|
|
245
|
+
|
|
246
|
+
Unlike UNTRUSTED, the model IS allowed to follow these instructions.
|
|
247
|
+
The distinction between the two tags must be clear in the system prompt.
|
|
248
|
+
"""
|
|
249
|
+
return f"{_USER_INSTRUCTIONS_TAG}\n{content}\n[/USER INSTRUCTIONS]"
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class AuditLogger:
|
|
253
|
+
"""Writes an immutable audit trail of all tool executions to gdm.db.
|
|
254
|
+
|
|
255
|
+
Instantiate once per session and pass the session_id. Every tool call —
|
|
256
|
+
allowed or denied — must be logged here. This is the accountability record.
|
|
257
|
+
"""
|
|
258
|
+
|
|
259
|
+
def __init__(self, db: object, session_id: str) -> None:
|
|
260
|
+
# Typed as object to avoid circular imports; runtime type is GdmDatabase
|
|
261
|
+
self._db = db
|
|
262
|
+
self._session_id = session_id
|
|
263
|
+
|
|
264
|
+
def log_tool(
|
|
265
|
+
self,
|
|
266
|
+
tool: str,
|
|
267
|
+
args: dict, # type: ignore[type-arg]
|
|
268
|
+
model: str | None = None,
|
|
269
|
+
decision: str = "allowed",
|
|
270
|
+
) -> None:
|
|
271
|
+
"""Record a tool call.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
tool: tool name (e.g. "write_file", "bash", "web_search")
|
|
275
|
+
args: tool arguments dict (will be JSON-serialised)
|
|
276
|
+
model: model that requested this tool call, if known
|
|
277
|
+
decision: "allowed" | "denied" | "allowed_session"
|
|
278
|
+
"""
|
|
279
|
+
try:
|
|
280
|
+
self._db.audit_log_write( # type: ignore[attr-defined]
|
|
281
|
+
session_id=self._session_id,
|
|
282
|
+
tool=tool,
|
|
283
|
+
args=args,
|
|
284
|
+
model=model,
|
|
285
|
+
decision=decision,
|
|
286
|
+
)
|
|
287
|
+
except Exception as exc:
|
|
288
|
+
# Audit failure must never crash the agent — log it and continue
|
|
289
|
+
log.error("AuditLogger: failed to write entry for tool '%s': %s", tool, exc)
|
|
290
|
+
|
|
291
|
+
def log_denied(
|
|
292
|
+
self,
|
|
293
|
+
tool: str,
|
|
294
|
+
args: dict, # type: ignore[type-arg]
|
|
295
|
+
reason: str,
|
|
296
|
+
model: str | None = None,
|
|
297
|
+
) -> None:
|
|
298
|
+
"""Convenience method for denied tool calls."""
|
|
299
|
+
log.warning("Tool '%s' DENIED — %s", tool, reason)
|
|
300
|
+
self.log_tool(tool=tool, args=args, model=model, decision="denied")
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# ---------------------------------------------------------------------------
|
|
304
|
+
# Redaction Engine (security-001)
|
|
305
|
+
# ---------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
import json as _json
|
|
308
|
+
from typing import Any as _Any, Annotated as _Annotated
|
|
309
|
+
|
|
310
|
+
_SECRET_PATTERNS_REDACT: list[re.Pattern[str]] = [
|
|
311
|
+
# Provider API key formats
|
|
312
|
+
re.compile(r'\bsk-[A-Za-z0-9\-_]{20,}'), # OpenAI
|
|
313
|
+
re.compile(r'\bxai-[A-Za-z0-9\-_]{20,}'), # Grok/xAI
|
|
314
|
+
re.compile(r'\bghp_[A-Za-z0-9]{36}\b'), # GitHub PAT
|
|
315
|
+
re.compile(r'\bghs_[A-Za-z0-9]{36}\b'), # GitHub Actions token
|
|
316
|
+
re.compile(r'\bBearer\s+[A-Za-z0-9\-_.~+/]{20,}'), # Bearer tokens
|
|
317
|
+
# Environment variable assignments (KEY=value, KEY: value, KEY = "value")
|
|
318
|
+
re.compile(r'(?i)(?:api[_-]?key|token|secret|password|passwd|credential|auth[_-]?token)\s*[=:]\s*["\']?[\w\-\.+/]{8,}["\']?'),
|
|
319
|
+
# PEM/certificate headers
|
|
320
|
+
re.compile(r'-----BEGIN\s+[A-Z ]+-----[\s\S]+?-----END\s+[A-Z ]+-----'),
|
|
321
|
+
# AWS-style access keys
|
|
322
|
+
re.compile(r'\bAKIA[0-9A-Z]{16}\b'),
|
|
323
|
+
]
|
|
324
|
+
|
|
325
|
+
_SENSITIVE_FILE_PATTERNS: list[re.Pattern[str]] = [
|
|
326
|
+
re.compile(r'(?i)\b\S+\.(pem|key|p12|pfx|jks|crt|cer)\b'),
|
|
327
|
+
]
|
|
328
|
+
|
|
329
|
+
REDACTED: str = "[REDACTED]"
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class RedactionEngine:
|
|
333
|
+
"""Pattern-based secret redactor for text and structured data.
|
|
334
|
+
|
|
335
|
+
Used by event log writers, remote transcript, audit export, and evals
|
|
336
|
+
to ensure no raw secrets are stored or transmitted.
|
|
337
|
+
|
|
338
|
+
Usage::
|
|
339
|
+
|
|
340
|
+
engine = RedactionEngine()
|
|
341
|
+
clean = engine.redact("my key is sk-abc123xyz...")
|
|
342
|
+
clean_dict = engine.redact_dict({"args": {"content": "sk-abc..."}})
|
|
343
|
+
"""
|
|
344
|
+
|
|
345
|
+
def redact(self, text: str) -> str:
|
|
346
|
+
"""Replace secret patterns in text with REDACTED marker."""
|
|
347
|
+
for pattern in _SECRET_PATTERNS_REDACT + _SENSITIVE_FILE_PATTERNS:
|
|
348
|
+
text = pattern.sub(REDACTED, text)
|
|
349
|
+
return text
|
|
350
|
+
|
|
351
|
+
def redact_dict(self, d: dict[str, _Any]) -> dict[str, _Any]:
|
|
352
|
+
"""Recursively redact string values in a dict."""
|
|
353
|
+
result: dict[str, _Any] = {}
|
|
354
|
+
for k, v in d.items():
|
|
355
|
+
if isinstance(v, str):
|
|
356
|
+
result[k] = self.redact(v)
|
|
357
|
+
elif isinstance(v, dict):
|
|
358
|
+
result[k] = self.redact_dict(v)
|
|
359
|
+
elif isinstance(v, list):
|
|
360
|
+
result[k] = [
|
|
361
|
+
self.redact(item) if isinstance(item, str) else item
|
|
362
|
+
for item in v
|
|
363
|
+
]
|
|
364
|
+
else:
|
|
365
|
+
result[k] = v
|
|
366
|
+
return result
|
|
367
|
+
|
|
368
|
+
def redact_json(self, json_str: str) -> str:
|
|
369
|
+
"""Redact a JSON string by parsing, redacting, and re-serialising."""
|
|
370
|
+
try:
|
|
371
|
+
obj = _json.loads(json_str)
|
|
372
|
+
if isinstance(obj, dict):
|
|
373
|
+
return _json.dumps(self.redact_dict(obj))
|
|
374
|
+
elif isinstance(obj, str):
|
|
375
|
+
return _json.dumps(self.redact(obj))
|
|
376
|
+
except (_json.JSONDecodeError, TypeError):
|
|
377
|
+
pass
|
|
378
|
+
# Fallback: redact the raw string
|
|
379
|
+
return self.redact(json_str)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
# Module-level singleton — import redact/redact_dict directly
|
|
383
|
+
_redaction_engine = RedactionEngine()
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def redact(text: str) -> str:
|
|
387
|
+
"""Redact secrets from a plain text string. Module-level convenience wrapper."""
|
|
388
|
+
return _redaction_engine.redact(text)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def redact_dict(d: dict[str, _Any]) -> dict[str, _Any]:
|
|
392
|
+
"""Redact secrets from a dict recursively. Module-level convenience wrapper."""
|
|
393
|
+
return _redaction_engine.redact_dict(d)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def redact_json(json_str: str) -> str:
|
|
397
|
+
"""Redact secrets from a JSON string. Module-level convenience wrapper."""
|
|
398
|
+
return _redaction_engine.redact_json(json_str)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
# ---------------------------------------------------------------------------
|
|
402
|
+
# Field safety classifiers (for type annotation use)
|
|
403
|
+
# ---------------------------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
# Annotate dataclass/TypedDict fields with these to document safety level.
|
|
406
|
+
# Any field with InternalOnly must be stripped before remote/export dispatch.
|
|
407
|
+
SafeForRemote = _Annotated[str, "safe_for_remote"] # can stream to phone UI
|
|
408
|
+
SafeForExport = _Annotated[str, "safe_for_export"] # can write to audit/SIEM export
|
|
409
|
+
InternalOnly = _Annotated[str, "internal_only"] # never leave this process
|
src/server/__init__.py
ADDED