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 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
- for name in ("GEMINI.md", "gemini.md"):
53
- p = project_root / name
54
- if p.is_file():
55
- return p.read_text(encoding="utf-8", errors="replace")[:50_000]
56
- return ""
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
- extra: list = []
155
- if use_mcp:
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
- extra: list = []
188
- if use_mcp:
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": "docs",
9
- "stdio": { "command": "npx", "args": ["-y", "@some/mcp-server"] }
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
- toolsets.append(
52
- McpToolset(
53
- connection_params=params,
54
- tool_name_prefix=prefix,
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
- """Backward-compatible re-exports. Prefer `gemcode.callbacks` for new code."""
1
+ """
2
+ Permission rules engine — inspired by Claude Code's allow/deny pattern system.
2
3
 
3
- from gemcode.callbacks import make_before_tool_callback
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
- __all__ = ["make_before_tool_callback"]
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