ghostcode-canary 0.1.25-canary.0

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.
Files changed (50) hide show
  1. package/LICENSE +201 -0
  2. package/PAT.txt +1 -0
  3. package/README.md +95 -0
  4. package/cli.js +92 -0
  5. package/error/err1.txt +12 -0
  6. package/package.json +31 -0
  7. package/setup.js +67 -0
  8. package/src/ghostcode/__init__.py +3 -0
  9. package/src/ghostcode/__main__.py +3 -0
  10. package/src/ghostcode/_version.py +17 -0
  11. package/src/ghostcode/agents/__init__.py +20 -0
  12. package/src/ghostcode/agents/code.py +104 -0
  13. package/src/ghostcode/agents/explore.py +50 -0
  14. package/src/ghostcode/ai/__init__.py +0 -0
  15. package/src/ghostcode/ai/anthropic_client.py +8 -0
  16. package/src/ghostcode/ai/base.py +33 -0
  17. package/src/ghostcode/ai/factory.py +38 -0
  18. package/src/ghostcode/ai/groq_client.py +7 -0
  19. package/src/ghostcode/ai/nvidia_client.py +7 -0
  20. package/src/ghostcode/ai/ollama_client.py +7 -0
  21. package/src/ghostcode/ai/openai_client.py +7 -0
  22. package/src/ghostcode/ai/openai_compat.py +139 -0
  23. package/src/ghostcode/ai/opencode_go_client.py +7 -0
  24. package/src/ghostcode/ai/opencode_zen_client.py +7 -0
  25. package/src/ghostcode/ai/openrouter_client.py +7 -0
  26. package/src/ghostcode/ai/registry.py +169 -0
  27. package/src/ghostcode/app.py +148 -0
  28. package/src/ghostcode/config.py +24 -0
  29. package/src/ghostcode/core/__init__.py +5 -0
  30. package/src/ghostcode/core/commands.py +55 -0
  31. package/src/ghostcode/core/file_ops.py +127 -0
  32. package/src/ghostcode/core/hooks.py +122 -0
  33. package/src/ghostcode/core/memory.py +137 -0
  34. package/src/ghostcode/core/prompt.py +73 -0
  35. package/src/ghostcode/core/reminders.py +61 -0
  36. package/src/ghostcode/core/security.py +119 -0
  37. package/src/ghostcode/core/tasks.py +125 -0
  38. package/src/ghostcode/tools/__init__.py +2 -0
  39. package/src/ghostcode/tools/defs.py +325 -0
  40. package/src/ghostcode/tools/executor.py +261 -0
  41. package/src/ghostcode/tui/__init__.py +0 -0
  42. package/src/ghostcode/tui/app.py +12 -0
  43. package/src/ghostcode/tui/dialogs.py +241 -0
  44. package/src/ghostcode/tui/screens.py +799 -0
  45. package/src/ghostcode/tui/widgets.py +32 -0
  46. package/src/ghostcode/utils/__init__.py +0 -0
  47. package/src/ghostcode/utils/config_loader.py +102 -0
  48. package/src/ghostcode/utils/logger.py +16 -0
  49. package/src/pyproject.toml +18 -0
  50. package/token.txt +1 -0
