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 +74 -0
- closecode_ai-0.1.0.dist-info/METADATA +138 -0
- closecode_ai-0.1.0.dist-info/RECORD +19 -0
- closecode_ai-0.1.0.dist-info/WHEEL +5 -0
- closecode_ai-0.1.0.dist-info/entry_points.txt +2 -0
- closecode_ai-0.1.0.dist-info/top_level.txt +14 -0
- debug_response.py +25 -0
- guardrails.py +347 -0
- harness.py +200 -0
- llm.py +145 -0
- main.py +520 -0
- mcp_tools.py +27 -0
- modes.py +36 -0
- search.py +130 -0
- session.py +197 -0
- todos.py +99 -0
- token_tracker.py +42 -0
- tools.py +137 -0
- ui.py +422 -0
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,,
|
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
|