gemcode 0.3.30__py3-none-any.whl → 0.3.32__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.
- gemcode/agent.py +103 -6
- gemcode/callbacks.py +22 -0
- gemcode/cli.py +4 -14
- gemcode/mcp_loader.py +103 -13
- gemcode/openapi_loader.py +127 -0
- gemcode/permissions.py +204 -3
- gemcode/repl_commands.py +36 -9
- gemcode/repl_slash.py +236 -4
- gemcode/review_agent.py +142 -0
- gemcode/session_runtime.py +21 -0
- gemcode/session_store.py +195 -0
- gemcode/tools/__init__.py +3 -1
- gemcode/tools/bash.py +146 -0
- gemcode/tools/notes.py +106 -0
- gemcode/tui/input_handler.py +5 -0
- gemcode/tui/scrollback.py +13 -0
- {gemcode-0.3.30.dist-info → gemcode-0.3.32.dist-info}/METADATA +1 -1
- {gemcode-0.3.30.dist-info → gemcode-0.3.32.dist-info}/RECORD +22 -18
- {gemcode-0.3.30.dist-info → gemcode-0.3.32.dist-info}/WHEEL +0 -0
- {gemcode-0.3.30.dist-info → gemcode-0.3.32.dist-info}/entry_points.txt +0 -0
- {gemcode-0.3.30.dist-info → gemcode-0.3.32.dist-info}/licenses/LICENSE +0 -0
- {gemcode-0.3.30.dist-info → gemcode-0.3.32.dist-info}/top_level.txt +0 -0
gemcode/agent.py
CHANGED
|
@@ -49,11 +49,77 @@ def _chain_before_model_callbacks(*callbacks):
|
|
|
49
49
|
|
|
50
50
|
|
|
51
51
|
def _load_gemini_md(project_root: Path) -> str:
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
52
|
+
"""
|
|
53
|
+
Load GEMINI.md / .gemcode/NOTES.md from a Claude Code–style hierarchy.
|
|
54
|
+
|
|
55
|
+
Priority (later entries override earlier ones, all are concatenated):
|
|
56
|
+
1. ~/.gemcode/GEMINI.md — user-global instructions (all projects)
|
|
57
|
+
2. Walk UP from project_root: each directory's GEMINI.md / .gemcode/GEMINI.md
|
|
58
|
+
(org-level files at higher dirs, project-level at project_root)
|
|
59
|
+
3. project_root/GEMINI.md — the primary project instructions
|
|
60
|
+
4. project_root/.gemcode/GEMINI.md — alternative location
|
|
61
|
+
5. project_root/.gemcode/notes.md — agent auto-generated notes (read-only context)
|
|
62
|
+
|
|
63
|
+
Max total: 80,000 chars. Each file is capped at 30,000 chars.
|
|
64
|
+
HTML comments (<!-- ... -->) are stripped before injection (saves tokens).
|
|
65
|
+
"""
|
|
66
|
+
import re
|
|
67
|
+
|
|
68
|
+
_NAMES = ("GEMINI.md", "gemini.md", ".gemcode/GEMINI.md")
|
|
69
|
+
_FILE_CAP = 30_000
|
|
70
|
+
_TOTAL_CAP = 80_000
|
|
71
|
+
_COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
|
|
72
|
+
|
|
73
|
+
def _read(p: Path) -> str:
|
|
74
|
+
if not p.is_file():
|
|
75
|
+
return ""
|
|
76
|
+
try:
|
|
77
|
+
raw = p.read_text(encoding="utf-8", errors="replace")[:_FILE_CAP]
|
|
78
|
+
# Strip HTML comments (like Claude Code does — saves tokens)
|
|
79
|
+
return _COMMENT_RE.sub("", raw).strip()
|
|
80
|
+
except OSError:
|
|
81
|
+
return ""
|
|
82
|
+
|
|
83
|
+
seen: set[Path] = set()
|
|
84
|
+
sections: list[str] = []
|
|
85
|
+
|
|
86
|
+
def _add(p: Path, label: str | None = None) -> None:
|
|
87
|
+
resolved = p.resolve()
|
|
88
|
+
if resolved in seen:
|
|
89
|
+
return
|
|
90
|
+
seen.add(resolved)
|
|
91
|
+
text = _read(p)
|
|
92
|
+
if text:
|
|
93
|
+
sections.append(f"<!-- {label or str(p)} -->\n{text}" if label else text)
|
|
94
|
+
|
|
95
|
+
# 1. User-global: ~/.gemcode/GEMINI.md
|
|
96
|
+
user_global = Path.home() / ".gemcode" / "GEMINI.md"
|
|
97
|
+
_add(user_global, "user-global (~/.gemcode/GEMINI.md)")
|
|
98
|
+
|
|
99
|
+
# 2. Walk UP from project_root to filesystem root — loads org / monorepo-level instructions
|
|
100
|
+
walk = project_root.resolve()
|
|
101
|
+
ancestors = []
|
|
102
|
+
while walk != walk.parent:
|
|
103
|
+
walk = walk.parent
|
|
104
|
+
if walk == Path.home() or walk == Path("/"):
|
|
105
|
+
break
|
|
106
|
+
ancestors.append(walk)
|
|
107
|
+
# Walk outer→inner (org first, closer dirs later — later = higher priority)
|
|
108
|
+
for ancestor in reversed(ancestors):
|
|
109
|
+
for name in _NAMES:
|
|
110
|
+
_add(ancestor / name)
|
|
111
|
+
|
|
112
|
+
# 3+4. Project-root level instructions (primary location)
|
|
113
|
+
for name in ("GEMINI.md", "gemini.md", ".gemcode/GEMINI.md", ".gemcode/gemini.md"):
|
|
114
|
+
_add(project_root / name)
|
|
115
|
+
|
|
116
|
+
# 5. Agent-generated notes (informational context, not instructions)
|
|
117
|
+
notes = project_root / ".gemcode" / "notes.md"
|
|
118
|
+
if notes.is_file():
|
|
119
|
+
_add(notes, "agent notes (.gemcode/notes.md)")
|
|
120
|
+
|
|
121
|
+
combined = "\n\n---\n\n".join(s for s in sections if s.strip())
|
|
122
|
+
return combined[:_TOTAL_CAP]
|
|
57
123
|
|
|
58
124
|
|
|
59
125
|
def _build_runtime_facts(cfg: GemCodeConfig) -> str:
|
|
@@ -383,6 +449,13 @@ You have native deep thinking capability — use it actively:
|
|
|
383
449
|
- For **dev servers**: `bash("npm run dev", background=True, cwd_subdir="frontend")`
|
|
384
450
|
- For **subfolders**: `bash("cargo build --release", cwd_subdir="backend")`
|
|
385
451
|
|
|
452
|
+
- **`bash_stream`** — streaming version of bash: yields stdout line-by-line in real-time. Use for long-running commands where the user wants to see live progress:
|
|
453
|
+
- `bash_stream("pytest -v tests/")` — see each test result as it runs
|
|
454
|
+
- `bash_stream("npm run build")` — watch build progress in real-time
|
|
455
|
+
- `bash_stream("pip install -r requirements.txt")` — see install progress
|
|
456
|
+
- `bash_stream("tail -f logs/app.log", timeout_seconds=60)` — live log watching
|
|
457
|
+
- Prefer `bash_stream` over `bash` whenever the command takes > 5 seconds and progress visibility matters
|
|
458
|
+
|
|
386
459
|
- **`run_command`** — simple single-executable calls without shell features:
|
|
387
460
|
- `run_command("npm", args=["install", "--legacy-peer-deps"])` — clean npm install
|
|
388
461
|
- `run_command("python3", args=["-m", "pytest", "--version"])` — version check
|
|
@@ -527,7 +600,23 @@ For tasks where quality matters:
|
|
|
527
600
|
- Prefer small, testable, accurate changes over broad rewrites.
|
|
528
601
|
|
|
529
602
|
## Workspace scope
|
|
530
|
-
All file tools use paths **relative to the project root** (where GemCode was started). The root may be the home folder — subfolders like `Desktop`, `Desktop/code`, `Documents` are inside the sandbox. Call `list_directory("Desktop")` or `glob_files("**/*name*.ts")` instead of assuming access is blocked. Only treat access as denied when a tool returns an explicit `error`.
|
|
603
|
+
All file tools use paths **relative to the project root** (where GemCode was started). The root may be the home folder — subfolders like `Desktop`, `Desktop/code`, `Documents` are inside the sandbox. Call `list_directory("Desktop")` or `glob_files("**/*name*.ts")` instead of assuming access is blocked. Only treat access as denied when a tool returns an explicit `error`.
|
|
604
|
+
|
|
605
|
+
## Agent notes (.gemcode/notes.md)
|
|
606
|
+
You have two tools to persist project insights across sessions, like Claude Code's auto-memory:
|
|
607
|
+
|
|
608
|
+
- **`append_project_note(note)`** — write a note to `.gemcode/notes.md`. Use this proactively when you discover something worth remembering:
|
|
609
|
+
- Build/test/lint commands you discover ("Build: `npm run build` — requires Node 20")
|
|
610
|
+
- Key file locations ("Auth middleware: `src/middleware/auth.ts`")
|
|
611
|
+
- Known issues or patterns ("DB migrations: always run `prisma db push` after schema changes")
|
|
612
|
+
- User workflow preferences ("User prefers running tests before committing")
|
|
613
|
+
- Architecture decisions or tricky patterns
|
|
614
|
+
|
|
615
|
+
Call this **immediately** when you discover something useful — not just at the end of tasks.
|
|
616
|
+
Notes are loaded at session start so future sessions inherit this knowledge.
|
|
617
|
+
|
|
618
|
+
- **`read_project_notes()`** — read current notes before starting a new project task.
|
|
619
|
+
If notes exist and you haven't read them yet, read them first to avoid re-discovering known information."""
|
|
531
620
|
|
|
532
621
|
# Inject capability-specific strategy sections only when those caps are on.
|
|
533
622
|
if getattr(cfg, "enable_computer_use", False):
|
|
@@ -590,6 +679,14 @@ def build_root_agent(
|
|
|
590
679
|
except Exception:
|
|
591
680
|
pass
|
|
592
681
|
|
|
682
|
+
# Agent auto-notes: write project insights to .gemcode/notes.md (Claude Code MEMORY.md equivalent)
|
|
683
|
+
try:
|
|
684
|
+
from gemcode.tools.notes import build_notes_tools
|
|
685
|
+
notes_tools = build_notes_tools(cfg.project_root)
|
|
686
|
+
tools = [*tools, *notes_tools]
|
|
687
|
+
except Exception:
|
|
688
|
+
pass
|
|
689
|
+
|
|
593
690
|
if extra_tools:
|
|
594
691
|
tools = [*tools, *extra_tools]
|
|
595
692
|
|
gemcode/callbacks.py
CHANGED
|
@@ -157,6 +157,24 @@ def make_before_tool_callback(cfg: GemCodeConfig):
|
|
|
157
157
|
except Exception:
|
|
158
158
|
pass
|
|
159
159
|
|
|
160
|
+
# ── Permission rules (.gemcode/settings.json allow/deny) ──────────────
|
|
161
|
+
# Evaluated before the normal permission flow so explicit allow/deny rules
|
|
162
|
+
# override cfg.permission_mode and interactive prompts.
|
|
163
|
+
try:
|
|
164
|
+
from gemcode.permissions import check_rules
|
|
165
|
+
rule_result = check_rules(name, args or {}, cfg.project_root)
|
|
166
|
+
if rule_result == "deny":
|
|
167
|
+
return {
|
|
168
|
+
"error": f"Tool call blocked by .gemcode/settings.json deny rule for '{name}'.",
|
|
169
|
+
"error_kind": _ERROR_KIND_PERMISSION_DENIED,
|
|
170
|
+
}
|
|
171
|
+
if rule_result == "allow":
|
|
172
|
+
# Explicit allow — skip the normal permission prompt entirely
|
|
173
|
+
# but still run post-tool hook (logging/audit).
|
|
174
|
+
pass # fall through to tool execution
|
|
175
|
+
except Exception:
|
|
176
|
+
rule_result = None
|
|
177
|
+
|
|
160
178
|
streak = 0
|
|
161
179
|
if tool_context is not None:
|
|
162
180
|
try:
|
|
@@ -181,6 +199,10 @@ def make_before_tool_callback(cfg: GemCodeConfig):
|
|
|
181
199
|
"error_kind": _ERROR_KIND_CIRCUIT_BREAKER,
|
|
182
200
|
}
|
|
183
201
|
|
|
202
|
+
# If permission rules explicitly allowed this tool call, skip the gate entirely.
|
|
203
|
+
if rule_result == "allow" and (name in MUTATING_TOOLS or is_computer_tool):
|
|
204
|
+
return None # allow without prompting
|
|
205
|
+
|
|
184
206
|
if name in MUTATING_TOOLS or is_computer_tool:
|
|
185
207
|
if cfg.permission_mode == "strict":
|
|
186
208
|
if is_computer_tool:
|
gemcode/cli.py
CHANGED
|
@@ -151,13 +151,8 @@ async def _run_prompt(
|
|
|
151
151
|
_maybe_prompt_google_api_key()
|
|
152
152
|
require_google_api_key()
|
|
153
153
|
_initialize_gemcode_project(cfg)
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
from gemcode.mcp_loader import load_mcp_toolsets
|
|
157
|
-
|
|
158
|
-
extra = load_mcp_toolsets(cfg)
|
|
159
|
-
|
|
160
|
-
runner = create_runner(cfg, extra_tools=extra or None)
|
|
154
|
+
# MCP and OpenAPI toolsets are now loaded inside create_runner() directly.
|
|
155
|
+
runner = create_runner(cfg, extra_tools=None)
|
|
161
156
|
try:
|
|
162
157
|
collected = await run_turn(
|
|
163
158
|
runner,
|
|
@@ -184,13 +179,8 @@ async def _run_repl(cfg: GemCodeConfig, session_id: str, *, use_mcp: bool) -> No
|
|
|
184
179
|
require_google_api_key()
|
|
185
180
|
_initialize_gemcode_project(cfg)
|
|
186
181
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
from gemcode.mcp_loader import load_mcp_toolsets
|
|
190
|
-
|
|
191
|
-
extra = load_mcp_toolsets(cfg)
|
|
192
|
-
|
|
193
|
-
runner = create_runner(cfg, extra_tools=extra or None)
|
|
182
|
+
# MCP and OpenAPI toolsets are now loaded inside create_runner() directly.
|
|
183
|
+
runner = create_runner(cfg, extra_tools=None)
|
|
194
184
|
try:
|
|
195
185
|
# For CLI UX, show concise tool summaries (helps users see what ran).
|
|
196
186
|
if os.environ.get("GEMCODE_EMIT_TOOL_USE_SUMMARIES") is None:
|
gemcode/mcp_loader.py
CHANGED
|
@@ -1,27 +1,60 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Optional MCP toolsets from `.gemcode/mcp.json`.
|
|
3
3
|
|
|
4
|
+
Now supports all three ADK connection types — like Claude Code's MCP integration:
|
|
5
|
+
|
|
4
6
|
Schema (example):
|
|
5
7
|
{
|
|
6
8
|
"servers": [
|
|
7
9
|
{
|
|
8
|
-
"name": "
|
|
9
|
-
"stdio": { "command": "npx", "args": ["-y", "@
|
|
10
|
+
"name": "filesystem",
|
|
11
|
+
"stdio": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] }
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"name": "github",
|
|
15
|
+
"http": {
|
|
16
|
+
"url": "https://api.githubcopilot.com/mcp/",
|
|
17
|
+
"headers": { "Authorization": "Bearer ${GITHUB_TOKEN}" }
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"name": "notion",
|
|
22
|
+
"sse": { "url": "https://mcp.notion.com/sse" }
|
|
10
23
|
}
|
|
11
24
|
]
|
|
12
25
|
}
|
|
13
26
|
|
|
27
|
+
Connection types:
|
|
28
|
+
stdio — local subprocess (npx, python, etc.) — legacy, always worked
|
|
29
|
+
http — Streamable HTTP (remote servers, Cloud Run, Smithery.ai)
|
|
30
|
+
sse — Server-Sent Events (older remote servers; use http when possible)
|
|
31
|
+
|
|
32
|
+
Header values support ${ENV_VAR} substitution from environment.
|
|
33
|
+
|
|
14
34
|
Requires: pip install gemcode[mcp]
|
|
15
35
|
"""
|
|
16
36
|
|
|
17
37
|
from __future__ import annotations
|
|
18
38
|
|
|
19
39
|
import json
|
|
40
|
+
import os
|
|
41
|
+
import re
|
|
20
42
|
from pathlib import Path
|
|
21
43
|
from typing import Any
|
|
22
44
|
|
|
23
45
|
from gemcode.config import GemCodeConfig
|
|
24
46
|
|
|
47
|
+
_ENV_VAR_RE = re.compile(r"\$\{([A-Z_][A-Z0-9_]*)\}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _expand_env(value: str) -> str:
|
|
51
|
+
"""Expand ${VAR} placeholders from environment variables."""
|
|
52
|
+
return _ENV_VAR_RE.sub(lambda m: os.environ.get(m.group(1), m.group(0)), value)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _expand_headers(headers: dict) -> dict:
|
|
56
|
+
return {k: _expand_env(str(v)) for k, v in headers.items()}
|
|
57
|
+
|
|
25
58
|
|
|
26
59
|
def load_mcp_toolsets(cfg: GemCodeConfig) -> list:
|
|
27
60
|
path = cfg.project_root / ".gemcode" / "mcp.json"
|
|
@@ -40,18 +73,75 @@ def load_mcp_toolsets(cfg: GemCodeConfig) -> list:
|
|
|
40
73
|
|
|
41
74
|
servers = data.get("servers") or []
|
|
42
75
|
toolsets: list[Any] = []
|
|
76
|
+
|
|
43
77
|
for s in servers:
|
|
44
|
-
stdio = s.get("stdio") or {}
|
|
45
|
-
cmd = stdio.get("command")
|
|
46
|
-
args = stdio.get("args") or []
|
|
47
|
-
if not cmd:
|
|
48
|
-
continue
|
|
49
|
-
params = StdioServerParameters(command=cmd, args=args)
|
|
50
78
|
prefix = s.get("name") or "mcp"
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
79
|
+
tool_filter: list[str] | None = s.get("tools") or None # optional allowlist
|
|
80
|
+
|
|
81
|
+
# ── stdio (local subprocess) ─────────────────────────────────────────
|
|
82
|
+
if "stdio" in s:
|
|
83
|
+
stdio = s["stdio"]
|
|
84
|
+
cmd = stdio.get("command")
|
|
85
|
+
args = stdio.get("args") or []
|
|
86
|
+
env_extra = {k: _expand_env(str(v)) for k, v in (stdio.get("env") or {}).items()}
|
|
87
|
+
if not cmd:
|
|
88
|
+
continue
|
|
89
|
+
params = StdioServerParameters(
|
|
90
|
+
command=cmd,
|
|
91
|
+
args=args,
|
|
92
|
+
env={**os.environ, **env_extra} if env_extra else None,
|
|
55
93
|
)
|
|
56
|
-
|
|
94
|
+
kw: dict[str, Any] = dict(connection_params=params, tool_name_prefix=prefix)
|
|
95
|
+
if tool_filter:
|
|
96
|
+
kw["tool_filter"] = tool_filter
|
|
97
|
+
toolsets.append(McpToolset(**kw))
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
# ── http (Streamable HTTP — modern, Cloud Run-friendly) ──────────────
|
|
101
|
+
if "http" in s:
|
|
102
|
+
http_cfg = s["http"]
|
|
103
|
+
url = _expand_env(http_cfg.get("url", ""))
|
|
104
|
+
if not url:
|
|
105
|
+
continue
|
|
106
|
+
headers = _expand_headers(http_cfg.get("headers") or {})
|
|
107
|
+
try:
|
|
108
|
+
from google.adk.tools.mcp_tool.mcp_session_manager import (
|
|
109
|
+
StreamableHTTPConnectionParams,
|
|
110
|
+
)
|
|
111
|
+
params_http = StreamableHTTPConnectionParams(url=url, headers=headers or None)
|
|
112
|
+
kw = dict(connection_params=params_http, tool_name_prefix=prefix)
|
|
113
|
+
if tool_filter:
|
|
114
|
+
kw["tool_filter"] = tool_filter
|
|
115
|
+
toolsets.append(McpToolset(**kw))
|
|
116
|
+
except ImportError:
|
|
117
|
+
# Fallback: try SseConnectionParams for older ADK builds
|
|
118
|
+
try:
|
|
119
|
+
from google.adk.tools.mcp_tool.mcp_session_manager import SseConnectionParams
|
|
120
|
+
params_sse_fb = SseConnectionParams(url=url, headers=headers or None)
|
|
121
|
+
kw = dict(connection_params=params_sse_fb, tool_name_prefix=prefix)
|
|
122
|
+
if tool_filter:
|
|
123
|
+
kw["tool_filter"] = tool_filter
|
|
124
|
+
toolsets.append(McpToolset(**kw))
|
|
125
|
+
except ImportError:
|
|
126
|
+
pass # ADK version doesn't support remote MCP — skip silently
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
# ── sse (Server-Sent Events — older remote servers) ──────────────────
|
|
130
|
+
if "sse" in s:
|
|
131
|
+
sse_cfg = s["sse"]
|
|
132
|
+
url = _expand_env(sse_cfg.get("url", ""))
|
|
133
|
+
if not url:
|
|
134
|
+
continue
|
|
135
|
+
headers = _expand_headers(sse_cfg.get("headers") or {})
|
|
136
|
+
try:
|
|
137
|
+
from google.adk.tools.mcp_tool.mcp_session_manager import SseConnectionParams
|
|
138
|
+
params_sse = SseConnectionParams(url=url, headers=headers or None)
|
|
139
|
+
kw = dict(connection_params=params_sse, tool_name_prefix=prefix)
|
|
140
|
+
if tool_filter:
|
|
141
|
+
kw["tool_filter"] = tool_filter
|
|
142
|
+
toolsets.append(McpToolset(**kw))
|
|
143
|
+
except ImportError:
|
|
144
|
+
pass # ADK version too old — skip
|
|
145
|
+
continue
|
|
146
|
+
|
|
57
147
|
return toolsets
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Auto-loads OpenAPI specs from `.gemcode/openapi/` as ADK OpenAPIToolset instances.
|
|
3
|
+
|
|
4
|
+
Inspired by the ADK community pattern: drop an OpenAPI spec in a directory and
|
|
5
|
+
the agent automatically gets REST API tools for it — no manual tool wiring needed.
|
|
6
|
+
|
|
7
|
+
Supported formats: .yaml, .yml, .json
|
|
8
|
+
|
|
9
|
+
Example:
|
|
10
|
+
.gemcode/openapi/
|
|
11
|
+
├── github.yaml → tools: mcp__github__list_repos, mcp__github__create_pr, …
|
|
12
|
+
├── sentry.json → tools: mcp__sentry__list_issues, …
|
|
13
|
+
└── internal_api.yaml → tools from your company's internal REST API
|
|
14
|
+
|
|
15
|
+
The filename (without extension) is used as the tool name prefix.
|
|
16
|
+
So `github.yaml` → tools prefixed with `github_`.
|
|
17
|
+
|
|
18
|
+
Auth:
|
|
19
|
+
Each spec file can have a matching `.auth` sidecar file with JSON:
|
|
20
|
+
{
|
|
21
|
+
"type": "api_key", // "api_key" | "bearer" | "basic" | "oauth2"
|
|
22
|
+
"header": "X-API-Key",
|
|
23
|
+
"value": "${GITHUB_TOKEN}" // ${ENV_VAR} is expanded from environment
|
|
24
|
+
}
|
|
25
|
+
For oauth2: {"type": "oauth2", "token_url": "...", "client_id": "...", "client_secret": "${VAR}"}
|
|
26
|
+
|
|
27
|
+
Requires: pip install google-adk (OpenAPIToolset is included in the main package)
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import logging
|
|
34
|
+
import os
|
|
35
|
+
import re
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
log = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
_ENV_VAR_RE = re.compile(r"\$\{([A-Z_][A-Z0-9_]*)\}")
|
|
42
|
+
_OPENAPI_DIR = ".gemcode/openapi"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _expand(value: str) -> str:
|
|
46
|
+
return _ENV_VAR_RE.sub(lambda m: os.environ.get(m.group(1), m.group(0)), value)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _load_auth(spec_path: Path):
|
|
50
|
+
"""Try to load auth config from <spec_stem>.auth next to the spec file."""
|
|
51
|
+
auth_path = spec_path.with_suffix(".auth")
|
|
52
|
+
if not auth_path.is_file():
|
|
53
|
+
auth_path = spec_path.parent / (spec_path.stem + ".auth.json")
|
|
54
|
+
if not auth_path.is_file():
|
|
55
|
+
return None, None
|
|
56
|
+
try:
|
|
57
|
+
auth_data = json.loads(auth_path.read_text())
|
|
58
|
+
except Exception:
|
|
59
|
+
return None, None
|
|
60
|
+
|
|
61
|
+
auth_type = auth_data.get("type", "").lower()
|
|
62
|
+
try:
|
|
63
|
+
from google.adk.auth import AuthCredential, AuthCredentialTypes
|
|
64
|
+
if auth_type == "api_key":
|
|
65
|
+
return None, AuthCredential(
|
|
66
|
+
auth_type=AuthCredentialTypes.API_KEY,
|
|
67
|
+
api_key=_expand(auth_data.get("value", "")),
|
|
68
|
+
)
|
|
69
|
+
if auth_type in ("bearer", "token"):
|
|
70
|
+
return None, AuthCredential(
|
|
71
|
+
auth_type=AuthCredentialTypes.HTTP,
|
|
72
|
+
http={"scheme": "bearer", "token": _expand(auth_data.get("value", ""))},
|
|
73
|
+
)
|
|
74
|
+
except ImportError:
|
|
75
|
+
pass
|
|
76
|
+
return None, None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def load_openapi_toolsets(project_root: Path) -> list[Any]:
|
|
80
|
+
"""
|
|
81
|
+
Scan .gemcode/openapi/ for spec files and return OpenAPIToolset instances.
|
|
82
|
+
|
|
83
|
+
Returns an empty list if the directory doesn't exist or no valid specs are found.
|
|
84
|
+
Errors loading individual specs are logged but don't crash GemCode.
|
|
85
|
+
"""
|
|
86
|
+
openapi_dir = project_root / _OPENAPI_DIR
|
|
87
|
+
if not openapi_dir.is_dir():
|
|
88
|
+
return []
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
from google.adk.tools.openapi_tool.openapi_spec_parser.openapi_toolset import (
|
|
92
|
+
OpenAPIToolset,
|
|
93
|
+
)
|
|
94
|
+
except ImportError:
|
|
95
|
+
log.debug("[openapi] OpenAPIToolset not available in this ADK version — skipping")
|
|
96
|
+
return []
|
|
97
|
+
|
|
98
|
+
toolsets: list[Any] = []
|
|
99
|
+
|
|
100
|
+
for spec_file in sorted(openapi_dir.iterdir()):
|
|
101
|
+
if spec_file.suffix.lower() not in (".yaml", ".yml", ".json"):
|
|
102
|
+
continue
|
|
103
|
+
if spec_file.stem.endswith(".auth"):
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
prefix = spec_file.stem # e.g. "github" from "github.yaml"
|
|
107
|
+
fmt = "json" if spec_file.suffix.lower() == ".json" else "yaml"
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
spec_str = spec_file.read_text(encoding="utf-8", errors="replace")
|
|
111
|
+
except OSError as e:
|
|
112
|
+
log.warning("[openapi] Could not read %s: %s", spec_file.name, e)
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
_, auth_credential = _load_auth(spec_file)
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
kwargs: dict[str, Any] = dict(spec_str=spec_str, spec_str_type=fmt)
|
|
119
|
+
if auth_credential is not None:
|
|
120
|
+
kwargs["auth_credential"] = auth_credential
|
|
121
|
+
toolset = OpenAPIToolset(**kwargs)
|
|
122
|
+
toolsets.append(toolset)
|
|
123
|
+
log.info("[openapi] Loaded spec '%s' from %s", prefix, spec_file.name)
|
|
124
|
+
except Exception as exc:
|
|
125
|
+
log.warning("[openapi] Failed to load %s: %s", spec_file.name, exc)
|
|
126
|
+
|
|
127
|
+
return toolsets
|
gemcode/permissions.py
CHANGED
|
@@ -1,5 +1,206 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""
|
|
2
|
+
Permission rules engine — inspired by Claude Code's allow/deny pattern system.
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
Users define rules in `.gemcode/settings.json` (project) or `~/.gemcode/settings.json` (global).
|
|
5
|
+
Rules are evaluated for every tool call: deny first, then allow, then default.
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
Schema:
|
|
8
|
+
{
|
|
9
|
+
"permissions": {
|
|
10
|
+
"allow": [
|
|
11
|
+
"bash(git *)",
|
|
12
|
+
"bash(npm run *)",
|
|
13
|
+
"bash(pytest *)",
|
|
14
|
+
"read_file(*)",
|
|
15
|
+
"write_file(src/**)"
|
|
16
|
+
],
|
|
17
|
+
"deny": [
|
|
18
|
+
"bash(rm -rf *)",
|
|
19
|
+
"bash(curl *)",
|
|
20
|
+
"bash(wget *)",
|
|
21
|
+
"read_file(.env)",
|
|
22
|
+
"read_file(.env.*)",
|
|
23
|
+
"write_file(secrets/**)"
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
Pattern syntax (same as Claude Code's permission rules):
|
|
29
|
+
- "bash" — matches ALL bash calls
|
|
30
|
+
- "bash(*)" — same as above
|
|
31
|
+
- "bash(git *)" — bash calls whose command starts with "git "
|
|
32
|
+
- "write_file(src/**)" — write_file calls where path matches src/**
|
|
33
|
+
- "read_file(.env)" — exact path match for .env
|
|
34
|
+
- "*" — matches all tool calls
|
|
35
|
+
|
|
36
|
+
Evaluation order: deny → allow → default (ask/yes_to_all/strict from cfg)
|
|
37
|
+
First matching rule wins.
|
|
38
|
+
|
|
39
|
+
Files (merged in order — later rules take precedence):
|
|
40
|
+
1. ~/.gemcode/settings.json (user-global)
|
|
41
|
+
2. .gemcode/settings.json (project-specific, can override global)
|
|
42
|
+
|
|
43
|
+
Reloaded on every tool call (file is stat-cached to avoid repeated disk reads).
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
import fnmatch
|
|
49
|
+
import json
|
|
50
|
+
import os
|
|
51
|
+
import time
|
|
52
|
+
from pathlib import Path
|
|
53
|
+
from typing import Any
|
|
54
|
+
|
|
55
|
+
# ── Cache so we don't re-read files every millisecond ────────────────────────
|
|
56
|
+
_file_cache: dict[str, tuple[float, float, list]] = {} # path → (mtime, stat_time, rules)
|
|
57
|
+
_CACHE_TTL = 2.0 # seconds
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _read_settings_rules(path: Path) -> list[dict]:
|
|
61
|
+
"""Read and cache permission rules from a settings file."""
|
|
62
|
+
path_str = str(path)
|
|
63
|
+
now = time.monotonic()
|
|
64
|
+
if path_str in _file_cache:
|
|
65
|
+
cached_mtime, cached_time, cached_rules = _file_cache[path_str]
|
|
66
|
+
if now - cached_time < _CACHE_TTL:
|
|
67
|
+
return cached_rules
|
|
68
|
+
|
|
69
|
+
if not path.is_file():
|
|
70
|
+
_file_cache[path_str] = (0.0, now, [])
|
|
71
|
+
return []
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
mtime = path.stat().st_mtime
|
|
75
|
+
if path_str in _file_cache and _file_cache[path_str][0] == mtime:
|
|
76
|
+
# File hasn't changed — just refresh the TTL
|
|
77
|
+
old = _file_cache[path_str]
|
|
78
|
+
_file_cache[path_str] = (old[0], now, old[2])
|
|
79
|
+
return old[2]
|
|
80
|
+
|
|
81
|
+
data = json.loads(path.read_text(encoding="utf-8", errors="replace"))
|
|
82
|
+
perms = data.get("permissions") or {}
|
|
83
|
+
rules: list[dict] = []
|
|
84
|
+
for pattern in perms.get("deny") or []:
|
|
85
|
+
rules.append({"action": "deny", "pattern": str(pattern)})
|
|
86
|
+
for pattern in perms.get("allow") or []:
|
|
87
|
+
rules.append({"action": "allow", "pattern": str(pattern)})
|
|
88
|
+
_file_cache[path_str] = (mtime, now, rules)
|
|
89
|
+
return rules
|
|
90
|
+
except Exception:
|
|
91
|
+
_file_cache[path_str] = (0.0, now, [])
|
|
92
|
+
return []
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def load_rules(project_root: Path) -> list[dict]:
|
|
96
|
+
"""
|
|
97
|
+
Load merged rules from user-global and project settings.
|
|
98
|
+
Order: global first (lower priority), project second (higher priority / overrides).
|
|
99
|
+
"""
|
|
100
|
+
global_rules = _read_settings_rules(Path.home() / ".gemcode" / "settings.json")
|
|
101
|
+
project_rules = _read_settings_rules(project_root / ".gemcode" / "settings.json")
|
|
102
|
+
# Deny rules always evaluated first regardless of file order, so combine and re-sort
|
|
103
|
+
all_rules = global_rules + project_rules
|
|
104
|
+
# Stable sort: deny rules before allow rules (deny has priority)
|
|
105
|
+
deny_rules = [r for r in all_rules if r["action"] == "deny"]
|
|
106
|
+
allow_rules = [r for r in all_rules if r["action"] == "allow"]
|
|
107
|
+
return deny_rules + allow_rules
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _parse_pattern(pattern: str) -> tuple[str, str | None]:
|
|
111
|
+
"""
|
|
112
|
+
Parse a rule pattern into (tool_glob, arg_glob | None).
|
|
113
|
+
|
|
114
|
+
Examples:
|
|
115
|
+
"bash" → ("bash", None)
|
|
116
|
+
"bash(*)" → ("bash", "*")
|
|
117
|
+
"bash(git *)" → ("bash", "git *")
|
|
118
|
+
"write_file(src/*)→ ("write_file", "src/*")
|
|
119
|
+
"*" → ("*", None)
|
|
120
|
+
"""
|
|
121
|
+
pattern = pattern.strip()
|
|
122
|
+
paren_open = pattern.find("(")
|
|
123
|
+
if paren_open == -1:
|
|
124
|
+
return (pattern, None)
|
|
125
|
+
tool_glob = pattern[:paren_open].strip()
|
|
126
|
+
arg_part = pattern[paren_open + 1:]
|
|
127
|
+
if arg_part.endswith(")"):
|
|
128
|
+
arg_part = arg_part[:-1]
|
|
129
|
+
return (tool_glob, arg_part.strip() or None)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _get_primary_arg(tool_name: str, args: dict[str, Any]) -> str:
|
|
133
|
+
"""
|
|
134
|
+
Extract the primary argument to match against the pattern's arg_glob.
|
|
135
|
+
For bash: the command string.
|
|
136
|
+
For file tools: the path argument.
|
|
137
|
+
For everything else: concatenate all string args.
|
|
138
|
+
"""
|
|
139
|
+
if tool_name in ("bash", "run_command"):
|
|
140
|
+
return (
|
|
141
|
+
args.get("command") or
|
|
142
|
+
args.get("cmd") or
|
|
143
|
+
args.get("args", [""])[0] if isinstance(args.get("args"), list) else args.get("args", "")
|
|
144
|
+
) or ""
|
|
145
|
+
# File tools — use first path-like arg
|
|
146
|
+
for key in ("path", "file_path", "file", "filename", "dest", "src"):
|
|
147
|
+
val = args.get(key)
|
|
148
|
+
if val and isinstance(val, str):
|
|
149
|
+
return val
|
|
150
|
+
# Fallback: join all string values
|
|
151
|
+
return " ".join(str(v) for v in args.values() if isinstance(v, str))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def check_rules(
|
|
155
|
+
tool_name: str,
|
|
156
|
+
args: dict[str, Any],
|
|
157
|
+
project_root: Path,
|
|
158
|
+
) -> str | None:
|
|
159
|
+
"""
|
|
160
|
+
Evaluate permission rules for a tool call.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
"allow" — explicit allow rule matched → skip normal permission prompts
|
|
164
|
+
"deny" — explicit deny rule matched → block the tool call
|
|
165
|
+
None — no rule matched → use default behavior (cfg.permission_mode)
|
|
166
|
+
"""
|
|
167
|
+
rules = load_rules(project_root)
|
|
168
|
+
if not rules:
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
primary_arg = _get_primary_arg(tool_name, args)
|
|
172
|
+
|
|
173
|
+
for rule in rules:
|
|
174
|
+
tool_glob, arg_glob = _parse_pattern(rule["pattern"])
|
|
175
|
+
|
|
176
|
+
# Match tool name
|
|
177
|
+
if tool_glob != "*" and not fnmatch.fnmatch(tool_name, tool_glob):
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
# Match argument (if pattern specifies one)
|
|
181
|
+
if arg_glob is not None and arg_glob != "*":
|
|
182
|
+
if not fnmatch.fnmatch(primary_arg, arg_glob):
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
return rule["action"] # "allow" or "deny"
|
|
186
|
+
|
|
187
|
+
return None # no rule matched
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def describe_rules(project_root: Path) -> list[str]:
|
|
191
|
+
"""Human-readable rule listing for /permissions output."""
|
|
192
|
+
rules = load_rules(project_root)
|
|
193
|
+
if not rules:
|
|
194
|
+
return [" (no rules — using default permission_mode)"]
|
|
195
|
+
lines: list[str] = []
|
|
196
|
+
deny_rules = [r for r in rules if r["action"] == "deny"]
|
|
197
|
+
allow_rules = [r for r in rules if r["action"] == "allow"]
|
|
198
|
+
if deny_rules:
|
|
199
|
+
lines.append(" deny:")
|
|
200
|
+
for r in deny_rules:
|
|
201
|
+
lines.append(f" ✗ {r['pattern']}")
|
|
202
|
+
if allow_rules:
|
|
203
|
+
lines.append(" allow:")
|
|
204
|
+
for r in allow_rules:
|
|
205
|
+
lines.append(f" ✓ {r['pattern']}")
|
|
206
|
+
return lines
|