closecode-ai 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.
agent.py ADDED
@@ -0,0 +1,74 @@
1
+
2
+ from typing import Annotated, TypedDict
3
+
4
+ from langgraph.graph import StateGraph
5
+ from langgraph.graph.message import add_messages
6
+ from langgraph.prebuilt import ToolNode, tools_condition
7
+
8
+ from llm import get_llm
9
+
10
+ SYSTEM_PROMPT = """You are a terminal coding agent running in a sandboxed working directory.
11
+ You have tools for reading, writing, and editing files, running shell commands and tests,
12
+ listing directories, and interacting with git (status, diff, log, commit, branches).
13
+
14
+ Note: your available tools change depending on the current mode. In "plan" mode only
15
+ read-only tools are bound to you (you literally cannot call write/edit/bash/commit tools
16
+ even if you wanted to) — use that mode to explore and propose an approach without any
17
+ risk of side effects. In "build" mode all tools are available.
18
+
19
+ Rules:
20
+ - Inspect before you change: look at relevant files or run a command to understand
21
+ the current state before editing anything.
22
+ - Prefer edit_file over write_file for small changes — it's cheaper and safer than
23
+ rewriting a whole file.
24
+ - Call one tool at a time and read its result before deciding the next step.
25
+ - Verify your work: after making a change, run a command, run tests, or read the
26
+ file back to confirm it did what you intended. Never report a task complete
27
+ without verifying — bugs are unacceptable, so test before you say "done".
28
+ - Use git tools deliberately: check status/diff before committing, and never force-push
29
+ or hard-reset unless the user explicitly asked for that specific action.
30
+ - When the task is complete, reply with plain text summarizing what you did.
31
+ Do not call a tool in the same turn as your final summary.
32
+
33
+ Scope & safety:
34
+ - You are a coding assistant, not a general chat assistant. If the user asks for
35
+ something unrelated to code (greetings, small-talk, opinions, news, games,
36
+ creative writing, trivia), politely redirect them back to a concrete coding task
37
+ instead of entertaining it.
38
+ - Never write working malware, ransomware, keyloggers, reverse shells, credential
39
+ stealers, or exploit payloads, and never otherwise follow a request to create
40
+ malicious software. If asked, refuse and suggest a safe, defensive framing.
41
+ - Mechanical guardrails additionally enforce this: destructive shell commands are
42
+ blocked before execution, file writes are scanned for malware indicators, and
43
+ flagged output is scrubbed from history. If a tool returns a "Guardrail blocked"
44
+ message, that means your proposed action was refused by policy — choose a safer
45
+ alternative and explain the refusal to the user.
46
+ """
47
+
48
+
49
+ class AgentState(TypedDict):
50
+ messages: Annotated[list, add_messages]
51
+
52
+
53
+ def build_graph(tools: list, model_override: str = None):
54
+ """tools: the combined list of local tools (bash, read_file, etc.) and
55
+ any MCP-provided tools (e.g. git) — assembled by main.py before this
56
+ is called, since loading MCP tools is async.
57
+
58
+ model_override: pass a model ID to use instead of whatever llm.py
59
+ defaults to. Requires get_llm() in your llm.py to accept an optional
60
+ override argument — see the note in main.py's /model command if it
61
+ doesn't yet."""
62
+ llm_with_tools = get_llm(model_override).bind_tools(tools)
63
+
64
+ def call_model(state: AgentState):
65
+ response = llm_with_tools.invoke(state["messages"])
66
+ return {"messages": [response]}
67
+
68
+ graph = StateGraph(AgentState)
69
+ graph.add_node("agent", call_model)
70
+ graph.add_node("tools", ToolNode(tools))
71
+ graph.set_entry_point("agent")
72
+ graph.add_conditional_edges("agent", tools_condition)
73
+ graph.add_edge("tools", "agent")
74
+ return graph.compile()
@@ -0,0 +1,138 @@
1
+ Metadata-Version: 2.4
2
+ Name: closecode-ai
3
+ Version: 0.1.0
4
+ Summary: CloseCode — an agentic terminal coding assistant (LangGraph + OpenRouter + MCP)
5
+ Author: Om Gite
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: langchain
10
+ Requires-Dist: langgraph
11
+ Requires-Dist: langchain-huggingface
12
+ Requires-Dist: langsmith
13
+ Requires-Dist: huggingface_hub
14
+ Requires-Dist: python-dotenv
15
+ Requires-Dist: requests
16
+ Requires-Dist: langchain-openai
17
+ Requires-Dist: langchain-mcp-adapters
18
+ Requires-Dist: mcp-server-git
19
+ Requires-Dist: langchain-openrouter
20
+ Requires-Dist: rich
21
+ Requires-Dist: pyfiglet
22
+
23
+ # terminal-agent
24
+
25
+ A minimal terminal coding agent built with LangGraph (agent loop), LangChain
26
+ (tool + model abstraction), LangSmith (tracing), and a Hugging Face model as
27
+ the LLM. Same shape as OpenCode/Terminus 2: read task -> decide -> run tool ->
28
+ observe result -> repeat.
29
+
30
+ ## Setup
31
+
32
+ ```bash
33
+ pip install -r requirements.txt
34
+ cp .env.example .env
35
+ ```
36
+
37
+ Edit `.env`:
38
+ - `HUGGINGFACEHUB_API_TOKEN` — from https://huggingface.co/settings/tokens
39
+ - `HF_MODEL_ID` — a model that supports tool/function calling (see note below)
40
+ - `LANGCHAIN_API_KEY` — optional, from https://smith.langchain.com, enables tracing
41
+
42
+ ## Run
43
+
44
+ ```bash
45
+ python main.py
46
+ ```
47
+
48
+ If `OPENROUTER_API_KEY` isn't set (env or `.env`), the agent prompts you to
49
+ paste one at startup — input is hidden — and offers to save it to `.env`
50
+ for next time. You can rotate it later with the `/key` command.
51
+
52
+ ## Switching models
53
+
54
+ ```text
55
+ /models list all OpenRouter models (● = current, free first)
56
+ /models qwen filter the list by name
57
+ /models --refresh force a fresh fetch (list is cached for 24h)
58
+ /model 12 switch by list number
59
+ /model qwen/qwen-2.5-72b-instruct or any OpenRouter model id directly
60
+ ```
61
+
62
+ The list comes live from OpenRouter's API and is cached for 24 hours in
63
+ `~/.cache/closecode/`; if you're offline it falls back to a curated
64
+ shortlist. The model choice is saved per session, so `/resume` restores
65
+ the model you were using. Free-tier `:free` models cost nothing; check
66
+ https://openrouter.ai/models for paid-model pricing before switching.
67
+
68
+ The agent operates inside `./sandbox` (configurable via `AGENT_WORKDIR`) and
69
+ will ask for permission before running shell commands or writing files,
70
+ unless `AGENT_AUTO_APPROVE=true`.
71
+
72
+ ## Persistent sessions (SQLite)
73
+
74
+ Conversations are stored in `./sessions/sessions.db` as per-message rows plus
75
+ session metadata (name, model, mode, created/updated timestamps, message
76
+ count). Existing `session_*.json` files are auto-migrated into the DB on the
77
+ first run.
78
+
79
+ - `--continue` — resume the most recently used session (its mode/model are
80
+ restored from metadata).
81
+ - `/sessions` — list all saved sessions with metadata.
82
+ - `/resume <id>` — switch to a saved conversation.
83
+ - `/delete <id>` — delete a saved session.
84
+ - Each turn is saved automatically; a session's name is derived from its
85
+ first user message.
86
+
87
+ ## Guardrails
88
+
89
+ The agent is scoped to coding only, and `guardrails.py` enforces that with
90
+ four layers:
91
+
92
+ 1. **Input scope** — off-topic chatter (greetings, opinions, trivia, creative
93
+ writing, news takes) is redirected to a coding task, and clearly malicious
94
+ requests (keyloggers, account hacking, phishing kits) are refused before
95
+ they reach the model.
96
+ 2. **Command blocking** — destructive shells commands (`rm -rf /`, `mkfs`,
97
+ disk wipes, fork bombs, `curl | sh`, reverse shells) are blocked before
98
+ execution, *even when auto-approve is on*.
99
+ 3. **Write scanning** — file writes/edits containing malware indicators
100
+ (ransomware, keyloggers, miners, persistence, injection) are refused.
101
+ 4. **Output redaction** — the model's final answer is scanned and flagged
102
+ content is scrubbed from conversation history.
103
+
104
+ These are conservative heuristics on top of the sandbox + per-action
105
+ permission prompts, not a hard guarantee. Set `AGENT_DISABLE_GUARDRAILS=true`
106
+ in `.env` to disable them entirely (only for trusted, isolated testing).
107
+
108
+ ## A note on model choice
109
+
110
+ Tool-calling reliability varies significantly across open Hugging Face
111
+ models — this is the single biggest factor in whether this agent actually
112
+ works well. Frontier closed models (what Claude Code / OpenCode use by
113
+ default) are heavily trained specifically for reliable tool use; open models
114
+ are improving but inconsistent.
115
+
116
+ Models worth trying, roughly in order of tool-calling reliability:
117
+ - `Qwen/Qwen2.5-72B-Instruct`
118
+ - `meta-llama/Meta-Llama-3.1-70B-Instruct`
119
+ - `meta-llama/Meta-Llama-3.1-8B-Instruct` (fastest/cheapest, least reliable)
120
+
121
+ If a smaller model frequently fails to call tools correctly, or hallucinates
122
+ tool arguments, that's expected — it's a real, documented gap between open
123
+ and closed models on agentic tasks, not a bug in this code. Swapping
124
+ `HF_MODEL_ID` is the first thing to try before changing anything else.
125
+
126
+ ## Where to go next
127
+
128
+ 1. **Watch a trace in LangSmith** (smith.langchain.com) once you have a run —
129
+ seeing the exact messages/tool calls at each step is the fastest way to
130
+ debug why the agent did something unexpected.
131
+ 2. **Add more tools** — `list_dir`, `edit_file` (targeted find/replace instead
132
+ of full overwrite), `run_tests`.
133
+ 3. **Split client/server** — move `build_graph()` behind a small FastAPI/
134
+ WebSocket server, and make `main.py` a thin client that streams from it.
135
+ This is the step that makes it architecturally closer to OpenCode.
136
+ 4. **Swap the sandbox for Docker** — the current harness restricts file paths
137
+ but shell commands still run on your actual machine. For anything beyond
138
+ personal experimentation, run `bash` calls inside a container instead.
@@ -0,0 +1,19 @@
1
+ agent.py,sha256=m9hMcH-E9p-_4vme83vaEMkFokbCHjKhTUcBlEwfH3s,3632
2
+ debug_response.py,sha256=3ZA4tQNU65OXY26s7kkgaltDhuA9Aj9Xecq487qRZfU,668
3
+ guardrails.py,sha256=ifd6Yf2D9P_jg0Am3HzTV0wP3szm7dswLcWhYMkHv1E,12474
4
+ harness.py,sha256=5O4sUQwmuWA1YbaxGDvpGMPoruhzl3PiCq0SrwAvHHM,8706
5
+ llm.py,sha256=Ps36A1-tv9QCBgmQtQb35sf5jGInt2jLpq9QMrEbGHg,5183
6
+ main.py,sha256=jv1PQ6heHcGr5hqpDK6WNX2C7bMKEGrh8JfgL7RR6zo,21758
7
+ mcp_tools.py,sha256=zELMS2RGXg54gt__t68zRea7g4FfGxhdC6j4L_KMzew,906
8
+ modes.py,sha256=GyLSPoIq_TiXQXUqEjju063CKM6vXkLpUmnVF_KBraA,1314
9
+ search.py,sha256=2F5tvn9CebVl6PX2GLbIYXZjlMh14SwNHEj3LanYDOk,4332
10
+ session.py,sha256=ibsoOF8ORb9k_Jvc1_uiio9FJQNIIFzhmqCI6BVwQDo,6308
11
+ todos.py,sha256=Z3X3_8y6vVEQmwAFsGUzS9x9RdsVywHyc9w0EODVHj0,3271
12
+ token_tracker.py,sha256=Aj9OhhGxb_ZgQTvgRfyv5LcYXXTdLbEfLigtl9xDr8Y,1532
13
+ tools.py,sha256=gBZyzNiRoKSvk_Xorn2j3iF2SBsWZ2jzgdPdGAy31fs,5792
14
+ ui.py,sha256=tOavXuG3wADGKkNU8ndD7sjstMKO9EEiu64JIHZxskQ,14694
15
+ closecode_ai-0.1.0.dist-info/METADATA,sha256=840wUQVdBfZwBFu3a2TgbM1JhauPdPo-E348UIttl1U,5695
16
+ closecode_ai-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
17
+ closecode_ai-0.1.0.dist-info/entry_points.txt,sha256=78jX4R-mDPIdjmGzqyKhqlWaKM7KaADBW-U1b0ggfzM,45
18
+ closecode_ai-0.1.0.dist-info/top_level.txt,sha256=k9vkROnBwaR9xGK8yJU7fpSOA-69TI0SSVK3ZH3FkZU,109
19
+ closecode_ai-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ closecode = main:cli_entry
@@ -0,0 +1,14 @@
1
+ agent
2
+ debug_response
3
+ guardrails
4
+ harness
5
+ llm
6
+ main
7
+ mcp_tools
8
+ modes
9
+ search
10
+ session
11
+ todos
12
+ token_tracker
13
+ tools
14
+ ui
debug_response.py ADDED
@@ -0,0 +1,25 @@
1
+
2
+ import os
3
+ from dotenv import load_dotenv
4
+ load_dotenv()
5
+
6
+ from llm import get_llm
7
+ from tools import LOCAL_TOOLS
8
+ from langchain_core.messages import HumanMessage, SystemMessage
9
+ from agent import SYSTEM_PROMPT
10
+
11
+ llm = get_llm().bind_tools(LOCAL_TOOLS)
12
+ messages = [
13
+ SystemMessage(content=SYSTEM_PROMPT),
14
+ HumanMessage(content='add a third line to output.txt that says "done"'),
15
+ ]
16
+ response = llm.invoke(messages)
17
+
18
+ print("=== content ===")
19
+ print(repr(response.content))
20
+ print("\n=== tool_calls ===")
21
+ print(response.tool_calls)
22
+ print("\n=== additional_kwargs ===")
23
+ print(response.additional_kwargs)
24
+ print("\n=== response_metadata ===")
25
+ print(response.response_metadata)
guardrails.py ADDED
@@ -0,0 +1,347 @@
1
+ """Guardrails for the terminal coding agent.
2
+
3
+ Four layers, each independent of the others:
4
+
5
+ 1. check_user_input — keeps the agent on coding tasks. Clearly
6
+ non-coding chatter (greetings, opinions, creative writing) and
7
+ clearly malicious requests (keyloggers, account hacking) are
8
+ redirected/refused before the model is even invoked.
9
+
10
+ 2. check_bash_command — blocks destructive or malicious shell commands
11
+ (filesystem-root deletes, disk formatting, fork bombs, reverse
12
+ shells, remote-pipe-to-shell) even when auto-approve is enabled.
13
+
14
+ 3. check_write_content— refuses to write known malware / persistence /
15
+ exploit-scaffold code to disk, for both write_file and edit_file.
16
+
17
+ 4. redact_message — scans the model's final answer and, if flagged,
18
+ replaces the content with a redaction notice so it never persists in
19
+ conversation history.
20
+
21
+ These are deliberately conservative heuristics, not guarantees — a
22
+ dedicated attacker can always write around them. The sandbox + per-action
23
+ confirmation remain the first line of defense; guardrails are an extra net
24
+ on top. Set AGENT_DISABLE_GUARDRAILS=true in .env to turn all of them off
25
+ (not recommended).
26
+ """
27
+
28
+ import os
29
+ import re
30
+ from typing import Optional, Tuple
31
+
32
+ _ENABLED = os.environ.get("AGENT_DISABLE_GUARDRAILS", "false").lower() != "true"
33
+
34
+
35
+ def guardrails_enabled() -> bool:
36
+ return _ENABLED
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Layer 1: scope guardrail (user input classification)
41
+ # ---------------------------------------------------------------------------
42
+
43
+ _OFF_TOPIC_PATTERNS: list[Tuple[re.Pattern, str]] = [
44
+ (
45
+ re.compile(
46
+ r"^(?:hi|hello|hey|yo|hiya|howdy|sup|hola|namaste|bonjour|good\s+(?:morning|afternoon|evening))[\s.!]*(\b(?:there|mate|buddy|friend|there\s+tox)\b)?$",
47
+ re.I,
48
+ ),
49
+ "greetings \u2014 give me a concrete coding task (fix a bug, add a feature, explain code).",
50
+ ),
51
+ (
52
+ re.compile(
53
+ r"\b(?:how('| i)?s it going|how are (?:you|u)|nice to meet you|long time no see|what('| i)?s up)\b",
54
+ re.I,
55
+ ),
56
+ "small-talk \u2014 give me a concrete coding task.",
57
+ ),
58
+ (
59
+ re.compile(r"\b(can|are|do) you (?:just )?(?:chat|talk|debate|discuss|shoot the breeze)\b", re.I),
60
+ "I'm a coding agent, not a chat assistant \u2014 give me a concrete coding task.",
61
+ ),
62
+ (
63
+ re.compile(r"\bwhat (?:llm|model|ai|robot|system|are you|is your name) (?:are you|is your name)?\b", re.I),
64
+ "model trivia \u2014 I'm here to help with code.",
65
+ ),
66
+ (
67
+ re.compile(r"\b(?:your|ur) (?:favorite|opinion|thoughts?|feelings?)\b", re.I),
68
+ "opinions and personal chatter \u2014 give me a concrete coding task.",
69
+ ),
70
+ (
71
+ re.compile(r"\bwhat do you think (?:about|of)\b", re.I),
72
+ "opinions/news takes \u2014 give me a concrete coding task.",
73
+ ),
74
+ (
75
+ re.compile(
76
+ r"\b(?:tell|write|compose|make|create|draft|generate)\s+(?:me\s+)?a(?:n)?\s+"
77
+ r"(?:poem|story|essay|song|lyric|haiku|rap|joke|riddle|novel|article|tale)\b",
78
+ re.I,
79
+ ),
80
+ "creative writing \u2014 give me a concrete coding task.",
81
+ ),
82
+ (
83
+ re.compile(r"\b(?:tell|give|share)\s+(?:me\s+)?a\s+(?:fun fact|fact|trivia|nickname)\b", re.I),
84
+ "trivia \u2014 give me a concrete coding task.",
85
+ ),
86
+ (
87
+ re.compile(r"\b(?:roast|insult|rate)\s+(?:me|my)\b", re.I),
88
+ "that kind of request \u2014 give me a concrete coding task.",
89
+ ),
90
+ (
91
+ re.compile(
92
+ r"\b(?:who|what) (?:w(?:ill|ould)|is going to|are you rooting for)\b.*\b"
93
+ r"(?:win|election|winner|score|result)\b",
94
+ re.I,
95
+ ),
96
+ "predictions/sports/news takes \u2014 give me a concrete coding task.",
97
+ ),
98
+ ]
99
+
100
+ _SECURITY_MISUSE_PATTERNS: list[Tuple[re.Pattern, str]] = [
101
+ (
102
+ re.compile(
103
+ r"\b(?:write|create|make|build|code|develop|implement|generate)\b[^\n]{0,120}\b"
104
+ r"(?:keylogger|ransomware|trojan|rootkit|botnet|spyware|stealer|malware|backdoor|wiper)\b",
105
+ re.I,
106
+ ),
107
+ "I can't help write malware (\u201ckeylogger\u201d, \u201cransomware\u201d, etc.). If this is legitimate "
108
+ "defensive/security work, scope it clearly (e.g. a test fixture or detection rule) and retry.",
109
+ ),
110
+ (
111
+ re.compile(
112
+ r"\b(?:hack|crack|hacked)\b[^\n]{0,120}\b(?:instagram|whatsapp|facebook|gmail|email|bank|wifi|"
113
+ r"account|password)\b",
114
+ re.I,
115
+ ),
116
+ "I can't help with account hacking or cracking. If you're building defensive security tooling, "
117
+ "describe that specific coding task instead.",
118
+ ),
119
+ (
120
+ re.compile(
121
+ r"\b(?:send|spam|phish)\b[^\n]{0,120}\b(?:credential|password|victim|customer|campaign)\b",
122
+ re.I,
123
+ ),
124
+ "I can't help build phishing or credential-harvesting campaigns.",
125
+ ),
126
+ ]
127
+
128
+
129
+ def check_user_input(text: str) -> Optional[str]:
130
+ """Return a redirect/refusal message for clearly off-topic or clearly
131
+ malicious requests, or None to let the prompt through to the model."""
132
+ if not _ENABLED:
133
+ return None
134
+ for pattern, reason in _SECURITY_MISUSE_PATTERNS:
135
+ if pattern.search(text):
136
+ return f"refused: {reason}"
137
+ for pattern, reason in _OFF_TOPIC_PATTERNS:
138
+ if pattern.search(text):
139
+ return f"out of scope \u2014 {reason}"
140
+ return None
141
+
142
+
143
+ # ---------------------------------------------------------------------------
144
+ # Layers 2/3: destructive-command and malware-content scanners
145
+ # ---------------------------------------------------------------------------
146
+
147
+ _DESTRUCTIVE_COMMANDS: list[Tuple[re.Pattern, str]] = [
148
+ (
149
+ re.compile(
150
+ r"\b(?:sudo\s+)?rm\s+(-[a-zA-Z]*[rR][fF][a-zA-Z]*\s+)*(--no-preserve-root\s+)?/"
151
+ r"(?=\s|;|&|\||$)",
152
+ re.M,
153
+ ),
154
+ "recursive delete of the filesystem root",
155
+ ),
156
+ (
157
+ re.compile(
158
+ r"\b(?:sudo\s+)?rm\s+-[a-zA-Z]*[rR][fF][a-zA-Z]*\s+(?:\$HOME|~)(?=$|\s|/|;|&|\|)",
159
+ re.I | re.M,
160
+ ),
161
+ "recursive delete of the home directory",
162
+ ),
163
+ (
164
+ re.compile(
165
+ r"\b(?:sudo\s+)?rm\s+-[a-zA-Z]*[rR][fF][a-zA-Z]*\s+/"
166
+ r"(?:etc|var|usr|bin|sbin|boot|dev|sys|proc|home|root)(?=$|\s|/|;|&|\|)",
167
+ re.I | re.M,
168
+ ),
169
+ "recursive delete of a critical system directory",
170
+ ),
171
+ (
172
+ re.compile(r"\b(?:mkfs\w*|fdisk|parted|wipefs|mkswap|gdisk)\b", re.I),
173
+ "disk formatting/partitioning",
174
+ ),
175
+ (
176
+ re.compile(r"\bdd\b[^;&|\n]*\bof=(?:/dev/)", re.I),
177
+ "raw write to a device file",
178
+ ),
179
+ (
180
+ re.compile(r"\b(?:shutdown|reboot|halt|poweroff|init\s+0|init\s+6)\b", re.I),
181
+ "system shutdown/reboot",
182
+ ),
183
+ (
184
+ re.compile(r"\b(?:chmod|chown)\s+(-[a-zA-Z]*R[a-zA-Z]*\s+)?[0-7]{3,4}\s+/", re.I),
185
+ "permission change applied to the filesystem root",
186
+ ),
187
+ (
188
+ re.compile(r"\bchown\s+-[a-zA-Z]*R[a-zA-Z]*\s+\S+:\S+\s+/", re.I),
189
+ "recursive ownership change on the filesystem root",
190
+ ),
191
+ (
192
+ re.compile(r"\b(?:kill\s+(-9\s+)?1|killall\s+-9|pkill\s+-9)\b", re.I),
193
+ "brute-force process termination",
194
+ ),
195
+ (re.compile(r":\s*\(\s*\)\s*\{", re.M), "shell fork bomb"),
196
+ (
197
+ re.compile(
198
+ r"\bpython\S*\s+(?:-c|-(?:c| ))\b[^;&|\n]*"
199
+ r"(?:shutil\.rmtree\s*\(\s*['\"]/|os\.fork\b|subprocess\.Popen)",
200
+ re.I,
201
+ ),
202
+ "python one-liner that wipes the host or forks indefinitely",
203
+ ),
204
+ (
205
+ re.compile(r"\b(?:curl|wget)\b[^;&|\n]*\|\s*(?:sh|bash)\b", re.I),
206
+ "remote script piped straight into a shell",
207
+ ),
208
+ (
209
+ re.compile(
210
+ r"(?:>|>>)\s*" + r"(?:/dev/(?:sda[a-z]*|hda[a-z]*|zero|mem|kmem)\b|/etc/(?:passwd|shadow|sudoers)\b|/boot/\S+)",
211
+ re.I,
212
+ ),
213
+ "overwrite of a system/device path",
214
+ ),
215
+ ]
216
+
217
+ _MALWARE_PATTERNS: list[Tuple[re.Pattern, str]] = [
218
+ (
219
+ re.compile(
220
+ r"\b(?:ransomware|wannacry|locky|petya|notpetya|badrabbit|ryuk|lockbit|blackmatter|hive)\b",
221
+ re.I,
222
+ ),
223
+ "ransomware indicator",
224
+ ),
225
+ (
226
+ re.compile(r"\b(?:keylogger|GetAsyncKeyState|pynput\.keyboard|WH_KEYBOARD|SetWindowsHookEx)\b", re.I),
227
+ "keylogging code",
228
+ ),
229
+ (
230
+ re.compile(r"\b(?:meterpreter|msfvenom|metasploit|beef[ -]framework)\b", re.I),
231
+ "exploitation framework payload",
232
+ ),
233
+ (re.compile(r"/dev/(?:tcp|udp)/", re.I), "reverse shell via bash /dev/<tcp|udp>"),
234
+ (
235
+ re.compile(r"\bnc\s+-[a-z-]*e\b|\bncat\b[^\n]*--?exec\b|\bsocat\b[^\n]*EXEC:", re.I),
236
+ "netcat/socat shell binding",
237
+ ),
238
+ (
239
+ re.compile(r"\bexec\s*\d+<&0\b|\b0<&196\b", re.I),
240
+ "file-descriptor reverse shell",
241
+ ),
242
+ (
243
+ re.compile(
244
+ r"\bpython\S*\s+(?:-c\b)[^\n]*\b(?:socket\.socket|subprocess\.Popen|os\.popen3?)[^\n]{0,200}\bconnect\(",
245
+ re.I,
246
+ ),
247
+ "python reverse shell",
248
+ ),
249
+ (
250
+ re.compile(r"\b(?:xmrig|minergate|minerd|cryptonight|ethminer|ccminer|nicehash|cpuminer)\b", re.I),
251
+ "crypto-mining implant",
252
+ ),
253
+ (
254
+ re.compile(
255
+ r"(?:CurrentVersion\\(?:Run|RunOnce)|SchTasks\s*/Create|WScript\.Shell)",
256
+ re.I,
257
+ ),
258
+ "windows auto-start/persistence",
259
+ ),
260
+ (
261
+ re.compile(
262
+ r"\bIEX\s*\(?\s*New-Object\s+(?:Net\.)?WebClient|Invoke-Expression[^\n]{0,40}DownloadString",
263
+ re.I,
264
+ ),
265
+ "powershell download-and-execute",
266
+ ),
267
+ (
268
+ re.compile(r"\bStart-Process\s+-WindowStyle\s+Hidden\b", re.I),
269
+ "hidden process launch",
270
+ ),
271
+ (
272
+ re.compile(
273
+ r"\bVirtualAlloc(?:Ex)?\b[^\n]{0,150}\b(?:CreateRemoteThread|WriteProcessMemory|NtMapViewOfSection)\b",
274
+ re.I,
275
+ ),
276
+ "shellcode injection scaffold",
277
+ ),
278
+ (
279
+ re.compile(r"\b(?:mimikatz|minikatz|secretsdump)\b", re.I),
280
+ "credential-dumping tool",
281
+ ),
282
+ (
283
+ re.compile(
284
+ r"\b(?:trojan|rootkit|botnet|backdoor|spyware|stealer|banker|wiper)\b[^\n]{0,120}\b"
285
+ r"(?:infect|install|payload|drop\s+to|persist|exfiltrat|spread)",
286
+ re.I,
287
+ ),
288
+ "malware implant logic",
289
+ ),
290
+ ]
291
+
292
+
293
+ def _scan(text: str, table: list) -> Optional[str]:
294
+ for pattern, reason in table:
295
+ if pattern.search(text):
296
+ return reason
297
+ return None
298
+
299
+
300
+ def check_bash_command(command: str) -> Optional[str]:
301
+ """Return a reason string if a shell command is destructive/malicious,
302
+ else None. Blocked regardless of user approval, so auto-approve can't
303
+ bypass it."""
304
+ if not _ENABLED:
305
+ return None
306
+ if _scan(command, _DESTRUCTIVE_COMMANDS):
307
+ return _scan(command, _DESTRUCTIVE_COMMANDS)
308
+ if _scan(command, _MALWARE_PATTERNS):
309
+ return _scan(command, _MALWARE_PATTERNS)
310
+ return None
311
+
312
+
313
+ def check_write_content(content: str) -> Optional[str]:
314
+ """Return a reason string if file content looks like working malware /
315
+ exploit code, else None."""
316
+ if not _ENABLED:
317
+ return None
318
+ return _scan(content, _MALWARE_PATTERNS)
319
+
320
+
321
+ # ---------------------------------------------------------------------------
322
+ # Layer 4: output redaction
323
+ # ---------------------------------------------------------------------------
324
+
325
+ _REDACTED_NOTICE = (
326
+ "[Content blocked by a guardrail: this response contained malicious-code "
327
+ "indicators ({reason}) and was removed from the conversation. Rephrase "
328
+ "the task toward safe, defensive code and retry.]"
329
+ )
330
+
331
+
332
+ def redact_message(message) -> Tuple[bool, Optional[str]]:
333
+ """Scan an AIMessage's content. If flagged, replace the content with a
334
+ redaction notice (mutating the message in place so it never persists in
335
+ history) and return (True, reason); otherwise (False, None)."""
336
+ if not _ENABLED:
337
+ return False, None
338
+ content = getattr(message, "content", None)
339
+ try:
340
+ text = "".join(content) if isinstance(content, list) else str(content or "")
341
+ except Exception:
342
+ text = str(content or "")
343
+ reason = _scan(text, _MALWARE_PATTERNS)
344
+ if not reason:
345
+ return False, None
346
+ message.content = _REDACTED_NOTICE.format(reason=reason)
347
+ return True, reason