nexus-coding-agent 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.
- nexus/__init__.py +4 -0
- nexus/application/__init__.py +2 -0
- nexus/application/approval_service.py +130 -0
- nexus/application/diff_service.py +177 -0
- nexus/application/event_publisher.py +180 -0
- nexus/application/execution_context.py +29 -0
- nexus/application/execution_ledger.py +126 -0
- nexus/application/model_call_context.py +29 -0
- nexus/application/observed_model_gateway.py +194 -0
- nexus/application/plan_approval_service.py +250 -0
- nexus/application/planning.py +668 -0
- nexus/application/runtime.py +624 -0
- nexus/application/session_service.py +411 -0
- nexus/application/structured_output.py +96 -0
- nexus/application/telemetry.py +396 -0
- nexus/application/telemetry_subscriber.py +216 -0
- nexus/application/tool_runtime.py +496 -0
- nexus/application/tracing_dispatcher.py +283 -0
- nexus/application/validation.py +293 -0
- nexus/config/__init__.py +17 -0
- nexus/config/loader.py +253 -0
- nexus/config/models.py +310 -0
- nexus/context/__init__.py +6 -0
- nexus/context/budget.py +24 -0
- nexus/context/builder.py +92 -0
- nexus/context/chunking.py +65 -0
- nexus/context/explorer.py +246 -0
- nexus/context/integration.py +63 -0
- nexus/context/manager.py +328 -0
- nexus/context/retrieval.py +261 -0
- nexus/domain/__init__.py +2 -0
- nexus/domain/agent_decision.py +113 -0
- nexus/domain/agent_state.py +49 -0
- nexus/domain/approvals.py +64 -0
- nexus/domain/context.py +108 -0
- nexus/domain/exploration.py +86 -0
- nexus/domain/mcp.py +51 -0
- nexus/domain/model.py +173 -0
- nexus/domain/observability.py +326 -0
- nexus/domain/persistence.py +122 -0
- nexus/domain/planning.py +329 -0
- nexus/domain/ports/__init__.py +61 -0
- nexus/domain/ports/agent_decision.py +9 -0
- nexus/domain/ports/approval_unit_of_work.py +27 -0
- nexus/domain/ports/checkpoint_provider.py +11 -0
- nexus/domain/ports/context.py +71 -0
- nexus/domain/ports/events.py +21 -0
- nexus/domain/ports/graph_runtime.py +30 -0
- nexus/domain/ports/mcp.py +23 -0
- nexus/domain/ports/model_gateway.py +21 -0
- nexus/domain/ports/model_input_budget.py +15 -0
- nexus/domain/ports/planning.py +62 -0
- nexus/domain/ports/repositories.py +54 -0
- nexus/domain/ports/repository_context.py +18 -0
- nexus/domain/ports/session_unit_of_work.py +33 -0
- nexus/domain/ports/skills.py +41 -0
- nexus/domain/ports/tooling.py +39 -0
- nexus/domain/ports/tracer.py +13 -0
- nexus/domain/ports/validation.py +32 -0
- nexus/domain/runtime_events.py +445 -0
- nexus/domain/skills.py +129 -0
- nexus/domain/tooling.py +163 -0
- nexus/domain/validation.py +161 -0
- nexus/errors/__init__.py +82 -0
- nexus/evaluation/__init__.py +7 -0
- nexus/evaluation/assertions.py +4 -0
- nexus/evaluation/evaluator.py +263 -0
- nexus/evaluation/fixtures.py +36 -0
- nexus/evaluation/harness.py +606 -0
- nexus/evaluation/loader.py +415 -0
- nexus/evaluation/metrics.py +27 -0
- nexus/evaluation/models.py +362 -0
- nexus/evaluation/report.py +43 -0
- nexus/evaluation/runner.py +480 -0
- nexus/infrastructure/__init__.py +2 -0
- nexus/infrastructure/asyncio_compat.py +13 -0
- nexus/infrastructure/bootstrap/__init__.py +17 -0
- nexus/infrastructure/bootstrap/composition.py +663 -0
- nexus/infrastructure/checkpoint/__init__.py +5 -0
- nexus/infrastructure/checkpoint/postgres.py +62 -0
- nexus/infrastructure/database.py +39 -0
- nexus/infrastructure/embedding.py +61 -0
- nexus/infrastructure/graph/__init__.py +6 -0
- nexus/infrastructure/graph/day4_runtime.py +966 -0
- nexus/infrastructure/graph/langgraph_runtime.py +172 -0
- nexus/infrastructure/mcp/__init__.py +5 -0
- nexus/infrastructure/mcp/manager.py +380 -0
- nexus/infrastructure/model_gateway/__init__.py +6 -0
- nexus/infrastructure/model_gateway/openai_compatible.py +345 -0
- nexus/infrastructure/observability/__init__.py +7 -0
- nexus/infrastructure/observability/console.py +31 -0
- nexus/infrastructure/observability/langsmith.py +236 -0
- nexus/infrastructure/observability/structured_logger.py +70 -0
- nexus/infrastructure/persistence/__init__.py +13 -0
- nexus/infrastructure/persistence/approval_unit_of_work.py +74 -0
- nexus/infrastructure/persistence/models.py +146 -0
- nexus/infrastructure/persistence/repositories.py +326 -0
- nexus/infrastructure/persistence/unit_of_work.py +82 -0
- nexus/infrastructure/repository_files.py +117 -0
- nexus/infrastructure/sandbox/__init__.py +5 -0
- nexus/infrastructure/sandbox/local_process.py +353 -0
- nexus/infrastructure/semantic.py +276 -0
- nexus/infrastructure/semantic_models.py +56 -0
- nexus/interfaces/__init__.py +2 -0
- nexus/interfaces/cli/__init__.py +2 -0
- nexus/interfaces/cli/__main__.py +6 -0
- nexus/interfaces/cli/app.py +266 -0
- nexus/interfaces/cli/renderer.py +118 -0
- nexus/security/__init__.py +14 -0
- nexus/security/approval_policies.py +73 -0
- nexus/security/command_policy.py +117 -0
- nexus/security/executables.py +107 -0
- nexus/security/git_commands.py +65 -0
- nexus/security/workspace.py +93 -0
- nexus/skills/__init__.py +7 -0
- nexus/skills/builtin/__init__.py +1 -0
- nexus/skills/builtin/debug-python/SKILL.md +26 -0
- nexus/skills/builtin/review-repository/SKILL.md +26 -0
- nexus/skills/builtin/write-tests/SKILL.md +26 -0
- nexus/skills/loader.py +439 -0
- nexus/skills/registry.py +146 -0
- nexus/skills/selector.py +152 -0
- nexus/tools/__init__.py +6 -0
- nexus/tools/editing.py +312 -0
- nexus/tools/mcp.py +160 -0
- nexus/tools/native.py +654 -0
- nexus/tools/registry.py +27 -0
- nexus_coding_agent-0.1.0.dist-info/METADATA +142 -0
- nexus_coding_agent-0.1.0.dist-info/RECORD +131 -0
- nexus_coding_agent-0.1.0.dist-info/WHEEL +4 -0
- nexus_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Day 5 read-only index filesystem access, contained and gitignore-aware."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from pathspec import GitIgnoreSpec
|
|
8
|
+
|
|
9
|
+
from nexus.context.chunking import normalize_text
|
|
10
|
+
from nexus.errors import NexusError
|
|
11
|
+
from nexus.security.workspace import WorkspaceGuard
|
|
12
|
+
|
|
13
|
+
EXCLUDED_DIRECTORIES = frozenset(
|
|
14
|
+
{
|
|
15
|
+
".git",
|
|
16
|
+
"node_modules",
|
|
17
|
+
".venv",
|
|
18
|
+
"venv",
|
|
19
|
+
"__pycache__",
|
|
20
|
+
"dist",
|
|
21
|
+
"build",
|
|
22
|
+
"target",
|
|
23
|
+
"coverage",
|
|
24
|
+
".pytest_cache",
|
|
25
|
+
".mypy_cache",
|
|
26
|
+
".ruff_cache",
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class _Scan:
|
|
33
|
+
files: tuple[tuple[str, str], ...]
|
|
34
|
+
scanned: int
|
|
35
|
+
skipped: int
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RepositoryFiles:
|
|
39
|
+
def __init__(self, workspace: Path, max_file_size_bytes: int = 1048576) -> None:
|
|
40
|
+
self.guard = WorkspaceGuard(workspace)
|
|
41
|
+
self._maximum = max_file_size_bytes
|
|
42
|
+
|
|
43
|
+
def read(self, path: str) -> str:
|
|
44
|
+
resolved = self.guard.resolve_existing(path, require_file=True)
|
|
45
|
+
if resolved.is_symlink() or resolved.is_junction():
|
|
46
|
+
raise ValueError("Linked files are not indexable.")
|
|
47
|
+
with resolved.open("rb") as stream:
|
|
48
|
+
raw = stream.read(self._maximum + 1)
|
|
49
|
+
if len(raw) > self._maximum or b"\0" in raw:
|
|
50
|
+
raise ValueError("Unsupported or oversized source.")
|
|
51
|
+
return normalize_text(raw.decode("utf-8-sig"))
|
|
52
|
+
|
|
53
|
+
def allowed(self, relative: str) -> bool:
|
|
54
|
+
path = Path(relative)
|
|
55
|
+
if (
|
|
56
|
+
path.is_absolute()
|
|
57
|
+
or ".." in path.parts
|
|
58
|
+
or any(part in EXCLUDED_DIRECTORIES for part in path.parts)
|
|
59
|
+
or path.name == "AGENTS.md"
|
|
60
|
+
):
|
|
61
|
+
return False
|
|
62
|
+
try:
|
|
63
|
+
self.guard.resolve_existing(relative, require_file=True)
|
|
64
|
+
rules: list[str] = []
|
|
65
|
+
current = Path()
|
|
66
|
+
for part in (*path.parts[:-1], None):
|
|
67
|
+
ignore = current / ".gitignore"
|
|
68
|
+
try:
|
|
69
|
+
content = self.read(ignore.as_posix())
|
|
70
|
+
# Match relative to each ignore file's directory. A parent directory
|
|
71
|
+
# exclusion is final: Git cannot reinclude children of an excluded dir.
|
|
72
|
+
spec = GitIgnoreSpec.from_lines(content.splitlines())
|
|
73
|
+
suffix = path.relative_to(current).as_posix()
|
|
74
|
+
components = suffix.split("/")
|
|
75
|
+
for length in range(1, len(components)):
|
|
76
|
+
if spec.match_file("/".join(components[:length]) + "/"):
|
|
77
|
+
return False
|
|
78
|
+
result = spec.check_file(suffix)
|
|
79
|
+
if result.include is not None:
|
|
80
|
+
rules.append("ignored" if result.include else "included")
|
|
81
|
+
except (OSError, ValueError, NexusError):
|
|
82
|
+
pass
|
|
83
|
+
if part is not None:
|
|
84
|
+
current /= part
|
|
85
|
+
return not rules or rules[-1] == "included"
|
|
86
|
+
except (OSError, ValueError, NexusError):
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
def scan(self) -> _Scan:
|
|
90
|
+
files: list[tuple[str, str]] = []
|
|
91
|
+
scanned = skipped = 0
|
|
92
|
+
for current, directories, names in os.walk(self.guard.root, followlinks=False):
|
|
93
|
+
folder = Path(current)
|
|
94
|
+
directories[:] = sorted(
|
|
95
|
+
name
|
|
96
|
+
for name in directories
|
|
97
|
+
if name not in EXCLUDED_DIRECTORIES
|
|
98
|
+
and not (folder / name).is_symlink()
|
|
99
|
+
and not (folder / name).is_junction()
|
|
100
|
+
)
|
|
101
|
+
for name in sorted(names):
|
|
102
|
+
candidate = folder / name
|
|
103
|
+
relative = candidate.relative_to(self.guard.root).as_posix()
|
|
104
|
+
scanned += 1
|
|
105
|
+
if candidate.is_symlink() or candidate.is_junction() or not self.allowed(relative):
|
|
106
|
+
skipped += 1
|
|
107
|
+
continue
|
|
108
|
+
try:
|
|
109
|
+
content = self.read(relative)
|
|
110
|
+
except (OSError, ValueError, NexusError):
|
|
111
|
+
skipped += 1
|
|
112
|
+
continue
|
|
113
|
+
if not content:
|
|
114
|
+
skipped += 1
|
|
115
|
+
continue
|
|
116
|
+
files.append((relative, content))
|
|
117
|
+
return _Scan(tuple(files), scanned, skipped)
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""Standard-library local process isolation behind the SandboxExecutor port."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import signal
|
|
8
|
+
import subprocess
|
|
9
|
+
import time
|
|
10
|
+
from asyncio.subprocess import Process
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import cast
|
|
14
|
+
|
|
15
|
+
from nexus.domain.ports.tooling import CommandPolicy
|
|
16
|
+
from nexus.domain.tooling import (
|
|
17
|
+
PolicyDecision,
|
|
18
|
+
RiskLevel,
|
|
19
|
+
SandboxRequest,
|
|
20
|
+
SandboxResult,
|
|
21
|
+
ToolError,
|
|
22
|
+
)
|
|
23
|
+
from nexus.errors import NexusError
|
|
24
|
+
from nexus.security.executables import TrustedExecutables
|
|
25
|
+
from nexus.security.workspace import WorkspaceGuard
|
|
26
|
+
|
|
27
|
+
_OUTPUT_LIMIT_BYTES = 1_048_576
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class LocalProcessSandbox:
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
workspace_guard: WorkspaceGuard,
|
|
34
|
+
command_policy: CommandPolicy,
|
|
35
|
+
executables: TrustedExecutables,
|
|
36
|
+
) -> None:
|
|
37
|
+
self._workspace_guard = workspace_guard
|
|
38
|
+
self._command_policy = command_policy
|
|
39
|
+
self._executables = executables
|
|
40
|
+
|
|
41
|
+
async def execute(self, request: SandboxRequest) -> SandboxResult:
|
|
42
|
+
started = time.perf_counter()
|
|
43
|
+
normalized_argv = self._executables.normalize_argv(request.argv)
|
|
44
|
+
arguments: dict[str, object] = {
|
|
45
|
+
"argv": normalized_argv,
|
|
46
|
+
"cwd": request.cwd,
|
|
47
|
+
"timeout_seconds": request.timeout_seconds,
|
|
48
|
+
}
|
|
49
|
+
proposed_risk = self._command_policy.classify(
|
|
50
|
+
operation=request.operation,
|
|
51
|
+
arguments=arguments,
|
|
52
|
+
)
|
|
53
|
+
if proposed_risk is RiskLevel.DANGEROUS:
|
|
54
|
+
return _denied_result(request, normalized_argv, started)
|
|
55
|
+
try:
|
|
56
|
+
cwd = self._workspace_guard.resolve_existing(
|
|
57
|
+
request.cwd,
|
|
58
|
+
require_directory=True,
|
|
59
|
+
)
|
|
60
|
+
except NexusError as exc:
|
|
61
|
+
return SandboxResult(
|
|
62
|
+
argv=normalized_argv,
|
|
63
|
+
cwd=request.cwd,
|
|
64
|
+
risk_level=RiskLevel.DANGEROUS,
|
|
65
|
+
policy_decision=PolicyDecision.DENIED,
|
|
66
|
+
exit_code=None,
|
|
67
|
+
stdout="",
|
|
68
|
+
stderr="",
|
|
69
|
+
duration_ms=_duration_ms(started),
|
|
70
|
+
timed_out=False,
|
|
71
|
+
output_truncated=False,
|
|
72
|
+
error=_tool_error(exc),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if os.name == "nt" and isinstance(
|
|
76
|
+
asyncio.get_running_loop(), asyncio.SelectorEventLoop
|
|
77
|
+
):
|
|
78
|
+
return await asyncio.to_thread(
|
|
79
|
+
self._execute_on_windows_proactor,
|
|
80
|
+
request,
|
|
81
|
+
normalized_argv,
|
|
82
|
+
cwd,
|
|
83
|
+
proposed_risk,
|
|
84
|
+
started,
|
|
85
|
+
)
|
|
86
|
+
return await self._execute_allowed(
|
|
87
|
+
request,
|
|
88
|
+
normalized_argv,
|
|
89
|
+
cwd,
|
|
90
|
+
proposed_risk,
|
|
91
|
+
started,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def _execute_on_windows_proactor(
|
|
95
|
+
self,
|
|
96
|
+
request: SandboxRequest,
|
|
97
|
+
normalized_argv: list[str],
|
|
98
|
+
cwd: Path,
|
|
99
|
+
proposed_risk: RiskLevel,
|
|
100
|
+
started: float,
|
|
101
|
+
) -> SandboxResult:
|
|
102
|
+
"""Isolate Windows subprocess support from Day 2's selector loop."""
|
|
103
|
+
|
|
104
|
+
loop_factory = cast(
|
|
105
|
+
Callable[[], asyncio.AbstractEventLoop],
|
|
106
|
+
vars(asyncio)["ProactorEventLoop"],
|
|
107
|
+
)
|
|
108
|
+
loop = loop_factory()
|
|
109
|
+
try:
|
|
110
|
+
return loop.run_until_complete(
|
|
111
|
+
self._execute_allowed(
|
|
112
|
+
request,
|
|
113
|
+
normalized_argv,
|
|
114
|
+
cwd,
|
|
115
|
+
proposed_risk,
|
|
116
|
+
started,
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
finally:
|
|
120
|
+
loop.close()
|
|
121
|
+
|
|
122
|
+
async def _execute_allowed(
|
|
123
|
+
self,
|
|
124
|
+
request: SandboxRequest,
|
|
125
|
+
normalized_argv: list[str],
|
|
126
|
+
cwd: Path,
|
|
127
|
+
proposed_risk: RiskLevel,
|
|
128
|
+
started: float,
|
|
129
|
+
) -> SandboxResult:
|
|
130
|
+
|
|
131
|
+
process: Process | None = None
|
|
132
|
+
stdout_task: asyncio.Task[tuple[bytes, bool]] | None = None
|
|
133
|
+
stderr_task: asyncio.Task[tuple[bytes, bool]] | None = None
|
|
134
|
+
try:
|
|
135
|
+
process = await self._start_process(normalized_argv, cwd)
|
|
136
|
+
assert process.stdout is not None
|
|
137
|
+
assert process.stderr is not None
|
|
138
|
+
stdout_task = asyncio.create_task(_read_bounded(process.stdout))
|
|
139
|
+
stderr_task = asyncio.create_task(_read_bounded(process.stderr))
|
|
140
|
+
try:
|
|
141
|
+
await asyncio.wait_for(process.wait(), timeout=request.timeout_seconds)
|
|
142
|
+
except TimeoutError:
|
|
143
|
+
await _terminate(process)
|
|
144
|
+
stdout, stdout_truncated = await _finish_reader(stdout_task)
|
|
145
|
+
stderr, stderr_truncated = await _finish_reader(stderr_task)
|
|
146
|
+
return SandboxResult(
|
|
147
|
+
argv=normalized_argv,
|
|
148
|
+
cwd=self._workspace_guard.relative_display(cwd),
|
|
149
|
+
risk_level=proposed_risk,
|
|
150
|
+
policy_decision=PolicyDecision.ALLOWED,
|
|
151
|
+
exit_code=None,
|
|
152
|
+
stdout=_decode(stdout),
|
|
153
|
+
stderr=_decode(stderr),
|
|
154
|
+
duration_ms=_duration_ms(started),
|
|
155
|
+
timed_out=True,
|
|
156
|
+
output_truncated=stdout_truncated or stderr_truncated,
|
|
157
|
+
error=ToolError(
|
|
158
|
+
"SANDBOX_TIMEOUT",
|
|
159
|
+
"The allowed command exceeded its timeout.",
|
|
160
|
+
True,
|
|
161
|
+
),
|
|
162
|
+
)
|
|
163
|
+
stdout, stdout_truncated = await _finish_reader(stdout_task)
|
|
164
|
+
stderr, stderr_truncated = await _finish_reader(stderr_task)
|
|
165
|
+
exit_code = process.returncode
|
|
166
|
+
error = (
|
|
167
|
+
None
|
|
168
|
+
if exit_code == 0
|
|
169
|
+
else ToolError(
|
|
170
|
+
"COMMAND_EXIT_NONZERO",
|
|
171
|
+
"The allowed command exited with a non-zero status.",
|
|
172
|
+
False,
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
return SandboxResult(
|
|
176
|
+
argv=normalized_argv,
|
|
177
|
+
cwd=self._workspace_guard.relative_display(cwd),
|
|
178
|
+
risk_level=proposed_risk,
|
|
179
|
+
policy_decision=PolicyDecision.ALLOWED,
|
|
180
|
+
exit_code=exit_code,
|
|
181
|
+
stdout=_decode(stdout),
|
|
182
|
+
stderr=_decode(stderr),
|
|
183
|
+
duration_ms=_duration_ms(started),
|
|
184
|
+
timed_out=False,
|
|
185
|
+
output_truncated=stdout_truncated or stderr_truncated,
|
|
186
|
+
error=error,
|
|
187
|
+
)
|
|
188
|
+
except asyncio.CancelledError:
|
|
189
|
+
if process is not None and process.returncode is None:
|
|
190
|
+
await _terminate(process)
|
|
191
|
+
raise
|
|
192
|
+
except Exception:
|
|
193
|
+
if process is not None and process.returncode is None:
|
|
194
|
+
await _terminate(process)
|
|
195
|
+
_cancel_reader(stdout_task)
|
|
196
|
+
_cancel_reader(stderr_task)
|
|
197
|
+
return SandboxResult(
|
|
198
|
+
argv=normalized_argv,
|
|
199
|
+
cwd=self._workspace_guard.relative_display(cwd),
|
|
200
|
+
risk_level=proposed_risk,
|
|
201
|
+
policy_decision=PolicyDecision.ALLOWED,
|
|
202
|
+
exit_code=None,
|
|
203
|
+
stdout="",
|
|
204
|
+
stderr="",
|
|
205
|
+
duration_ms=_duration_ms(started),
|
|
206
|
+
timed_out=False,
|
|
207
|
+
output_truncated=False,
|
|
208
|
+
error=ToolError(
|
|
209
|
+
"SANDBOX_EXECUTION_ERROR",
|
|
210
|
+
"The allowed command could not be safely executed.",
|
|
211
|
+
False,
|
|
212
|
+
),
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
async def _start_process(self, argv: list[str], cwd: Path) -> Process:
|
|
216
|
+
environment = _minimum_environment(self._workspace_guard.root)
|
|
217
|
+
if os.name == "nt":
|
|
218
|
+
return await asyncio.create_subprocess_exec(
|
|
219
|
+
*argv,
|
|
220
|
+
cwd=cwd,
|
|
221
|
+
env=environment,
|
|
222
|
+
stdin=subprocess.DEVNULL,
|
|
223
|
+
stdout=asyncio.subprocess.PIPE,
|
|
224
|
+
stderr=asyncio.subprocess.PIPE,
|
|
225
|
+
creationflags=cast(
|
|
226
|
+
int,
|
|
227
|
+
vars(subprocess)["CREATE_NEW_PROCESS_GROUP"],
|
|
228
|
+
),
|
|
229
|
+
)
|
|
230
|
+
return await asyncio.create_subprocess_exec(
|
|
231
|
+
*argv,
|
|
232
|
+
cwd=cwd,
|
|
233
|
+
env=environment,
|
|
234
|
+
stdin=subprocess.DEVNULL,
|
|
235
|
+
stdout=asyncio.subprocess.PIPE,
|
|
236
|
+
stderr=asyncio.subprocess.PIPE,
|
|
237
|
+
start_new_session=True,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
async def _read_bounded(stream: asyncio.StreamReader) -> tuple[bytes, bool]:
|
|
242
|
+
retained = bytearray()
|
|
243
|
+
truncated = False
|
|
244
|
+
while chunk := await stream.read(65_536):
|
|
245
|
+
remaining = _OUTPUT_LIMIT_BYTES - len(retained)
|
|
246
|
+
if remaining > 0:
|
|
247
|
+
retained.extend(chunk[:remaining])
|
|
248
|
+
if len(chunk) > remaining:
|
|
249
|
+
truncated = True
|
|
250
|
+
return bytes(retained), truncated
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
async def _finish_reader(task: asyncio.Task[tuple[bytes, bool]]) -> tuple[bytes, bool]:
|
|
254
|
+
try:
|
|
255
|
+
return await asyncio.wait_for(task, timeout=2.0)
|
|
256
|
+
except TimeoutError:
|
|
257
|
+
task.cancel()
|
|
258
|
+
return b"", True
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _cancel_reader(task: asyncio.Task[tuple[bytes, bool]] | None) -> None:
|
|
262
|
+
if task is not None and not task.done():
|
|
263
|
+
task.cancel()
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
async def _terminate(process: Process) -> None:
|
|
267
|
+
if process.returncode is not None:
|
|
268
|
+
return
|
|
269
|
+
try:
|
|
270
|
+
if os.name != "nt":
|
|
271
|
+
kill_process_group = getattr(os, "killpg") # noqa: B009
|
|
272
|
+
kill_process_group(process.pid, getattr(signal, "SIGKILL")) # noqa: B009
|
|
273
|
+
else:
|
|
274
|
+
process.kill()
|
|
275
|
+
except ProcessLookupError:
|
|
276
|
+
pass
|
|
277
|
+
await process.wait()
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _minimum_environment(workspace: Path) -> dict[str, str]:
|
|
281
|
+
keys = ["PATH"]
|
|
282
|
+
keys.extend(
|
|
283
|
+
["SystemRoot", "WINDIR", "PATHEXT", "TEMP", "TMP"]
|
|
284
|
+
if os.name == "nt"
|
|
285
|
+
else ["LANG", "LC_ALL", "TMPDIR"]
|
|
286
|
+
)
|
|
287
|
+
environment = {key: os.environ[key] for key in keys if key in os.environ}
|
|
288
|
+
if "PATH" in environment:
|
|
289
|
+
entries = []
|
|
290
|
+
for value in environment["PATH"].split(os.pathsep):
|
|
291
|
+
if not value or not Path(value).is_absolute():
|
|
292
|
+
continue
|
|
293
|
+
resolved = Path(value).resolve(strict=False)
|
|
294
|
+
if not _is_within(resolved, workspace):
|
|
295
|
+
entries.append(str(resolved))
|
|
296
|
+
environment["PATH"] = os.pathsep.join(entries)
|
|
297
|
+
environment.update(
|
|
298
|
+
{
|
|
299
|
+
"PYTHONIOENCODING": "utf-8",
|
|
300
|
+
"GIT_TERMINAL_PROMPT": "0",
|
|
301
|
+
"GIT_CONFIG_NOSYSTEM": "1",
|
|
302
|
+
"GIT_OPTIONAL_LOCKS": "0",
|
|
303
|
+
"GIT_CONFIG_GLOBAL": os.devnull,
|
|
304
|
+
}
|
|
305
|
+
)
|
|
306
|
+
return environment
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _denied_result(
|
|
310
|
+
request: SandboxRequest,
|
|
311
|
+
argv: list[str],
|
|
312
|
+
started: float,
|
|
313
|
+
) -> SandboxResult:
|
|
314
|
+
return SandboxResult(
|
|
315
|
+
argv=argv,
|
|
316
|
+
cwd=request.cwd,
|
|
317
|
+
risk_level=RiskLevel.DANGEROUS,
|
|
318
|
+
policy_decision=PolicyDecision.DENIED,
|
|
319
|
+
exit_code=None,
|
|
320
|
+
stdout="",
|
|
321
|
+
stderr="",
|
|
322
|
+
duration_ms=_duration_ms(started),
|
|
323
|
+
timed_out=False,
|
|
324
|
+
output_truncated=False,
|
|
325
|
+
error=ToolError(
|
|
326
|
+
"COMMAND_DENIED",
|
|
327
|
+
"The command is not allowed by the Day 3 security policy.",
|
|
328
|
+
False,
|
|
329
|
+
),
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _tool_error(error: NexusError) -> ToolError:
|
|
334
|
+
return ToolError(error.code, str(error), error.retryable)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _duration_ms(started: float) -> int:
|
|
338
|
+
return max(0, round((time.perf_counter() - started) * 1000))
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _decode(value: bytes) -> str:
|
|
342
|
+
return value.decode("utf-8", errors="replace")
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _is_within(path: Path, workspace: Path) -> bool:
|
|
346
|
+
normalized_path = os.path.normcase(str(path))
|
|
347
|
+
normalized_workspace = os.path.normcase(str(workspace))
|
|
348
|
+
try:
|
|
349
|
+
return os.path.commonpath([normalized_path, normalized_workspace]) == (
|
|
350
|
+
normalized_workspace
|
|
351
|
+
)
|
|
352
|
+
except ValueError:
|
|
353
|
+
return False
|