@@ -0,0 +1,55 @@
1
+ import dataclasses
2
+ from collections.abc import Callable, Coroutine
3
+ from typing import Any
4
+
5
+
6
+ @dataclasses.dataclass
7
+ class Command:
8
+ name: str
9
+ description: str
10
+ handler: Callable[..., Coroutine[Any, Any, str | None]]
11
+ usage: str = ""
12
+ aliases: list[str] | None = None
13
+ category: str = "general"
14
+
15
+ def __post_init__(self):
16
+ if not self.usage:
17
+ self.usage = f"/{self.name}"
18
+
19
+
20
+ _command_registry: dict[str, Command] = {}
21
+
22
+
23
+ def register(cmd: Command):
24
+ _command_registry[cmd.name] = cmd
25
+ for alias in (cmd.aliases or []):
26
+ _command_registry[alias] = cmd
27
+
28
+
29
+ def get_command(name: str) -> Command | None:
30
+ return _command_registry.get(name.lstrip("/").lower().split()[0])
31
+
32
+
33
+ def list_commands(category: str | None = None) -> list[Command]:
34
+ cmds = list(_command_registry.values())
35
+ if category:
36
+ cmds = [c for c in cmds if c.category == category]
37
+ seen = {}
38
+ for c in cmds:
39
+ if c.name not in seen:
40
+ seen[c.name] = c
41
+ return list(seen.values())
42
+
43
+
44
+ def all_commands() -> dict[str, Command]:
45
+ deduped = {}
46
+ for name, cmd in _command_registry.items():
47
+ deduped[name] = cmd
48
+ return deduped
49
+
50
+
51
+ async def dispatch(cmd_name: str, args: list[str], ctx: dict | None = None) -> str | None:
52
+ cmd = get_command(cmd_name)
53
+ if not cmd:
54
+ return None
55
+ return await cmd.handler(args, ctx or {})
@@ -0,0 +1,127 @@
1
+ import re
2
+ from pathlib import Path
3
+
4
+ OUTPUT_DIR = "ghostcode_gen"
5
+
6
+
7
+ def _detect_name(code: str, lang: str) -> str:
8
+ for pat in [
9
+ r"^(?:def|class|async def)\s+(\w+)",
10
+ r"(?:def|class)\s+(\w+)",
11
+ r"(?:export\s+)?(?:function|const)\s+(\w+)",
12
+ r"^(?:fn|fun|func)\s+(\w+)",
13
+ r"^(?:public\s+)?(?:class|interface|trait)\s+(\w+)",
14
+ ]:
15
+ m = re.search(pat, code, re.MULTILINE)
16
+ if m:
17
+ return m.group(1)
18
+ return None
19
+
20
+
21
+ _EXT_MAP = {
22
+ "python": "py", "py": "py",
23
+ "javascript": "js", "js": "js", "node": "js",
24
+ "typescript": "ts", "ts": "ts",
25
+ "jsx": "jsx", "tsx": "tsx",
26
+ "go": "go", "golang": "go",
27
+ "rust": "rs", "rs": "rs",
28
+ "ruby": "rb", "rb": "rb",
29
+ "java": "java",
30
+ "kotlin": "kt", "kt": "kt",
31
+ "c": "c", "cpp": "cpp", "c++": "cpp",
32
+ "h": "h", "hpp": "hpp",
33
+ "html": "html", "htm": "html",
34
+ "css": "css", "scss": "scss", "sass": "scss",
35
+ "bash": "sh", "sh": "sh", "shell": "sh", "zsh": "sh",
36
+ "sql": "sql",
37
+ "yaml": "yaml", "yml": "yaml",
38
+ "json": "json",
39
+ "toml": "toml",
40
+ "markdown": "md", "md": "md",
41
+ "dockerfile": "Dockerfile",
42
+ "makefile": "Makefile",
43
+ "swift": "swift",
44
+ "php": "php",
45
+ "r": "r",
46
+ "lua": "lua",
47
+ }
48
+
49
+
50
+ def _ext_for_lang(lang: str) -> str:
51
+ lang = lang.lower().strip()
52
+ return _EXT_MAP.get(lang, lang)
53
+
54
+
55
+ _counter = 0
56
+
57
+
58
+ def _next_fallback(lang: str) -> str:
59
+ global _counter
60
+ _counter += 1
61
+ ext = _ext_for_lang(lang)
62
+ return f"code_{_counter}.{ext}"
63
+
64
+
65
+ def autosave_code_blocks(content: str) -> list[dict]:
66
+ blocks = extract_code_blocks(content)
67
+ saved = []
68
+ out_dir = Path(OUTPUT_DIR)
69
+ out_dir.mkdir(parents=True, exist_ok=True)
70
+
71
+ for i, (lang, code) in enumerate(blocks):
72
+ name = _detect_name(code, lang)
73
+ if name:
74
+ ext = _ext_for_lang(lang)
75
+ filename = f"{name}.{ext}" if ext != lang else name
76
+ else:
77
+ filename = _next_fallback(lang)
78
+
79
+ path = out_dir / filename
80
+
81
+ if path.exists():
82
+ stem = path.stem
83
+ path = out_dir / f"{stem}_{i+1}{path.suffix}"
84
+
85
+ path.write_text(code, encoding="utf-8")
86
+ saved.append({
87
+ "path": str(path),
88
+ "size": len(code),
89
+ "language": lang,
90
+ })
91
+
92
+ return saved
93
+
94
+
95
+ def safe_write(path: str, content: str, backup: bool = True) -> dict:
96
+ p = Path(path).expanduser().resolve()
97
+ if p.exists() and backup:
98
+ bak = p.with_suffix(p.suffix + ".bak")
99
+ import shutil
100
+ shutil.copy2(str(p), str(bak))
101
+ p.parent.mkdir(parents=True, exist_ok=True)
102
+ p.write_text(content, encoding="utf-8")
103
+ return {"path": str(p), "size": len(content)}
104
+
105
+
106
+ def read_file(path: str) -> str | None:
107
+ p = Path(path).expanduser().resolve()
108
+ if not p.exists():
109
+ return None
110
+ return p.read_text(encoding="utf-8")
111
+
112
+
113
+ def extract_code_blocks(text: str) -> list[tuple[str, str]]:
114
+ blocks = []
115
+ lines = text.splitlines()
116
+ i = 0
117
+ while i < len(lines):
118
+ if lines[i].strip().startswith("```"):
119
+ lang = lines[i].strip().removeprefix("```").strip()
120
+ code = []
121
+ i += 1
122
+ while i < len(lines) and not lines[i].strip().startswith("```"):
123
+ code.append(lines[i])
124
+ i += 1
125
+ blocks.append((lang, "\n".join(code)))
126
+ i += 1
127
+ return blocks
@@ -0,0 +1,122 @@
1
+ import json
2
+ import subprocess
3
+ import sys
4
+ from pathlib import Path
5
+
6
+
7
+ HOOKS_FILE = ".ghostcode/hooks.json"
8
+
9
+
10
+ class HookRegistry:
11
+ def __init__(self):
12
+ self._before: list[dict] = []
13
+ self._after: list[dict] = []
14
+ self._stop: dict | None = None
15
+ self._load()
16
+
17
+ def _load(self):
18
+ p = Path.cwd() / HOOKS_FILE
19
+ if not p.exists():
20
+ return
21
+ try:
22
+ data = json.loads(p.read_text())
23
+ self._before = data.get("before_tool", [])
24
+ self._after = data.get("after_tool", [])
25
+ self._stop = data.get("stop_hook", None)
26
+ except (json.JSONDecodeError, Exception):
27
+ pass
28
+
29
+ def _save(self):
30
+ p = Path.cwd() / HOOKS_FILE
31
+ p.parent.mkdir(parents=True, exist_ok=True)
32
+ p.write_text(json.dumps({
33
+ "before_tool": self._before,
34
+ "after_tool": self._after,
35
+ "stop_hook": self._stop,
36
+ }, indent=2))
37
+
38
+ def set_hook(self, hook_type: str, config: dict):
39
+ if hook_type == "before_tool":
40
+ self._before.append(config)
41
+ elif hook_type == "after_tool":
42
+ self._after.append(config)
43
+ elif hook_type == "stop_hook":
44
+ self._stop = config
45
+ self._save()
46
+
47
+ def remove_hook(self, hook_type: str, index: int = 0) -> bool:
48
+ if hook_type == "before_tool" and 0 <= index < len(self._before):
49
+ self._before.pop(index)
50
+ self._save()
51
+ return True
52
+ if hook_type == "after_tool" and 0 <= index < len(self._after):
53
+ self._after.pop(index)
54
+ self._save()
55
+ return True
56
+ if hook_type == "stop_hook" and self._stop:
57
+ self._stop = None
58
+ self._save()
59
+ return True
60
+ return False
61
+
62
+ def get_hooks(self, hook_type: str) -> list[dict]:
63
+ if hook_type == "before_tool":
64
+ return self._before
65
+ if hook_type == "after_tool":
66
+ return self._after
67
+ if hook_type == "stop_hook":
68
+ return [self._stop] if self._stop else []
69
+ return []
70
+
71
+ def run_before(self, tool_name: str, args: dict) -> tuple[bool, str]:
72
+ for hook in self._before:
73
+ condition = hook.get("condition", "")
74
+ if condition and condition not in json.dumps({"tool": tool_name, "args": args}):
75
+ continue
76
+ command = hook.get("command", "")
77
+ if command:
78
+ try:
79
+ result = subprocess.run(command, shell=True, capture_output=True, text=True, timeout=10)
80
+ if result.returncode != 0:
81
+ return False, result.stderr[:200]
82
+ except subprocess.TimeoutExpired:
83
+ return False, "Hook timed out"
84
+ except Exception as e:
85
+ return False, str(e)
86
+ return True, ""
87
+
88
+ def run_after(self, tool_name: str, args: dict, result: str) -> str:
89
+ for hook in self._after:
90
+ condition = hook.get("condition", "")
91
+ if condition and condition not in json.dumps({"tool": tool_name, "args": args}):
92
+ continue
93
+ command = hook.get("command", "")
94
+ if command:
95
+ try:
96
+ subprocess.run(command, shell=True, capture_output=True, text=True, timeout=10)
97
+ except Exception:
98
+ pass
99
+ return result
100
+
101
+ def check_stop(self) -> str | None:
102
+ if not self._stop:
103
+ return None
104
+ command = self._stop.get("command", "")
105
+ if command:
106
+ try:
107
+ result = subprocess.run(command, shell=True, capture_output=True, text=True, timeout=5)
108
+ if result.returncode == 0:
109
+ return self._stop.get("message", "Stop hook triggered")
110
+ except Exception:
111
+ pass
112
+ return None
113
+
114
+ def summary(self) -> dict:
115
+ return {
116
+ "before_tool": len(self._before),
117
+ "after_tool": len(self._after),
118
+ "stop_hook": 1 if self._stop else 0,
119
+ }
120
+
121
+
122
+ hook_registry = HookRegistry()
@@ -0,0 +1,137 @@
1
+ from pathlib import Path
2
+ import yaml
3
+ import re
4
+
5
+
6
+ MEMORY_DIR = ".ghostcode/memory"
7
+ MEMORY_TYPES = ("user", "feedback", "project", "reference")
8
+
9
+ _frontmatter_re = re.compile(r"^---\s*\n(.*?)\n---\s*\n?(.*)", re.DOTALL)
10
+
11
+
12
+ def _memories_dir() -> Path:
13
+ return Path.cwd() / MEMORY_DIR
14
+
15
+
16
+ def _memory_path(name: str) -> Path:
17
+ return _memories_dir() / f"{name}.md"
18
+
19
+
20
+ def _list_memory_files() -> list[Path]:
21
+ d = _memories_dir()
22
+ if not d.exists():
23
+ return []
24
+ return sorted(d.glob("*.md"))
25
+
26
+
27
+ def load_memory(name: str) -> dict | None:
28
+ p = _memory_path(name)
29
+ if not p.exists():
30
+ return None
31
+ raw = p.read_text(encoding="utf-8")
32
+ m = _frontmatter_re.match(raw)
33
+ if not m:
34
+ return {"name": name, "content": raw.strip(), "metadata": {}}
35
+ metadata = yaml.safe_load(m.group(1)) or {}
36
+ content = m.group(2).strip()
37
+ return {"name": metadata.get("name", name), "description": metadata.get("description", ""), "metadata": metadata.get("metadata", {}), "content": content}
38
+
39
+
40
+ def search_memory(query: str) -> list[dict]:
41
+ q = query.lower()
42
+ results = []
43
+ for fp in _list_memory_files():
44
+ entry = load_memory(fp.stem)
45
+ if not entry:
46
+ continue
47
+ text = f"{entry.get('name', '')} {entry.get('description', '')} {entry.get('content', '')}".lower()
48
+ if q in text:
49
+ results.append(entry)
50
+ return results
51
+
52
+
53
+ def save_memory(name: str, content: str, metadata: dict | None = None) -> dict:
54
+ _memories_dir().mkdir(parents=True, exist_ok=True)
55
+ safe_name = name.lower().replace(" ", "-")
56
+ safe_name = "".join(c for c in safe_name if c.isalnum() or c in "-_")
57
+ if not safe_name:
58
+ safe_name = "memory"
59
+
60
+ slug = safe_name
61
+ path = _memory_path(slug)
62
+ if path.exists():
63
+ for i in range(1, 100):
64
+ alt = f"{slug}-{i}"
65
+ p = _memory_path(alt)
66
+ if not p.exists():
67
+ slug, path = alt, p
68
+ break
69
+
70
+ meta = metadata or {}
71
+ mtype = meta.get("type", "reference")
72
+ if mtype not in MEMORY_TYPES:
73
+ mtype = "reference"
74
+
75
+ desc = meta.get("description", content[:80] if len(content) > 80 else content)
76
+ pinned = meta.get("pinned", False)
77
+
78
+ frontmatter = {
79
+ "name": slug,
80
+ "description": desc,
81
+ "metadata": {"type": mtype},
82
+ }
83
+ if pinned:
84
+ frontmatter["metadata"]["pinned"] = True
85
+
86
+ raw = f"---\n{yaml.dump(frontmatter, default_flow_style=False).strip()}\n---\n\n{content}\n"
87
+ path.write_text(raw, encoding="utf-8")
88
+ return {"path": str(path), "slug": slug, "type": mtype, "bytes": len(raw)}
89
+
90
+
91
+ def delete_memory(name: str) -> bool:
92
+ p = _memory_path(name)
93
+ if p.exists():
94
+ p.unlink()
95
+ return True
96
+ return False
97
+
98
+
99
+ def list_memories(mtype: str | None = None) -> list[dict]:
100
+ results = []
101
+ for fp in _list_memory_files():
102
+ entry = load_memory(fp.stem)
103
+ if not entry:
104
+ continue
105
+ if mtype and entry.get("metadata", {}).get("type") != mtype:
106
+ continue
107
+ results.append(entry)
108
+ return results
109
+
110
+
111
+ def update_memory(name: str, content: str | None = None, metadata: dict | None = None) -> dict | None:
112
+ existing = load_memory(name)
113
+ if not existing:
114
+ return None
115
+ new_meta = existing.get("metadata", {})
116
+ if metadata:
117
+ mtype = metadata.get("type")
118
+ if mtype and mtype in MEMORY_TYPES:
119
+ new_meta["type"] = mtype
120
+ pinned = metadata.get("pinned")
121
+ if pinned is not None:
122
+ new_meta["pinned"] = pinned
123
+ save_memory(name, content or existing["content"], {"type": new_meta.get("type", "reference"), "description": existing.get("description", ""), "pinned": new_meta.get("pinned", False)})
124
+ return load_memory(name)
125
+
126
+
127
+ def count_memories() -> int:
128
+ return len(_list_memory_files())
129
+
130
+
131
+ def memory_summary() -> dict:
132
+ memories = list_memories()
133
+ by_type: dict[str, int] = {}
134
+ for m in memories:
135
+ t = m.get("metadata", {}).get("type", "unknown")
136
+ by_type[t] = by_type.get(t, 0) + 1
137
+ return {"total": len(memories), "by_type": by_type}
@@ -0,0 +1,73 @@
1
+ import re
2
+
3
+ from ghostcode.agents import get_system_prompt
4
+ from ghostcode.core.reminders import build_reminders, format_reminders
5
+
6
+ SYSTEM_PROMPT = get_system_prompt("code")
7
+
8
+ _GREETINGS = re.compile(
9
+ r"^(hi|hello|hey|yo|sup|hola|hai|hallo|heyy?|helloo?|good\s* morning|good\s* evening|what'?s up|howdy|hey\s+there)\s*[.!]*\s*$",
10
+ re.IGNORECASE,
11
+ )
12
+
13
+ _THANK_YOUS = re.compile(
14
+ r"^(thanks|thank\s*you|ty|thx|tysm|thank\s*you\s*so\s*much|cheers|appreciate\s*it)\s*[.!]*\s*$",
15
+ re.IGNORECASE,
16
+ )
17
+
18
+ _AGREEMENTS = re.compile(
19
+ r"^(ok|okay|k|got it|i see|understood|sure|yes|yeah|yep|alright|fine|makes sense)\s*[.!]*\s*$",
20
+ re.IGNORECASE,
21
+ )
22
+
23
+ _WHO_MADE = re.compile(
24
+ r"^(who\s+)?((made|created|built|develop(ed)?|program(med)?|wrote|own(s)?)\s+(you|ghostcode)|(is\s+your\s+(creator|author|maker)))(\?)?\s*$",
25
+ re.IGNORECASE,
26
+ )
27
+
28
+
29
+ def detect_greeting(text: str) -> str | None:
30
+ t = text.strip().lower()
31
+ if _WHO_MADE.match(t):
32
+ return "GhostCode was built by [bold]TheDarkGhost Team[/bold]. The underlying LLM is by its respective provider (e.g., Meta for Llama, Mistral AI for Mistral, etc.)."
33
+ if _GREETINGS.match(t):
34
+ return "hi"
35
+ if _THANK_YOUS.match(t):
36
+ return "np"
37
+ if _AGREEMENTS.match(t):
38
+ return "👍"
39
+ return None
40
+
41
+
42
+ def build_system_content(agent: str = "code", **context) -> str:
43
+ prompt = get_system_prompt(agent)
44
+ reminders = build_reminders(**context)
45
+ if reminders:
46
+ sep = "\n\n" if agent == "code" else "\n"
47
+ return prompt + sep + format_reminders(reminders)
48
+ return prompt
49
+
50
+
51
+ def build_messages(
52
+ history: list[dict],
53
+ user_prompt: str,
54
+ agent: str = "code",
55
+ plan_mode: bool = False,
56
+ file_state: dict | None = None,
57
+ hook_state: dict | None = None,
58
+ memory_count: int = 0,
59
+ task_count: int = 0,
60
+ ) -> list[dict]:
61
+ content = build_system_content(
62
+ agent=agent,
63
+ plan_mode=plan_mode,
64
+ file_state=file_state,
65
+ hook_state=hook_state,
66
+ memory_count=memory_count,
67
+ task_count=task_count,
68
+ )
69
+ messages = [{"role": "system", "content": content}]
70
+ for entry in history:
71
+ messages.append({"role": entry["role"], "content": entry["content"]})
72
+ messages.append({"role": "user", "content": user_prompt})
73
+ return messages
@@ -0,0 +1,61 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+
5
+ SYSTEM_REMINDERS = {
6
+ "file_empty": "<system-reminder>Warning: the file exists but the contents are empty.</system-reminder>",
7
+ "file_truncated": "Note: The file was too large and has been truncated. Use read_file to read more of the file if you need.",
8
+ "file_range_exceeded": "<system-reminder>Warning: the file exists but is shorter than the provided offset.</system-reminder>",
9
+ "exited_plan_mode": "## Exited Plan Mode\n\nYou have exited plan mode. You can now make edits, run tools, and take actions.",
10
+ "session_continued": "This session is being continued from another machine. Application state may have changed.",
11
+ }
12
+
13
+
14
+ def reminder(key: str, **kwargs) -> str:
15
+ tmpl = SYSTEM_REMINDERS.get(key)
16
+ if not tmpl:
17
+ return ""
18
+ if kwargs:
19
+ return tmpl.format(**kwargs)
20
+ return tmpl
21
+
22
+
23
+ def build_reminders(
24
+ plan_mode: bool = False,
25
+ file_state: dict | None = None,
26
+ hook_state: dict | None = None,
27
+ memory_count: int = 0,
28
+ task_count: int = 0,
29
+ ) -> list[str]:
30
+ result = []
31
+ if plan_mode:
32
+ result.append(
33
+ "## Plan mode is active\n\n"
34
+ "You are in read-only plan mode. DO NOT write or edit any files. "
35
+ "Explore the codebase, understand patterns, and design an implementation strategy. "
36
+ "When ready, describe your plan to the user."
37
+ )
38
+ if file_state:
39
+ if file_state.get("empty"):
40
+ result.append(reminder("file_empty"))
41
+ if file_state.get("truncated"):
42
+ result.append(reminder("file_truncated"))
43
+ if file_state.get("range_exceeded"):
44
+ result.append(reminder("file_range_exceeded"))
45
+ if file_state.get("modified_by_user"):
46
+ result.append(
47
+ "Note: A file was modified externally (by user or linter). "
48
+ "This change was intentional — don't revert it unless asked."
49
+ )
50
+ if hook_state:
51
+ if hook_state.get("blocking_error"):
52
+ result.append(f"Hook blocking error: {hook_state['blocking_error']}")
53
+ if hook_state.get("success"):
54
+ result.append(f"Hook success: {hook_state['success']}")
55
+ return result
56
+
57
+
58
+ def format_reminders(reminders_list: list[str]) -> str:
59
+ if not reminders_list:
60
+ return ""
61
+ return "\n\n".join(reminders_list)
@@ -0,0 +1,119 @@
1
+ import os
2
+ import re
3
+ from pathlib import Path
4
+
5
+
6
+ DENY_FILE = ".ghostcode/deny.json"
7
+
8
+
9
+ def _load_deny_rules() -> list[dict]:
10
+ import json
11
+ p = Path.cwd() / DENY_FILE
12
+ if not p.exists():
13
+ return []
14
+ try:
15
+ return json.loads(p.read_text())
16
+ except (json.JSONDecodeError, Exception):
17
+ return []
18
+
19
+
20
+ def _save_deny_rules(rules: list[dict]):
21
+ import json
22
+ p = Path.cwd() / DENY_FILE
23
+ p.parent.mkdir(parents=True, exist_ok=True)
24
+ p.write_text(json.dumps(rules, indent=2))
25
+
26
+
27
+ def is_action_denied(tool_name: str, args: dict) -> tuple[bool, str]:
28
+ rules = _load_deny_rules()
29
+ for rule in rules:
30
+ pattern = rule.get("pattern", "")
31
+ if not pattern:
32
+ continue
33
+ if tool_name == "run_command":
34
+ cmd = args.get("command", "")
35
+ if re.search(pattern, cmd):
36
+ return True, rule.get("reason", "denied by pattern")
37
+ elif tool_name in ("write_file", "edit_file", "read_file", "list_files"):
38
+ path = args.get("file_path", "") or args.get("path", "")
39
+ if re.search(pattern, path):
40
+ return True, rule.get("reason", "denied by pattern")
41
+ elif tool_name == "grep_search":
42
+ pat = args.get("pattern", "")
43
+ inc = args.get("include", "")
44
+ combined = f"{pat} {inc}"
45
+ if re.search(pattern, combined):
46
+ return True, rule.get("reason", "denied by pattern")
47
+ return False, ""
48
+
49
+
50
+ def add_deny_rule(pattern: str, reason: str = ""):
51
+ rules = _load_deny_rules()
52
+ rules.append({"pattern": pattern, "reason": reason})
53
+ _save_deny_rules(rules)
54
+
55
+
56
+ def remove_deny_rule(pattern: str) -> bool:
57
+ rules = _load_deny_rules()
58
+ new_rules = [r for r in rules if r.get("pattern") != pattern]
59
+ if len(new_rules) == len(rules):
60
+ return False
61
+ _save_deny_rules(new_rules)
62
+ return True
63
+
64
+
65
+ def list_deny_rules() -> list[dict]:
66
+ return _load_deny_rules()
67
+
68
+
69
+ DESTRUCTIVE_TOOLS = {"write_file", "edit_file", "run_command"}
70
+ OUTWARD_FACING_PATTERNS = [r"git\s+push", r"gh\s+pr", r"git\s+commit", r"npm\s+publish", r"pip(\s+|.*\s+)publish", r"kubectl\s+(apply|delete|create)", r"aws\s+"]
71
+
72
+
73
+ def is_reversible(args: dict) -> bool:
74
+ return True
75
+
76
+
77
+ def needs_confirmation(tool_name: str, args: dict) -> tuple[bool, str]:
78
+ if tool_name in ("write_file", "edit_file"):
79
+ path = args.get("file_path", "")
80
+ p = Path(path)
81
+ if p.exists():
82
+ return True, f"File already exists: {path}"
83
+ if tool_name == "run_command":
84
+ cmd = args.get("command", "")
85
+ if any(re.search(pat, cmd) for pat in OUTWARD_FACING_PATTERNS):
86
+ return True, f"Command may affect external systems: {cmd[:80]}"
87
+ destructive_words = ["rm ", "rm -rf", "rmdir", "del ", "rd ", "format ", "mkfs", "dd "]
88
+ if any(w in cmd for w in destructive_words):
89
+ return True, f"Destructive command: {cmd[:80]}"
90
+ if tool_name in ("delete_memory",):
91
+ return True, f"Deleting memory: {args.get('name', '')}"
92
+ return False, ""
93
+
94
+
95
+ SECRET_PATTERNS = [
96
+ r"(?:api[_-]?key|apikey|secret|token|password|passwd|credential)[\s\"'=:]+[A-Za-z0-9_\-\.]{16,}",
97
+ r"sk-[A-Za-z0-9]{32,}",
98
+ r"ghp_[A-Za-z0-9]{36}",
99
+ r"gho_[A-Za-z0-9]{36}",
100
+ r"ghu_[A-Za-z0-9]{36}",
101
+ r"xox[bpras]-[A-Za-z0-9\-]{10,}",
102
+ r"AKIA[0-9A-Z]{16}",
103
+ ]
104
+
105
+
106
+ def scan_for_secrets(content: str) -> list[str]:
107
+ findings = []
108
+ for pat in SECRET_PATTERNS:
109
+ matches = re.findall(pat, content)
110
+ for m in matches:
111
+ if m not in findings:
112
+ findings.append(m)
113
+ return findings
114
+
115
+
116
+ def redact_secrets(content: str) -> str:
117
+ for pat in SECRET_PATTERNS:
118
+ content = re.sub(pat, lambda m: m.group(0)[:4] + "..." + m.group(0)[-4:], content)
119
+ return content