nexcoder 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.
- nex/__init__.py +6 -0
- nex/agent.py +623 -0
- nex/api_client.py +194 -0
- nex/cli.py +506 -0
- nex/config.py +168 -0
- nex/context.py +252 -0
- nex/exceptions.py +39 -0
- nex/indexer/__init__.py +16 -0
- nex/indexer/index.py +332 -0
- nex/indexer/parser.py +352 -0
- nex/indexer/scanner.py +191 -0
- nex/memory/__init__.py +15 -0
- nex/memory/decisions.py +131 -0
- nex/memory/errors.py +257 -0
- nex/memory/project.py +158 -0
- nex/planner.py +122 -0
- nex/py.typed +0 -0
- nex/reviewer.py +111 -0
- nex/safety.py +235 -0
- nex/test_runner.py +201 -0
- nex/tools/__init__.py +114 -0
- nex/tools/file_ops.py +89 -0
- nex/tools/git_ops.py +183 -0
- nex/tools/search.py +156 -0
- nex/tools/shell.py +72 -0
- nexcoder-0.1.0.dist-info/METADATA +170 -0
- nexcoder-0.1.0.dist-info/RECORD +30 -0
- nexcoder-0.1.0.dist-info/WHEEL +4 -0
- nexcoder-0.1.0.dist-info/entry_points.txt +2 -0
- nexcoder-0.1.0.dist-info/licenses/LICENSE +21 -0
nex/memory/project.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Project memory manager for .nex/memory.md.
|
|
2
|
+
|
|
3
|
+
The project memory file is a human-readable markdown document that stores
|
|
4
|
+
project context (overview, tech stack, architecture, conventions). It is
|
|
5
|
+
loaded into the system prompt on every agent invocation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Final
|
|
13
|
+
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
|
|
16
|
+
from nex.exceptions import NexMemoryError
|
|
17
|
+
|
|
18
|
+
console: Final[Console] = Console(stderr=True)
|
|
19
|
+
|
|
20
|
+
_MEMORY_TEMPLATE: Final[str] = """\
|
|
21
|
+
# Project Overview
|
|
22
|
+
|
|
23
|
+
{project_name}
|
|
24
|
+
|
|
25
|
+
## Tech Stack
|
|
26
|
+
|
|
27
|
+
{tech_stack}
|
|
28
|
+
|
|
29
|
+
## Architecture
|
|
30
|
+
|
|
31
|
+
<!-- Describe the high-level architecture of this project. -->
|
|
32
|
+
|
|
33
|
+
## Conventions
|
|
34
|
+
|
|
35
|
+
<!-- List coding conventions, naming rules, and style preferences. -->
|
|
36
|
+
|
|
37
|
+
## Notes
|
|
38
|
+
|
|
39
|
+
<!-- Anything else the agent should remember between sessions. -->
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class ProjectMemory:
|
|
45
|
+
"""Manages persistent project memory stored in .nex/memory.md.
|
|
46
|
+
|
|
47
|
+
Attributes:
|
|
48
|
+
project_dir: Root directory of the project containing the .nex folder.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
project_dir: Path
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def memory_path(self) -> Path:
|
|
55
|
+
"""Return the absolute path to the memory file."""
|
|
56
|
+
return self.project_dir / ".nex" / "memory.md"
|
|
57
|
+
|
|
58
|
+
def exists(self) -> bool:
|
|
59
|
+
"""Check whether the memory file exists on disk."""
|
|
60
|
+
return self.memory_path.is_file()
|
|
61
|
+
|
|
62
|
+
def load(self) -> str:
|
|
63
|
+
"""Read and return the contents of memory.md.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
The full text of the memory file, or an empty string if not found.
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
NexMemoryError: If the file exists but cannot be read.
|
|
70
|
+
"""
|
|
71
|
+
if not self.exists():
|
|
72
|
+
return ""
|
|
73
|
+
try:
|
|
74
|
+
return self.memory_path.read_text(encoding="utf-8")
|
|
75
|
+
except OSError as exc:
|
|
76
|
+
raise NexMemoryError(
|
|
77
|
+
f"Failed to read project memory at {self.memory_path}: {exc}"
|
|
78
|
+
) from exc
|
|
79
|
+
|
|
80
|
+
def save(self, content: str) -> None:
|
|
81
|
+
"""Write content to memory.md, creating parent dirs if needed.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
content: The full markdown text to persist.
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
NexMemoryError: If the file cannot be written.
|
|
88
|
+
"""
|
|
89
|
+
try:
|
|
90
|
+
self.memory_path.parent.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
self.memory_path.write_text(content, encoding="utf-8")
|
|
92
|
+
except OSError as exc:
|
|
93
|
+
raise NexMemoryError(
|
|
94
|
+
f"Failed to save project memory at {self.memory_path}: {exc}"
|
|
95
|
+
) from exc
|
|
96
|
+
|
|
97
|
+
def initialize(self, project_name: str, tech_stack: str = "") -> None:
|
|
98
|
+
"""Create an initial memory.md from the built-in template.
|
|
99
|
+
|
|
100
|
+
Does not overwrite if already exists.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
project_name: Human-readable project name.
|
|
104
|
+
tech_stack: Optional tech stack description.
|
|
105
|
+
|
|
106
|
+
Raises:
|
|
107
|
+
NexMemoryError: If the file cannot be written.
|
|
108
|
+
"""
|
|
109
|
+
if self.exists():
|
|
110
|
+
console.print("[yellow]Memory file already exists — skipping.[/yellow]")
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
rendered = _MEMORY_TEMPLATE.format(
|
|
114
|
+
project_name=project_name,
|
|
115
|
+
tech_stack=tech_stack or "<!-- Add your tech stack here. -->",
|
|
116
|
+
)
|
|
117
|
+
self.save(rendered)
|
|
118
|
+
console.print(f"[green]Initialized project memory[/green] for [bold]{project_name}[/bold]")
|
|
119
|
+
|
|
120
|
+
def append(self, section: str, content: str) -> None:
|
|
121
|
+
"""Append content under a given section heading.
|
|
122
|
+
|
|
123
|
+
If the section is not found, a new section is appended at EOF.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
section: Section title without the # prefix (e.g. "Notes").
|
|
127
|
+
content: Markdown text to append.
|
|
128
|
+
|
|
129
|
+
Raises:
|
|
130
|
+
NexMemoryError: If the file cannot be read or written.
|
|
131
|
+
"""
|
|
132
|
+
existing = self.load()
|
|
133
|
+
lines = existing.splitlines(keepends=True)
|
|
134
|
+
|
|
135
|
+
section_idx: int | None = None
|
|
136
|
+
for idx, line in enumerate(lines):
|
|
137
|
+
stripped = line.strip().lstrip("#").strip()
|
|
138
|
+
if stripped.lower() == section.lower():
|
|
139
|
+
section_idx = idx
|
|
140
|
+
break
|
|
141
|
+
|
|
142
|
+
if section_idx is not None:
|
|
143
|
+
insert_idx = len(lines)
|
|
144
|
+
for idx in range(section_idx + 1, len(lines)):
|
|
145
|
+
if lines[idx].lstrip().startswith("#"):
|
|
146
|
+
insert_idx = idx
|
|
147
|
+
break
|
|
148
|
+
|
|
149
|
+
while insert_idx > section_idx + 1 and lines[insert_idx - 1].strip() == "":
|
|
150
|
+
insert_idx -= 1
|
|
151
|
+
|
|
152
|
+
lines.insert(insert_idx, f"\n{content}\n")
|
|
153
|
+
else:
|
|
154
|
+
if existing and not existing.endswith("\n"):
|
|
155
|
+
lines.append("\n")
|
|
156
|
+
lines.append(f"\n## {section}\n\n{content}\n")
|
|
157
|
+
|
|
158
|
+
self.save("".join(lines))
|
nex/planner.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Task decomposition using Claude Haiku."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
from nex.api_client import AnthropicClient
|
|
12
|
+
|
|
13
|
+
console = Console(stderr=True)
|
|
14
|
+
|
|
15
|
+
_PLANNER_SYSTEM_PROMPT = """\
|
|
16
|
+
You are a task planner for a coding agent. Given a task description and project context, \
|
|
17
|
+
decompose the task into a list of concrete subtasks.
|
|
18
|
+
|
|
19
|
+
Respond with a JSON array of objects, each with:
|
|
20
|
+
- "description": what to do (be specific)
|
|
21
|
+
- "file_paths": list of files likely to be touched
|
|
22
|
+
- "priority": 1 = do first, 2 = do second, etc.
|
|
23
|
+
|
|
24
|
+
Keep the list concise (3-7 subtasks). Order by dependency — things that must happen first \
|
|
25
|
+
get lower priority numbers.
|
|
26
|
+
|
|
27
|
+
Respond ONLY with the JSON array, no other text.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Subtask:
|
|
33
|
+
"""A single subtask from the planner.
|
|
34
|
+
|
|
35
|
+
Attributes:
|
|
36
|
+
description: What to do.
|
|
37
|
+
file_paths: Expected files to touch.
|
|
38
|
+
priority: 1 = highest priority.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
description: str
|
|
42
|
+
file_paths: list[str]
|
|
43
|
+
priority: int
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Planner:
|
|
47
|
+
"""Decomposes a task into subtasks using Claude Haiku."""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
api_client: AnthropicClient,
|
|
52
|
+
haiku_model: str = "claude-haiku-4-5-20251001",
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Initialize the planner.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
api_client: The Anthropic API client.
|
|
58
|
+
haiku_model: Model to use for planning.
|
|
59
|
+
"""
|
|
60
|
+
self._client = api_client
|
|
61
|
+
self._model = haiku_model
|
|
62
|
+
|
|
63
|
+
async def plan(self, task: str, project_context: str) -> list[Subtask]:
|
|
64
|
+
"""Decompose a task into subtasks.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
task: The user's task description.
|
|
68
|
+
project_context: Project memory and relevant context.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
List of Subtask instances sorted by priority.
|
|
72
|
+
"""
|
|
73
|
+
messages: list[dict[str, Any]] = [
|
|
74
|
+
{
|
|
75
|
+
"role": "user",
|
|
76
|
+
"content": f"Project context:\n{project_context}\n\nTask: {task}",
|
|
77
|
+
}
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
console.print("[dim]Planning task decomposition...[/dim]")
|
|
81
|
+
|
|
82
|
+
response = await self._client.send_message(
|
|
83
|
+
messages=messages,
|
|
84
|
+
system=_PLANNER_SYSTEM_PROMPT,
|
|
85
|
+
model=self._model,
|
|
86
|
+
max_tokens=2048,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Extract text response
|
|
90
|
+
text = ""
|
|
91
|
+
for block in response.content:
|
|
92
|
+
if block.get("type") == "text":
|
|
93
|
+
text = block.get("text", "")
|
|
94
|
+
break
|
|
95
|
+
|
|
96
|
+
# Parse JSON
|
|
97
|
+
try:
|
|
98
|
+
# Handle markdown code fences
|
|
99
|
+
if "```" in text:
|
|
100
|
+
text = text.split("```")[1]
|
|
101
|
+
if text.startswith("json"):
|
|
102
|
+
text = text[4:]
|
|
103
|
+
|
|
104
|
+
raw: list[dict[str, Any]] = json.loads(text.strip())
|
|
105
|
+
subtasks = [
|
|
106
|
+
Subtask(
|
|
107
|
+
description=item.get("description", ""),
|
|
108
|
+
file_paths=item.get("file_paths", []),
|
|
109
|
+
priority=item.get("priority", 99),
|
|
110
|
+
)
|
|
111
|
+
for item in raw
|
|
112
|
+
if isinstance(item, dict)
|
|
113
|
+
]
|
|
114
|
+
subtasks.sort(key=lambda s: s.priority)
|
|
115
|
+
console.print(f"[green]Planned[/green] {len(subtasks)} subtasks")
|
|
116
|
+
return subtasks
|
|
117
|
+
|
|
118
|
+
except (json.JSONDecodeError, KeyError, TypeError):
|
|
119
|
+
console.print(
|
|
120
|
+
"[yellow]Warning[/yellow]: Could not parse plan, proceeding without decomposition"
|
|
121
|
+
)
|
|
122
|
+
return [Subtask(description=task, file_paths=[], priority=1)]
|
nex/py.typed
ADDED
|
File without changes
|
nex/reviewer.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Independent code reviewer — separate API call, no access to the plan."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
from nex.api_client import AnthropicClient
|
|
12
|
+
|
|
13
|
+
console = Console(stderr=True)
|
|
14
|
+
|
|
15
|
+
_REVIEWER_SYSTEM_PROMPT = """\
|
|
16
|
+
You are an independent code reviewer. You will be shown a git diff of changes made by a \
|
|
17
|
+
coding agent. Review the diff for:
|
|
18
|
+
|
|
19
|
+
1. Correctness: Does the code do what the task asks?
|
|
20
|
+
2. Bugs: Any obvious logic errors, off-by-one errors, null/undefined issues?
|
|
21
|
+
3. Security: Any injection vulnerabilities, hardcoded secrets, unsafe operations?
|
|
22
|
+
4. Style: Does it match the project's existing conventions?
|
|
23
|
+
5. Tests: Were tests added or updated for the changes?
|
|
24
|
+
|
|
25
|
+
Respond with a JSON object:
|
|
26
|
+
{
|
|
27
|
+
"approved": true/false,
|
|
28
|
+
"issues": ["list of problems found"],
|
|
29
|
+
"suggestions": ["list of improvement suggestions"]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
Be concise. Only flag real issues, not style nitpicks. If the code looks good, approve it.
|
|
33
|
+
Respond ONLY with the JSON object, no other text.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class ReviewResult:
|
|
39
|
+
"""Result of an independent code review.
|
|
40
|
+
|
|
41
|
+
Attributes:
|
|
42
|
+
approved: Whether the changes pass review.
|
|
43
|
+
issues: List of problems found.
|
|
44
|
+
suggestions: List of improvement suggestions.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
approved: bool
|
|
48
|
+
issues: list[str]
|
|
49
|
+
suggestions: list[str]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Reviewer:
|
|
53
|
+
"""Independent code reviewer using a separate API call."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, api_client: AnthropicClient) -> None:
|
|
56
|
+
"""Initialize the reviewer.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
api_client: The Anthropic API client.
|
|
60
|
+
"""
|
|
61
|
+
self._client = api_client
|
|
62
|
+
|
|
63
|
+
async def review(self, diff: str, task: str) -> ReviewResult:
|
|
64
|
+
"""Review a git diff against the original task.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
diff: The git diff to review.
|
|
68
|
+
task: The original task description.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Structured ReviewResult.
|
|
72
|
+
"""
|
|
73
|
+
if not diff.strip():
|
|
74
|
+
return ReviewResult(approved=True, issues=[], suggestions=[])
|
|
75
|
+
|
|
76
|
+
messages: list[dict[str, Any]] = [
|
|
77
|
+
{
|
|
78
|
+
"role": "user",
|
|
79
|
+
"content": f"Original task: {task}\n\nGit diff:\n```\n{diff}\n```",
|
|
80
|
+
}
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
console.print("[dim]Reviewing changes...[/dim]")
|
|
84
|
+
|
|
85
|
+
response = await self._client.send_message(
|
|
86
|
+
messages=messages,
|
|
87
|
+
system=_REVIEWER_SYSTEM_PROMPT,
|
|
88
|
+
max_tokens=2048,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
text = ""
|
|
92
|
+
for block in response.content:
|
|
93
|
+
if block.get("type") == "text":
|
|
94
|
+
text = block.get("text", "")
|
|
95
|
+
break
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
if "```" in text:
|
|
99
|
+
text = text.split("```")[1]
|
|
100
|
+
if text.startswith("json"):
|
|
101
|
+
text = text[4:]
|
|
102
|
+
|
|
103
|
+
raw: dict[str, Any] = json.loads(text.strip())
|
|
104
|
+
return ReviewResult(
|
|
105
|
+
approved=bool(raw.get("approved", False)),
|
|
106
|
+
issues=raw.get("issues", []),
|
|
107
|
+
suggestions=raw.get("suggestions", []),
|
|
108
|
+
)
|
|
109
|
+
except (json.JSONDecodeError, KeyError, TypeError):
|
|
110
|
+
console.print("[yellow]Warning[/yellow]: Could not parse review, assuming approval")
|
|
111
|
+
return ReviewResult(approved=True, issues=[], suggestions=[])
|
nex/safety.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Safety layer — destructive operation detection and user approval.
|
|
2
|
+
|
|
3
|
+
Detects rm -rf, DROP TABLE, force push, etc. and requires explicit user
|
|
4
|
+
confirmation before execution. In dry-run mode, dangerous actions are
|
|
5
|
+
always blocked.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import ClassVar
|
|
14
|
+
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.panel import Panel
|
|
17
|
+
from rich.prompt import Prompt
|
|
18
|
+
from rich.text import Text
|
|
19
|
+
|
|
20
|
+
from nex.exceptions import SafetyError
|
|
21
|
+
|
|
22
|
+
console = Console()
|
|
23
|
+
|
|
24
|
+
_SENSITIVE_FILENAMES: frozenset[str] = frozenset(
|
|
25
|
+
{
|
|
26
|
+
".env",
|
|
27
|
+
".env.local",
|
|
28
|
+
".env.production",
|
|
29
|
+
".env.development",
|
|
30
|
+
"credentials.json",
|
|
31
|
+
"service-account.json",
|
|
32
|
+
"secrets.yaml",
|
|
33
|
+
"secrets.yml",
|
|
34
|
+
"id_rsa",
|
|
35
|
+
"id_ed25519",
|
|
36
|
+
".npmrc",
|
|
37
|
+
".pypirc",
|
|
38
|
+
}
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class SafetyCheck:
|
|
44
|
+
"""Result of a safety evaluation.
|
|
45
|
+
|
|
46
|
+
Attributes:
|
|
47
|
+
is_safe: True when the operation is allowed without prompting.
|
|
48
|
+
reason: Explanation when the check fails.
|
|
49
|
+
requires_approval: True when user must explicitly confirm.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
is_safe: bool
|
|
53
|
+
reason: str | None = None
|
|
54
|
+
requires_approval: bool = False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class SafetyLayer:
|
|
58
|
+
"""Detects destructive operations and requires user approval."""
|
|
59
|
+
|
|
60
|
+
DESTRUCTIVE_PATTERNS: ClassVar[list[tuple[str, str]]] = [
|
|
61
|
+
(r"rm\s+-rf?\s+", "Recursive file deletion"),
|
|
62
|
+
(r"rm\s+-r\s+/", "Deleting from root"),
|
|
63
|
+
(r"DROP\s+TABLE", "SQL table drop"),
|
|
64
|
+
(r"DROP\s+DATABASE", "SQL database drop"),
|
|
65
|
+
(r"DELETE\s+FROM\s+\w+\s*;?\s*$", "SQL delete without WHERE"),
|
|
66
|
+
(r"TRUNCATE\s+TABLE", "SQL table truncation"),
|
|
67
|
+
(r"git\s+push\s+--force", "Force push"),
|
|
68
|
+
(r"git\s+push\s+-f\s+", "Force push"),
|
|
69
|
+
(r"git\s+clean\s+-[fd]", "Git clean"),
|
|
70
|
+
(r"git\s+reset\s+--hard", "Hard reset"),
|
|
71
|
+
(r"chmod\s+-R\s+777", "Insecure permissions"),
|
|
72
|
+
(r">\s*/dev/sd[a-z]", "Writing to disk device"),
|
|
73
|
+
(r"mkfs\.", "Formatting filesystem"),
|
|
74
|
+
(r"dd\s+if=", "Direct disk operation"),
|
|
75
|
+
(r"curl.*\|\s*bash", "Piping curl to bash"),
|
|
76
|
+
(r"wget.*\|\s*bash", "Piping wget to bash"),
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
_compiled: ClassVar[list[tuple[re.Pattern[str], str]] | None] = None
|
|
80
|
+
|
|
81
|
+
def __init__(self, dry_run: bool = False) -> None:
|
|
82
|
+
"""Initialize the safety layer.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
dry_run: If True, always block dangerous actions without prompting.
|
|
86
|
+
"""
|
|
87
|
+
self._dry_run = dry_run
|
|
88
|
+
|
|
89
|
+
if SafetyLayer._compiled is None:
|
|
90
|
+
SafetyLayer._compiled = [
|
|
91
|
+
(re.compile(pattern, re.IGNORECASE), label)
|
|
92
|
+
for pattern, label in self.DESTRUCTIVE_PATTERNS
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
def check_command(self, command: str) -> SafetyCheck:
|
|
96
|
+
"""Evaluate a shell command against destructive patterns.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
command: The shell command to inspect.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
SafetyCheck indicating whether the command is safe.
|
|
103
|
+
"""
|
|
104
|
+
assert self._compiled is not None
|
|
105
|
+
for pattern, label in self._compiled:
|
|
106
|
+
if pattern.search(command):
|
|
107
|
+
return SafetyCheck(
|
|
108
|
+
is_safe=False,
|
|
109
|
+
reason=f"Destructive operation detected: {label}",
|
|
110
|
+
requires_approval=True,
|
|
111
|
+
)
|
|
112
|
+
return SafetyCheck(is_safe=True)
|
|
113
|
+
|
|
114
|
+
def check_file_write(self, path: str, project_dir: Path) -> SafetyCheck:
|
|
115
|
+
"""Evaluate whether writing to path is allowed.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
path: The file path to write to.
|
|
119
|
+
project_dir: The project root directory.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
SafetyCheck with the evaluation result.
|
|
123
|
+
"""
|
|
124
|
+
traversal = self.check_path_traversal(path, project_dir)
|
|
125
|
+
if not traversal.is_safe:
|
|
126
|
+
return traversal
|
|
127
|
+
|
|
128
|
+
filename = Path(path).name.lower()
|
|
129
|
+
if filename in _SENSITIVE_FILENAMES:
|
|
130
|
+
return SafetyCheck(
|
|
131
|
+
is_safe=False,
|
|
132
|
+
reason=f"Writing to sensitive file '{filename}' which may contain secrets.",
|
|
133
|
+
requires_approval=True,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
suffix = Path(path).suffix.lower()
|
|
137
|
+
if suffix in {".pem", ".key", ".p12", ".pfx"}:
|
|
138
|
+
return SafetyCheck(
|
|
139
|
+
is_safe=False,
|
|
140
|
+
reason=f"Writing to cryptographic key file ('{suffix}').",
|
|
141
|
+
requires_approval=True,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
return SafetyCheck(is_safe=True)
|
|
145
|
+
|
|
146
|
+
def check_path_traversal(self, path: str, project_dir: Path) -> SafetyCheck:
|
|
147
|
+
"""Detect path-traversal attempts escaping the project root.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
path: Target file path.
|
|
151
|
+
project_dir: Project root directory.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
SafetyCheck — unsafe if resolved path is outside project.
|
|
155
|
+
"""
|
|
156
|
+
try:
|
|
157
|
+
resolved = (project_dir / path).resolve()
|
|
158
|
+
project_resolved = project_dir.resolve()
|
|
159
|
+
if not str(resolved).startswith(str(project_resolved)):
|
|
160
|
+
return SafetyCheck(
|
|
161
|
+
is_safe=False,
|
|
162
|
+
reason=f"Path traversal: '{path}' resolves outside project directory.",
|
|
163
|
+
requires_approval=False,
|
|
164
|
+
)
|
|
165
|
+
except (OSError, ValueError) as exc:
|
|
166
|
+
return SafetyCheck(is_safe=False, reason=f"Invalid path '{path}': {exc}")
|
|
167
|
+
|
|
168
|
+
return SafetyCheck(is_safe=True)
|
|
169
|
+
|
|
170
|
+
async def request_approval(self, action: str, reason: str) -> bool:
|
|
171
|
+
"""Prompt user for explicit approval of a dangerous action.
|
|
172
|
+
|
|
173
|
+
In dry-run mode, always returns False.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
action: Description of the action.
|
|
177
|
+
reason: Why it was flagged.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
True if user approves, False otherwise.
|
|
181
|
+
"""
|
|
182
|
+
if self._dry_run:
|
|
183
|
+
console.print(
|
|
184
|
+
Panel(
|
|
185
|
+
Text.assemble(
|
|
186
|
+
("BLOCKED (dry-run): ", "bold red"),
|
|
187
|
+
(action, "white"),
|
|
188
|
+
("\nReason: ", "bold yellow"),
|
|
189
|
+
(reason, "yellow"),
|
|
190
|
+
),
|
|
191
|
+
title="[bold red]Safety Layer[/bold red]",
|
|
192
|
+
border_style="red",
|
|
193
|
+
)
|
|
194
|
+
)
|
|
195
|
+
return False
|
|
196
|
+
|
|
197
|
+
console.print(
|
|
198
|
+
Panel(
|
|
199
|
+
Text.assemble(
|
|
200
|
+
("Action: ", "bold white"),
|
|
201
|
+
(action, "white"),
|
|
202
|
+
("\nReason: ", "bold yellow"),
|
|
203
|
+
(reason, "yellow"),
|
|
204
|
+
),
|
|
205
|
+
title="[bold red]Dangerous Operation[/bold red]",
|
|
206
|
+
border_style="red",
|
|
207
|
+
)
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
answer = Prompt.ask("[bold]Allow?[/bold]", choices=["y", "n"], default="n")
|
|
211
|
+
return answer.lower() == "y"
|
|
212
|
+
|
|
213
|
+
async def guard_command(self, command: str) -> bool:
|
|
214
|
+
"""Check command and prompt for approval if needed.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
command: Shell command to evaluate.
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
True if command may proceed.
|
|
221
|
+
|
|
222
|
+
Raises:
|
|
223
|
+
SafetyError: If command is blocked.
|
|
224
|
+
"""
|
|
225
|
+
check = self.check_command(command)
|
|
226
|
+
if check.is_safe:
|
|
227
|
+
return True
|
|
228
|
+
|
|
229
|
+
if not check.requires_approval:
|
|
230
|
+
raise SafetyError(check.reason or "Operation blocked")
|
|
231
|
+
|
|
232
|
+
approved = await self.request_approval(command, check.reason or "Destructive operation")
|
|
233
|
+
if not approved:
|
|
234
|
+
raise SafetyError(f"User denied: {check.reason}")
|
|
235
|
+
return True
|