co-agent-coder 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.
- co_agent_coder-0.1.0.dist-info/METADATA +396 -0
- co_agent_coder-0.1.0.dist-info/RECORD +51 -0
- co_agent_coder-0.1.0.dist-info/WHEEL +5 -0
- co_agent_coder-0.1.0.dist-info/entry_points.txt +5 -0
- co_agent_coder-0.1.0.dist-info/licenses/LICENSE +201 -0
- co_agent_coder-0.1.0.dist-info/top_level.txt +1 -0
- coagent/__init__.py +84 -0
- coagent/__main__.py +6 -0
- coagent/agent/__init__.py +27 -0
- coagent/agent/budget.py +220 -0
- coagent/agent/loop.py +1102 -0
- coagent/agent/prompts.py +228 -0
- coagent/agent/state.py +181 -0
- coagent/cli.py +351 -0
- coagent/config.py +307 -0
- coagent/context/__init__.py +26 -0
- coagent/context/artifacts.py +431 -0
- coagent/context/repo_map.py +590 -0
- coagent/context/retrieval.py +375 -0
- coagent/context/tokens.py +93 -0
- coagent/evals/__init__.py +0 -0
- coagent/gitx/__init__.py +52 -0
- coagent/gitx/commit.py +91 -0
- coagent/gitx/durable.py +147 -0
- coagent/gitx/git.py +351 -0
- coagent/gitx/lock.py +157 -0
- coagent/gitx/log.py +85 -0
- coagent/gitx/resume.py +199 -0
- coagent/gitx/session.py +233 -0
- coagent/llm/__init__.py +83 -0
- coagent/llm/base.py +235 -0
- coagent/llm/grammar.py +306 -0
- coagent/llm/registry.py +147 -0
- coagent/llm/transformers_backend.py +326 -0
- coagent/py.typed +0 -0
- coagent/safety/__init__.py +24 -0
- coagent/safety/denylist.py +146 -0
- coagent/safety/policy.py +219 -0
- coagent/safety/redact.py +146 -0
- coagent/templates/config.toml +104 -0
- coagent/tools/__init__.py +155 -0
- coagent/tools/artifacts.py +307 -0
- coagent/tools/base.py +226 -0
- coagent/tools/fs.py +507 -0
- coagent/tools/notebooks.py +875 -0
- coagent/tools/pyt.py +240 -0
- coagent/tools/shell.py +558 -0
- coagent/ui/__init__.py +50 -0
- coagent/ui/gradio_app.py +353 -0
- coagent/ui/notebook.py +342 -0
- coagent/ui/render.py +422 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""The local `transformers` backend (DESIGN §4.2, §4.5).
|
|
2
|
+
|
|
3
|
+
Pinned to what a Turing T4 can actually do:
|
|
4
|
+
|
|
5
|
+
* **NF4 4-bit weights, fp16 compute.** T4 (SM 7.5) has no bf16.
|
|
6
|
+
* **`attn_implementation="sdpa"`.** FlashAttention-2 needs Ampere+.
|
|
7
|
+
* **No `torch.compile`.** Compile time exceeds most sessions' savings.
|
|
8
|
+
* **`PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True` set before torch import.**
|
|
9
|
+
|
|
10
|
+
Everything torch-related is imported inside methods so `import coagent` stays
|
|
11
|
+
light and the test suite runs without a GPU.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import contextlib
|
|
17
|
+
import gc
|
|
18
|
+
import os
|
|
19
|
+
import threading
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from .base import (
|
|
25
|
+
GenerationConfig,
|
|
26
|
+
GenerationResult,
|
|
27
|
+
LLMBackend,
|
|
28
|
+
Message,
|
|
29
|
+
StopReason,
|
|
30
|
+
Usage,
|
|
31
|
+
)
|
|
32
|
+
from .registry import MODELS, ModelSpec, get_model
|
|
33
|
+
|
|
34
|
+
__all__ = ["TransformersBackend", "OutOfMemoryLadder"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _torch():
|
|
38
|
+
"""Resolve torch at call time.
|
|
39
|
+
|
|
40
|
+
Module-level accessors keep `import coagent` torch-free while still letting a
|
|
41
|
+
test substitute a stub — patching a local `import` is impossible.
|
|
42
|
+
"""
|
|
43
|
+
import torch
|
|
44
|
+
|
|
45
|
+
return torch
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _transformers():
|
|
49
|
+
"""Resolve transformers at call time (same reason as `_torch`)."""
|
|
50
|
+
import transformers
|
|
51
|
+
|
|
52
|
+
return transformers
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(slots=True)
|
|
56
|
+
class OutOfMemoryLadder:
|
|
57
|
+
"""The OOM response order from DESIGN §4.5.
|
|
58
|
+
|
|
59
|
+
The session must never be lost to an OOM, and the model must never grow.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
step: int = 0
|
|
63
|
+
context_reduction: float = 0.25
|
|
64
|
+
max_new_tokens_reduction: float = 0.5
|
|
65
|
+
|
|
66
|
+
def next_action(self) -> str:
|
|
67
|
+
"""`shrink_context`, `halve_output`, `fallback_model`, or `stop`."""
|
|
68
|
+
return ("shrink_context", "halve_output", "fallback_model", "stop")[min(self.step, 3)]
|
|
69
|
+
|
|
70
|
+
def advance(self) -> str:
|
|
71
|
+
action = self.next_action()
|
|
72
|
+
self.step += 1
|
|
73
|
+
return action
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def exhausted(self) -> bool:
|
|
77
|
+
"""True once every recovery action has been tried (3 actions, then stop)."""
|
|
78
|
+
return self.step >= 3
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class TransformersBackend(LLMBackend):
|
|
82
|
+
"""A 4-bit causal LM on the local GPU."""
|
|
83
|
+
|
|
84
|
+
name = "transformers"
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
model_key: str = "qwen-coder-7b",
|
|
89
|
+
*,
|
|
90
|
+
spec: ModelSpec | None = None,
|
|
91
|
+
device_map: str | dict[str, Any] = "auto",
|
|
92
|
+
trust_remote_code: bool = False,
|
|
93
|
+
max_context: int | None = None,
|
|
94
|
+
) -> None:
|
|
95
|
+
# Set the allocator flag before torch ever initialises CUDA.
|
|
96
|
+
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
|
|
97
|
+
self.spec = spec or get_model(model_key)
|
|
98
|
+
self._device_map = device_map
|
|
99
|
+
self._trust = trust_remote_code
|
|
100
|
+
self._max_context_override = max_context
|
|
101
|
+
self._model: Any = None
|
|
102
|
+
self._tokenizer: Any = None
|
|
103
|
+
self._tokenizer_errors: set[str] = set()
|
|
104
|
+
self._lock = threading.Lock()
|
|
105
|
+
|
|
106
|
+
# ------------------------------------------------------------- lifecycle
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def model_id(self) -> str:
|
|
110
|
+
return self.spec.trailer_id
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def max_context(self) -> int:
|
|
114
|
+
return self._max_context_override or self.spec.max_context
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def loaded(self) -> bool:
|
|
118
|
+
return self._model is not None
|
|
119
|
+
|
|
120
|
+
def load(self) -> None:
|
|
121
|
+
if self._model is not None:
|
|
122
|
+
return
|
|
123
|
+
torch = _torch()
|
|
124
|
+
transformers = _transformers()
|
|
125
|
+
auto_model = transformers.AutoModelForCausalLM
|
|
126
|
+
auto_tokenizer = transformers.AutoTokenizer
|
|
127
|
+
|
|
128
|
+
quant = transformers.BitsAndBytesConfig(
|
|
129
|
+
load_in_4bit=True,
|
|
130
|
+
bnb_4bit_quant_type="nf4",
|
|
131
|
+
# T4 has no bf16. fp16 compute is the only correct choice.
|
|
132
|
+
bnb_4bit_compute_dtype=torch.float16,
|
|
133
|
+
bnb_4bit_use_double_quant=True,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
self._tokenizer = auto_tokenizer.from_pretrained(
|
|
137
|
+
self.spec.name, trust_remote_code=self._trust
|
|
138
|
+
)
|
|
139
|
+
self._model = auto_model.from_pretrained(
|
|
140
|
+
self.spec.name,
|
|
141
|
+
quantization_config=quant,
|
|
142
|
+
device_map=self._device_map,
|
|
143
|
+
attn_implementation="sdpa", # flash-attn 2 needs Ampere+
|
|
144
|
+
torch_dtype=torch.float16,
|
|
145
|
+
trust_remote_code=self._trust,
|
|
146
|
+
)
|
|
147
|
+
self._model.eval()
|
|
148
|
+
|
|
149
|
+
def unload(self) -> None:
|
|
150
|
+
"""Release VRAM. Required before `verify.gpu_commands` (DESIGN §4.6)."""
|
|
151
|
+
if self._model is None:
|
|
152
|
+
return
|
|
153
|
+
self._model = None
|
|
154
|
+
self._tokenizer = None
|
|
155
|
+
gc.collect()
|
|
156
|
+
try:
|
|
157
|
+
torch = _torch()
|
|
158
|
+
|
|
159
|
+
if torch.cuda.is_available():
|
|
160
|
+
torch.cuda.empty_cache()
|
|
161
|
+
torch.cuda.ipc_collect()
|
|
162
|
+
except Exception: # noqa: BLE001 - unloading must never raise
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
# ------------------------------------------------------------- tokenizer
|
|
166
|
+
|
|
167
|
+
def count_tokens(self, text: str) -> int:
|
|
168
|
+
"""Tokenizer truth for the budget (FR-CTX-6).
|
|
169
|
+
|
|
170
|
+
Falls back to a conservative character estimate only if the tokenizer
|
|
171
|
+
cannot be loaded at all, and records that it did so.
|
|
172
|
+
"""
|
|
173
|
+
if not text:
|
|
174
|
+
return 0
|
|
175
|
+
if self._tokenizer is None:
|
|
176
|
+
try:
|
|
177
|
+
self._tokenizer = _transformers().AutoTokenizer.from_pretrained(
|
|
178
|
+
self.spec.name, trust_remote_code=self._trust
|
|
179
|
+
)
|
|
180
|
+
except Exception: # noqa: BLE001 - no network / no weights
|
|
181
|
+
if "estimate" not in self._tokenizer_errors:
|
|
182
|
+
self._tokenizer_errors.add("estimate")
|
|
183
|
+
# ~4 chars/token is the usual ratio; over-estimating is the safe bias.
|
|
184
|
+
return max(1, len(text) // 3)
|
|
185
|
+
return len(self._tokenizer.encode(text, add_special_tokens=False))
|
|
186
|
+
|
|
187
|
+
def using_estimated_tokens(self) -> bool:
|
|
188
|
+
return "estimate" in self._tokenizer_errors
|
|
189
|
+
|
|
190
|
+
# ------------------------------------------------------------ generation
|
|
191
|
+
|
|
192
|
+
def generate(
|
|
193
|
+
self,
|
|
194
|
+
messages: list[Message],
|
|
195
|
+
config: GenerationConfig | None = None,
|
|
196
|
+
*,
|
|
197
|
+
on_token: Callable[[str], None] | None = None,
|
|
198
|
+
cancel: Callable[[], bool] | None = None,
|
|
199
|
+
) -> GenerationResult:
|
|
200
|
+
cfg = config or GenerationConfig()
|
|
201
|
+
self.load()
|
|
202
|
+
torch = _torch()
|
|
203
|
+
transformers = _transformers()
|
|
204
|
+
|
|
205
|
+
assert self._tokenizer is not None
|
|
206
|
+
prompt = self._tokenizer.apply_chat_template(
|
|
207
|
+
[m.to_dict() for m in messages], tokenize=False, add_generation_prompt=True
|
|
208
|
+
)
|
|
209
|
+
inputs = self._tokenizer(prompt, return_tensors="pt").to(self._model.device)
|
|
210
|
+
|
|
211
|
+
if cfg.seed:
|
|
212
|
+
torch.manual_seed(cfg.seed)
|
|
213
|
+
|
|
214
|
+
streamer = transformers.TextIteratorStreamer(
|
|
215
|
+
self._tokenizer, skip_prompt=True, skip_special_tokens=True
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
stop_criteria = _make_stop_criteria(cancel) if cancel is not None else None
|
|
219
|
+
stopping = transformers.StoppingCriteriaList([stop_criteria]) if stop_criteria else None
|
|
220
|
+
|
|
221
|
+
gen_kwargs: dict[str, Any] = {
|
|
222
|
+
"input_ids": inputs["input_ids"],
|
|
223
|
+
"attention_mask": inputs.get("attention_mask"),
|
|
224
|
+
"max_new_tokens": cfg.max_new_tokens,
|
|
225
|
+
"do_sample": cfg.temperature > 0,
|
|
226
|
+
"temperature": max(cfg.temperature, 1e-5),
|
|
227
|
+
"top_p": cfg.top_p,
|
|
228
|
+
"repetition_penalty": 1.05,
|
|
229
|
+
"pad_token_id": self._tokenizer.eos_token_id,
|
|
230
|
+
"streamer": streamer,
|
|
231
|
+
}
|
|
232
|
+
if stopping is not None:
|
|
233
|
+
gen_kwargs["stopping_criteria"] = stopping
|
|
234
|
+
|
|
235
|
+
error_holder: list[BaseException] = []
|
|
236
|
+
|
|
237
|
+
def _run() -> None:
|
|
238
|
+
try:
|
|
239
|
+
with torch.inference_mode():
|
|
240
|
+
self._model.generate(**gen_kwargs)
|
|
241
|
+
except BaseException as exc: # noqa: BLE001 - surfaced below
|
|
242
|
+
error_holder.append(exc)
|
|
243
|
+
with contextlib.suppress(Exception):
|
|
244
|
+
streamer.end()
|
|
245
|
+
|
|
246
|
+
worker = threading.Thread(target=_run, daemon=True)
|
|
247
|
+
worker.start()
|
|
248
|
+
|
|
249
|
+
pieces: list[str] = []
|
|
250
|
+
try:
|
|
251
|
+
for chunk in streamer:
|
|
252
|
+
if chunk:
|
|
253
|
+
pieces.append(chunk)
|
|
254
|
+
if on_token is not None:
|
|
255
|
+
on_token(chunk)
|
|
256
|
+
finally:
|
|
257
|
+
worker.join(timeout=1.0)
|
|
258
|
+
|
|
259
|
+
if error_holder:
|
|
260
|
+
exc = error_holder[0]
|
|
261
|
+
if _is_cuda_oom(exc):
|
|
262
|
+
self.unload()
|
|
263
|
+
raise _oom_from(exc) from exc
|
|
264
|
+
raise exc
|
|
265
|
+
|
|
266
|
+
text = "".join(pieces)
|
|
267
|
+
cancelled = cancel is not None and cancel()
|
|
268
|
+
stop = StopReason.CANCELLED if cancelled else StopReason.STOP
|
|
269
|
+
if not cancelled and len(pieces) and cfg.max_new_tokens and self.count_tokens(text) >= cfg.max_new_tokens:
|
|
270
|
+
stop = StopReason.LENGTH
|
|
271
|
+
|
|
272
|
+
return GenerationResult(
|
|
273
|
+
text=text,
|
|
274
|
+
stop_reason=stop,
|
|
275
|
+
usage=Usage(
|
|
276
|
+
prompt_tokens=int(inputs["input_ids"].shape[-1]),
|
|
277
|
+
completion_tokens=self.count_tokens(text),
|
|
278
|
+
),
|
|
279
|
+
model=self.model_id,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
# ---------------------------------------------------------------- ladder
|
|
283
|
+
|
|
284
|
+
def apply_oom_action(self, action: str, *, context: int, max_new_tokens: int) -> tuple[int, int, str]:
|
|
285
|
+
"""Return `(context, max_new_tokens, model_key)` for the next attempt."""
|
|
286
|
+
ladder = OutOfMemoryLadder()
|
|
287
|
+
if action == "shrink_context":
|
|
288
|
+
return int(context * (1 - ladder.context_reduction)), max_new_tokens, self.spec.key
|
|
289
|
+
if action == "halve_output":
|
|
290
|
+
return context, max(64, max_new_tokens // 2), self.spec.key
|
|
291
|
+
if action == "fallback_model":
|
|
292
|
+
fallback = MODELS["qwen-coder-3b"]
|
|
293
|
+
return fallback.max_context, max_new_tokens, fallback.key
|
|
294
|
+
return context, max_new_tokens, self.spec.key
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _make_stop_criteria(cancel: Callable[[], bool]):
|
|
298
|
+
"""Build a real HF `StoppingCriteria` that polls the cancel flag (FR-UX-8).
|
|
299
|
+
|
|
300
|
+
Imported lazily: this module must stay importable without transformers.
|
|
301
|
+
"""
|
|
302
|
+
class _StopOnCancel(_transformers().StoppingCriteria):
|
|
303
|
+
def __call__(self, input_ids, scores, **kwargs) -> bool: # noqa: ANN001, ARG002
|
|
304
|
+
return bool(cancel())
|
|
305
|
+
|
|
306
|
+
return _StopOnCancel()
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _is_cuda_oom(exc: BaseException) -> bool:
|
|
310
|
+
name = type(exc).__name__.lower()
|
|
311
|
+
text = str(exc).lower()
|
|
312
|
+
return (
|
|
313
|
+
"outofmemory" in name
|
|
314
|
+
or "out of memory" in text
|
|
315
|
+
# A wrapped torch OOM often surfaces only as the class name in the message.
|
|
316
|
+
or "outofmemoryerror" in text
|
|
317
|
+
or "cuda error: out of memory" in text
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _oom_from(exc: BaseException) -> RuntimeError:
|
|
322
|
+
return RuntimeError(
|
|
323
|
+
"CUDA is out of memory. coAgent unloaded the model and will retry with a "
|
|
324
|
+
"smaller context; if that fails it halves the output length, then falls "
|
|
325
|
+
"back to the 3B model. Your session and its checkpoints are intact."
|
|
326
|
+
)
|
coagent/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Safety: redaction, denylist, and the approval matrix (DESIGN §9)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .denylist import DEFAULT_DENIED, Denial, Denylist, deny_reasons
|
|
6
|
+
from .policy import Decision, Policy, RiskClass, SafetyClass, Verdict
|
|
7
|
+
from .redact import MASK, Redactor, contains_secret, redact, redact_obj
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"DEFAULT_DENIED",
|
|
11
|
+
"MASK",
|
|
12
|
+
"Decision",
|
|
13
|
+
"Denial",
|
|
14
|
+
"Denylist",
|
|
15
|
+
"Policy",
|
|
16
|
+
"Redactor",
|
|
17
|
+
"RiskClass",
|
|
18
|
+
"SafetyClass",
|
|
19
|
+
"Verdict",
|
|
20
|
+
"contains_secret",
|
|
21
|
+
"deny_reasons",
|
|
22
|
+
"redact",
|
|
23
|
+
"redact_obj",
|
|
24
|
+
]
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Denylist of destructive commands (PRD FR-TOL-5, FR-GIT-9).
|
|
2
|
+
|
|
3
|
+
This is a seatbelt, not a sandbox — the reality of a root-ish Colab shell.
|
|
4
|
+
The real control is the approval matrix in `policy.py`. What this module does
|
|
5
|
+
is evaluate a command *before* anything runs, deny rules before allow rules,
|
|
6
|
+
and return an actionable reason.
|
|
7
|
+
|
|
8
|
+
Matching strategy: a permissive tokenizer (shlex) so `git push --force` and
|
|
9
|
+
`git push --force=true` and `git push -f` are all caught, but a commit message
|
|
10
|
+
quoting `reset --hard` is not.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import shlex
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
__all__ = ["Denial", "Denylist", "DEFAULT_DENIED", "deny_reasons"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class Denial:
|
|
23
|
+
rule: str
|
|
24
|
+
reason: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# Pattern: token sequence of a dangerous command. Tokens are matched in order
|
|
28
|
+
# and may skip over "=" joins: `git push --force` and `git push --force=true`.
|
|
29
|
+
_RULES: tuple[tuple[str, tuple[str, ...], str], ...] = (
|
|
30
|
+
("git-force-push", ("git", "push", "--force*"), "force-push rewrites remote history (FR-GIT-9)."),
|
|
31
|
+
("git-force-push-short", ("git", "push", "-f"), "force-push rewrites remote history (FR-GIT-9)."),
|
|
32
|
+
("git-push-main", ("git", "push", "*", "main*"), "pushing main is never allowed (FR-GIT-8)."),
|
|
33
|
+
("git-reset-hard", ("git", "reset", "--hard*"), "hard reset discards work (FR-GIT-9)."),
|
|
34
|
+
(
|
|
35
|
+
"git-branch-delete",
|
|
36
|
+
("git", "branch", "-D*"),
|
|
37
|
+
"deleting branches is destructive (FR-GIT-9).",
|
|
38
|
+
),
|
|
39
|
+
("git-clean-fd", ("git", "clean", "-f*"), "git clean -f deletes untracked files."),
|
|
40
|
+
("rm-root", ("rm", "-rf", "/"), "rm -rf / is never valid here (FR-TOL-5)."),
|
|
41
|
+
("rm-home", ("rm", "-rf", "~"), "rm -rf ~ is never valid here."),
|
|
42
|
+
("chmod-root", ("chmod", "-R", "777", "/"), "chmod -R 777 / destroys a system."),
|
|
43
|
+
("chmod-home", ("chmod", "-R", "777", "~"), "chmod -R 777 ~ destroys a system."),
|
|
44
|
+
(
|
|
45
|
+
"pipe-to-shell",
|
|
46
|
+
("*", "|", "bash"),
|
|
47
|
+
"piping to a shell executes untrusted input; requires approval at a higher level.",
|
|
48
|
+
),
|
|
49
|
+
("curl-pipe-sh", ("curl*", "|", "sh"), "curl | sh executes remote code (FR-TOL-5)."),
|
|
50
|
+
("wget-pipe-sh", ("wget*", "|", "sh"), "wget | sh executes remote code (FR-TOL-5)."),
|
|
51
|
+
("dd-disk", ("dd", "*", "of=/dev/*"), "dd to a device overwrites disks."),
|
|
52
|
+
("fork-bomb", ("*", ":(){", "|", "*", "&"), "fork bombs violate Colab fair use."),
|
|
53
|
+
("shutdown", ("shutdown", "*"), "shutting down the VM kills the session (FR-REL-2)."),
|
|
54
|
+
("reboot", ("reboot", "*"), "rebooting the VM kills the session (FR-REL-2)."),
|
|
55
|
+
("rm-var", ("rm", "-rf", "/var"), "deleting /var destroys the runtime."),
|
|
56
|
+
("mkfs", ("mkfs*", "*"), "mkfs formats disks."),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _tokens(cmd: str) -> list[str]:
|
|
61
|
+
try:
|
|
62
|
+
return shlex.split(cmd)
|
|
63
|
+
except ValueError:
|
|
64
|
+
# Unbalanced quotes — split on whitespace instead so a denial still
|
|
65
|
+
# applies; the command would fail to run anyway.
|
|
66
|
+
return cmd.split()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _token_matches(actual: str, rule: str) -> bool:
|
|
70
|
+
if rule.endswith("*") and len(rule) > 1:
|
|
71
|
+
prefix = rule[:-1]
|
|
72
|
+
if actual.startswith(prefix):
|
|
73
|
+
return True
|
|
74
|
+
# Flags are case-sensitive in git, but `-d` (merged-only delete) is the
|
|
75
|
+
# common lowercase slip; treat any `-d`/`-D` variant as destructive.
|
|
76
|
+
if prefix == "-D":
|
|
77
|
+
return actual.startswith("-d") or actual.startswith("-D")
|
|
78
|
+
return False
|
|
79
|
+
if rule == "*":
|
|
80
|
+
return True
|
|
81
|
+
return actual == rule
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
DEFAULT_DENIED: tuple[str, ...] = tuple(rule[0] for rule in _RULES)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def deny_reasons(cmd: str, rules: tuple[str, ...] = DEFAULT_DENIED) -> tuple[Denial, ...]:
|
|
88
|
+
"""Return every denial that applies to `cmd`.
|
|
89
|
+
|
|
90
|
+
Deny rules are evaluated before allow rules (PRD FR-GIT-9): a force-push to
|
|
91
|
+
a coagent/* ref is still denied.
|
|
92
|
+
"""
|
|
93
|
+
tokens = _tokens(cmd)
|
|
94
|
+
if not tokens:
|
|
95
|
+
return ()
|
|
96
|
+
reasons: list[Denial] = []
|
|
97
|
+
for rule_id, pattern, reason in _RULES:
|
|
98
|
+
if rule_id not in rules:
|
|
99
|
+
continue
|
|
100
|
+
if _match_pattern(tokens, pattern):
|
|
101
|
+
reasons.append(Denial(rule=rule_id, reason=reason))
|
|
102
|
+
return tuple(reasons)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _match_pattern(tokens: list[str], pattern: tuple[str, ...]) -> bool:
|
|
106
|
+
"""Sequence match with wildcards.
|
|
107
|
+
|
|
108
|
+
* a trailing `*` on a rule token is a prefix match, so `--force=true`
|
|
109
|
+
matches the rule `--force*`;
|
|
110
|
+
* the rule token `"*"` matches exactly one token;
|
|
111
|
+
* the rule token `"|"` means the *rest* of the pattern must match
|
|
112
|
+
somewhere later in the token list (a shell pipe).
|
|
113
|
+
"""
|
|
114
|
+
if not tokens or not pattern:
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
def match_from(tok_i: int, pat_i: int) -> bool:
|
|
118
|
+
while pat_i < len(pattern):
|
|
119
|
+
rule = pattern[pat_i]
|
|
120
|
+
if rule == "|":
|
|
121
|
+
rest = pattern[pat_i + 1 :]
|
|
122
|
+
# A pipe: try every later token as the start of the rest.
|
|
123
|
+
for start in range(tok_i, len(tokens)):
|
|
124
|
+
if match_from(start, pat_i + 1) if rest else True:
|
|
125
|
+
return True
|
|
126
|
+
return False
|
|
127
|
+
if tok_i >= len(tokens):
|
|
128
|
+
return False
|
|
129
|
+
if not _token_matches(tokens[tok_i], rule):
|
|
130
|
+
return False
|
|
131
|
+
tok_i += 1
|
|
132
|
+
pat_i += 1
|
|
133
|
+
return True
|
|
134
|
+
|
|
135
|
+
return match_from(0, 0)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class Denylist:
|
|
139
|
+
"""Holds the configurable deny rules for one session."""
|
|
140
|
+
|
|
141
|
+
def __init__(self, rules: tuple[str, ...] | list[str] | None = None) -> None:
|
|
142
|
+
self.rules: tuple[str, ...] = tuple(rules) if rules is not None else DEFAULT_DENIED
|
|
143
|
+
|
|
144
|
+
def check(self, cmd: str) -> Denial | None:
|
|
145
|
+
reasons = deny_reasons(cmd, self.rules)
|
|
146
|
+
return reasons[0] if reasons else None
|