coding-guardrails 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/PKG-INFO +1 -1
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/pyproject.toml +1 -1
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/cli.py +8 -2
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/middleware.py +15 -2
- coding_guardrails-0.3.0/src/coding_guardrails/proxy/client.py +51 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/proxy/handler.py +75 -14
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/proxy/server.py +26 -5
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/rules/commands.py +46 -35
- coding_guardrails-0.3.0/src/coding_guardrails/rules/prerequisites.py +95 -0
- coding_guardrails-0.3.0/src/coding_guardrails/rules/sequencing.py +85 -0
- coding_guardrails-0.3.0/tests/unit/test_commands.py +125 -0
- coding_guardrails-0.3.0/tests/unit/test_config.py +77 -0
- coding_guardrails-0.3.0/tests/unit/test_prerequisites.py +108 -0
- coding_guardrails-0.3.0/tests/unit/test_sequencing.py +88 -0
- coding_guardrails-0.2.0/src/coding_guardrails/rules/prerequisites.py +0 -95
- coding_guardrails-0.2.0/src/coding_guardrails/rules/sequencing.py +0 -114
- coding_guardrails-0.2.0/tests/unit/test_commands.py +0 -71
- coding_guardrails-0.2.0/tests/unit/test_config.py +0 -56
- coding_guardrails-0.2.0/tests/unit/test_prerequisites.py +0 -77
- coding_guardrails-0.2.0/tests/unit/test_sequencing.py +0 -77
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/.github/workflows/ci.yaml +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/.gitignore +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/Dockerfile +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/LICENSE +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/README.md +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/configs/aider.yaml +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/configs/claude-code.yaml +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/configs/guardrail-config.yaml +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/configs/opencode.json +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/configs/pi.yaml +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/docker-compose.yaml +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/docs/agents.md +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/docs/architecture.md +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/docs/models.md +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/docs/rules.md +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/eval/scenarios/destructive_format_disk.json +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/eval/scenarios/destructive_rm_rf.json +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/eval/scenarios/edit_without_read.json +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/eval/scenarios/path_traversal_read_etc_passwd.json +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/eval/scenarios/path_traversal_shadow.json +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/eval/scenarios/safe_edit_project_file.json +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/eval/scenarios/safe_read_project_file.json +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/eval/scenarios/secret_aws_key_in_args.json +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/eval/scenarios/secret_private_key_write.json +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/__init__.py +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/__main__.py +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/config.py +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/eval/scenarios/destructive_format_disk.json +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/eval/scenarios/destructive_rm_rf.json +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/eval/scenarios/edit_without_read.json +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/eval/scenarios/path_traversal_read_etc_passwd.json +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/eval/scenarios/path_traversal_shadow.json +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/eval/scenarios/safe_edit_project_file.json +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/eval/scenarios/safe_read_project_file.json +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/eval/scenarios/secret_aws_key_in_args.json +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/eval/scenarios/secret_private_key_write.json +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/eval.py +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/models/__init__.py +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/models/profiles.py +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/models/registry.py +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/proxy/__init__.py +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/rules/__init__.py +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/rules/base.py +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/rules/path_safety.py +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/rules/secrets.py +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/rules/tool_resolution.py +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/tests/__init__.py +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/tests/integration/__init__.py +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/tests/integration/test_proxy.py +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/tests/unit/__init__.py +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/tests/unit/test_middleware.py +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/tests/unit/test_path_safety.py +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/tests/unit/test_secrets.py +0 -0
- {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/tests/unit/test_tool_resolution.py +0 -0
|
@@ -32,6 +32,7 @@ def main() -> None:
|
|
|
32
32
|
@click.option("--no-rescue", is_flag=True, help="Disable Forge rescue parsing")
|
|
33
33
|
@click.option("--no-guardrails", is_flag=True, help="Disable Layer 2 guardrails (Forge only)")
|
|
34
34
|
@click.option("--serialize", is_flag=True, help="Serialize requests (single-GPU)")
|
|
35
|
+
@click.option("--timeout", default=600, type=float, help="Backend request timeout in seconds (default: 600)")
|
|
35
36
|
@click.option("--verbose", "-v", is_flag=True, help="Verbose logging")
|
|
36
37
|
def serve(
|
|
37
38
|
backend_url: str,
|
|
@@ -43,6 +44,7 @@ def serve(
|
|
|
43
44
|
no_rescue: bool,
|
|
44
45
|
no_guardrails: bool,
|
|
45
46
|
serialize: bool,
|
|
47
|
+
timeout: float,
|
|
46
48
|
verbose: bool,
|
|
47
49
|
) -> None:
|
|
48
50
|
"""Start the coding-guardrails proxy server."""
|
|
@@ -69,6 +71,7 @@ def serve(
|
|
|
69
71
|
rescue_enabled=not no_rescue,
|
|
70
72
|
guardrails_enabled=not no_guardrails,
|
|
71
73
|
serialize=serialize,
|
|
74
|
+
timeout=timeout,
|
|
72
75
|
))
|
|
73
76
|
except KeyboardInterrupt:
|
|
74
77
|
click.echo("\nStopped.")
|
|
@@ -84,9 +87,10 @@ async def _run_proxy(
|
|
|
84
87
|
rescue_enabled: bool,
|
|
85
88
|
guardrails_enabled: bool,
|
|
86
89
|
serialize: bool,
|
|
90
|
+
timeout: float = 600.0,
|
|
87
91
|
) -> None:
|
|
88
92
|
"""Async proxy startup and run loop."""
|
|
89
|
-
from
|
|
93
|
+
from coding_guardrails.proxy.client import SafeLlamafileClient
|
|
90
94
|
from forge.context.manager import ContextManager
|
|
91
95
|
from forge.context.strategies import TieredCompact
|
|
92
96
|
from coding_guardrails.proxy.server import GuardrailProxyServer
|
|
@@ -97,10 +101,12 @@ async def _run_proxy(
|
|
|
97
101
|
if not base.endswith("/v1"):
|
|
98
102
|
base = base + "/v1"
|
|
99
103
|
|
|
100
|
-
client =
|
|
104
|
+
client = SafeLlamafileClient(
|
|
101
105
|
gguf_path=model,
|
|
102
106
|
base_url=base,
|
|
103
107
|
mode="native",
|
|
108
|
+
timeout=timeout,
|
|
109
|
+
default_max_tokens=8192,
|
|
104
110
|
)
|
|
105
111
|
|
|
106
112
|
# Auto-detect context budget from backend
|
|
@@ -61,7 +61,13 @@ class CodingGuardrails:
|
|
|
61
61
|
prereq_cfg = config.get("prerequisites", {})
|
|
62
62
|
if prereq_cfg.get("enabled", True):
|
|
63
63
|
rules["prerequisites"] = PrerequisiteRule(
|
|
64
|
-
|
|
64
|
+
edit_tools=tuple(prereq_cfg.get("edit_tools", (
|
|
65
|
+
"edit", "write", "create",
|
|
66
|
+
))),
|
|
67
|
+
read_tools=tuple(prereq_cfg.get("read_tools", (
|
|
68
|
+
"read", "cat", "head", "tail", "less",
|
|
69
|
+
))),
|
|
70
|
+
match_arg=prereq_cfg.get("match_arg", "path"),
|
|
65
71
|
max_violations=prereq_cfg.get("max_violations", 2),
|
|
66
72
|
)
|
|
67
73
|
|
|
@@ -98,7 +104,14 @@ class CodingGuardrails:
|
|
|
98
104
|
seq_cfg = config.get("sequencing", {})
|
|
99
105
|
if seq_cfg.get("enabled", True):
|
|
100
106
|
rules["sequencing"] = SequenceRule(
|
|
101
|
-
|
|
107
|
+
trigger_prefixes=tuple(seq_cfg.get("trigger_tools", (
|
|
108
|
+
"edit", "write", "create",
|
|
109
|
+
))),
|
|
110
|
+
suggest_prefixes=tuple(seq_cfg.get("suggest_tools", (
|
|
111
|
+
"bash", "shell", "run", "exec",
|
|
112
|
+
))),
|
|
113
|
+
strength=seq_cfg.get("strength", "soft"),
|
|
114
|
+
nudge=seq_cfg.get("nudge", "Consider running tests to verify your changes."),
|
|
102
115
|
cooldown=seq_cfg.get("cooldown", 3),
|
|
103
116
|
)
|
|
104
117
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Extended Forge client that passes max_tokens through to the backend.
|
|
2
|
+
|
|
3
|
+
Forge's LlamafileClient._apply_sampling() only handles its own field list
|
|
4
|
+
(temperature, top_p, etc.) — it deliberately ignores max_tokens. This
|
|
5
|
+
subclass extends it to also forward max_tokens / n_predict, which prevents
|
|
6
|
+
runaway model generation.
|
|
7
|
+
|
|
8
|
+
This is the only place we extend Forge behavior without modifying the
|
|
9
|
+
installed package. Everyone gets the fix just by pip-installing our package
|
|
10
|
+
and Forge — no manual edits required.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from forge.clients.llamafile import LlamafileClient
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SafeLlamafileClient(LlamafileClient):
|
|
21
|
+
"""LlamafileClient that forwards max_tokens to the backend.
|
|
22
|
+
|
|
23
|
+
Adds max_tokens, max_completion_tokens, and n_predict to the sampling
|
|
24
|
+
pipeline so the backend always receives an output cap.
|
|
25
|
+
|
|
26
|
+
The default_max_tokens constructor arg is injected when the caller
|
|
27
|
+
doesn't provide one — acts as a safety net against runaway generation.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
_EXTRA_SAMPLING_FIELDS = ("max_tokens", "n_predict")
|
|
31
|
+
|
|
32
|
+
def __init__(self, *args: Any, default_max_tokens: int = 8192, **kwargs: Any) -> None:
|
|
33
|
+
super().__init__(*args, **kwargs)
|
|
34
|
+
self._default_max_tokens = default_max_tokens
|
|
35
|
+
|
|
36
|
+
def _apply_sampling(
|
|
37
|
+
self, body: dict[str, Any], sampling: dict[str, Any] | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
# Let Forge handle its own fields first
|
|
40
|
+
super()._apply_sampling(body, sampling)
|
|
41
|
+
|
|
42
|
+
# Forward our extra fields (max_tokens, n_predict)
|
|
43
|
+
for field in self._EXTRA_SAMPLING_FIELDS:
|
|
44
|
+
override = (sampling or {}).get(field)
|
|
45
|
+
if override is not None:
|
|
46
|
+
body[field] = override
|
|
47
|
+
break # First match wins (max_tokens preferred over n_predict)
|
|
48
|
+
|
|
49
|
+
# Safety net: if nobody set any cap, inject a default
|
|
50
|
+
if "max_tokens" not in body and "n_predict" not in body:
|
|
51
|
+
body["max_tokens"] = self._default_max_tokens
|
|
@@ -12,6 +12,7 @@ from __future__ import annotations
|
|
|
12
12
|
|
|
13
13
|
import json
|
|
14
14
|
import logging
|
|
15
|
+
import time
|
|
15
16
|
from typing import Any
|
|
16
17
|
|
|
17
18
|
from forge.clients.base import LLMClient
|
|
@@ -34,7 +35,36 @@ from coding_guardrails.rules.base import ToolCall as GuardrailToolCall
|
|
|
34
35
|
|
|
35
36
|
logger = logging.getLogger("coding_guardrails.proxy")
|
|
36
37
|
|
|
38
|
+
# ── Banner helpers ──────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
_BANNER_WIDTH = 60
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _banner(label: str, char: str = "─") -> str:
|
|
44
|
+
pad = _BANNER_WIDTH - len(label) - 4
|
|
45
|
+
left = pad // 2
|
|
46
|
+
right = pad - left
|
|
47
|
+
return f"{char * left} ▸ {label} ◂ {char * right}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _short(msg: str, width: int = 80) -> str:
|
|
51
|
+
if len(msg) <= width:
|
|
52
|
+
return msg
|
|
53
|
+
return msg[:width - 3] + "..."
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _fmt_tools(calls: list[ToolCall]) -> str:
|
|
57
|
+
parts = [f"{tc.tool}({','.join(f'{k}={_short(str(v),20)}' for k, v in list(tc.args.items())[:3])})" for tc in calls]
|
|
58
|
+
return " | ".join(parts)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _fmt_elapsed(seconds: float) -> str:
|
|
62
|
+
if seconds < 1.0:
|
|
63
|
+
return f"{seconds * 1000:.0f}ms"
|
|
64
|
+
return f"{seconds:.1f}s"
|
|
65
|
+
|
|
37
66
|
# OpenAI-compatible top-level body fields plumbed from inbound to client.
|
|
67
|
+
# Note: max_tokens / n_predict are handled by SafeLlamafileClient, not here.
|
|
38
68
|
_SAMPLING_FIELDS = (
|
|
39
69
|
"temperature", "top_p", "top_k", "min_p",
|
|
40
70
|
"repeat_penalty", "presence_penalty", "seed",
|
|
@@ -45,6 +75,15 @@ _SAMPLING_FIELDS = (
|
|
|
45
75
|
def _extract_sampling(body: dict[str, Any]) -> dict[str, Any] | None:
|
|
46
76
|
"""Pull recognized sampling fields from the inbound request body."""
|
|
47
77
|
extracted = {f: body[f] for f in _SAMPLING_FIELDS if f in body}
|
|
78
|
+
# Also forward max_tokens variants — SafeLlamafileClient handles them
|
|
79
|
+
for field in ("max_tokens", "max_completion_tokens", "n_predict"):
|
|
80
|
+
if field in body:
|
|
81
|
+
extracted[field] = body[field]
|
|
82
|
+
# Normalize: max_completion_tokens → max_tokens
|
|
83
|
+
if "max_completion_tokens" in extracted and "max_tokens" not in extracted:
|
|
84
|
+
extracted["max_tokens"] = extracted.pop("max_completion_tokens")
|
|
85
|
+
else:
|
|
86
|
+
extracted.pop("max_completion_tokens", None)
|
|
48
87
|
return extracted or None
|
|
49
88
|
|
|
50
89
|
|
|
@@ -141,16 +180,23 @@ async def handle_chat_completions(
|
|
|
141
180
|
|
|
142
181
|
# No tools → plain chat completion, pass through
|
|
143
182
|
if not tool_specs:
|
|
144
|
-
logger.info("
|
|
183
|
+
logger.info("💬 Plain text (no tools)")
|
|
184
|
+
t0 = time.monotonic()
|
|
145
185
|
api_format = getattr(client, "api_format", "ollama")
|
|
146
186
|
api_messages = fold_and_serialize(messages, api_format)
|
|
147
187
|
response = await client.send(api_messages, tools=None, sampling=sampling)
|
|
188
|
+
elapsed = time.monotonic() - t0
|
|
148
189
|
text = response.content if isinstance(response, TextResponse) else ""
|
|
190
|
+
logger.info("✅ Text response (%s, %d chars)", _fmt_elapsed(elapsed), len(text))
|
|
149
191
|
if is_stream:
|
|
150
192
|
return text_to_sse_events(text, model=model_name)
|
|
151
193
|
return text_response_to_openai(text, model=model_name)
|
|
152
194
|
|
|
153
|
-
# ── Layer 1: Forge
|
|
195
|
+
# ── Layer 1: Forge (rescue, validate, retry) ──
|
|
196
|
+
logger.info(_banner("LAYER 1 · Forge"))
|
|
197
|
+
logger.info("🔧 Calling model (%d tools, %d msgs, max %d retries)", len(tool_names), len(messages), max_retries)
|
|
198
|
+
t0 = time.monotonic()
|
|
199
|
+
|
|
154
200
|
validator = ResponseValidator(tool_names, rescue_enabled=rescue_enabled)
|
|
155
201
|
error_tracker = ErrorTracker(max_retries=max_retries)
|
|
156
202
|
|
|
@@ -166,12 +212,16 @@ async def handle_chat_completions(
|
|
|
166
212
|
)
|
|
167
213
|
except ToolCallError as exc:
|
|
168
214
|
raw = exc.raw_response or ""
|
|
169
|
-
logger.warning("Layer 1 retries
|
|
215
|
+
logger.warning("❌ Layer 1 failed after %d retries (%s)", max_retries, _short(raw, 80))
|
|
170
216
|
if is_stream:
|
|
171
217
|
return text_to_sse_events(raw, model=model_name)
|
|
172
218
|
return text_response_to_openai(raw, model=model_name)
|
|
173
219
|
|
|
220
|
+
elapsed_l1 = time.monotonic() - t0
|
|
221
|
+
retries_used = error_tracker.attempt if hasattr(error_tracker, 'attempt') else 0
|
|
222
|
+
|
|
174
223
|
if result is None:
|
|
224
|
+
logger.info("⚠️ Model returned empty")
|
|
175
225
|
if is_stream:
|
|
176
226
|
return text_to_sse_events("", model=model_name)
|
|
177
227
|
return text_response_to_openai("", model=model_name)
|
|
@@ -184,37 +234,50 @@ async def handle_chat_completions(
|
|
|
184
234
|
|
|
185
235
|
if respond_calls and not other_calls:
|
|
186
236
|
text = respond_calls[0].args.get("message", "")
|
|
187
|
-
logger.info("
|
|
237
|
+
logger.info("📝 Model responded with text (%s)", _fmt_elapsed(elapsed_l1))
|
|
188
238
|
if is_stream:
|
|
189
239
|
return text_to_sse_events(text, model=model_name)
|
|
190
240
|
return text_response_to_openai(text, model=model_name)
|
|
191
241
|
|
|
192
242
|
if not other_calls:
|
|
243
|
+
logger.info("⚠️ No actionable tool calls")
|
|
193
244
|
if is_stream:
|
|
194
245
|
return text_to_sse_events("", model=model_name)
|
|
195
246
|
return text_response_to_openai("", model=model_name)
|
|
196
247
|
|
|
248
|
+
logger.info("✅ Layer 1 complete (%s, %d tool calls: %s)",
|
|
249
|
+
_fmt_elapsed(elapsed_l1), len(other_calls), _fmt_tools(other_calls))
|
|
250
|
+
|
|
197
251
|
# ── Layer 2: Coding guardrails ──
|
|
252
|
+
logger.info(_banner("LAYER 2 · Guardrails"))
|
|
253
|
+
t1 = time.monotonic()
|
|
254
|
+
|
|
198
255
|
guardrail_calls = [_forge_call_to_guardrail_call(tc) for tc in other_calls]
|
|
199
256
|
guardrail_result = guardrails.check(guardrail_calls)
|
|
200
257
|
|
|
201
258
|
# Record executed calls (for stateful rules like prerequisites)
|
|
202
259
|
if guardrail_result.allowed:
|
|
203
260
|
guardrails.record(guardrail_result.allowed)
|
|
261
|
+
for call in guardrail_result.allowed:
|
|
262
|
+
logger.info(" ✅ %s — allowed", call.tool)
|
|
204
263
|
|
|
205
|
-
# Log
|
|
264
|
+
# Log blocks
|
|
206
265
|
if guardrail_result.has_blocks:
|
|
207
266
|
for block in guardrail_result.blocked:
|
|
208
|
-
logger.info(
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
267
|
+
logger.info(" 🚫 %s — BLOCKED [%s]", block.tool, block.reason or "policy violation")
|
|
268
|
+
logger.info(" ↳ %s", _short(block.nudge or "", 60))
|
|
269
|
+
|
|
270
|
+
# Log nudges
|
|
212
271
|
if guardrail_result.has_nudges:
|
|
213
272
|
for nudge in guardrail_result.nudges:
|
|
214
|
-
logger.info("
|
|
273
|
+
logger.info(" ⚠️ %s — nudged [%s]", nudge.tool, nudge.reason or "advisory")
|
|
274
|
+
logger.info(" ↳ %s", _short(nudge.nudge or "", 60))
|
|
275
|
+
|
|
276
|
+
elapsed_l2 = time.monotonic() - t1
|
|
215
277
|
|
|
216
278
|
# If any call was hard-blocked, return block responses
|
|
217
279
|
if guardrail_result.has_blocks:
|
|
280
|
+
logger.info("⛔ Request BLOCKED by Layer 2 (%s)", _fmt_elapsed(elapsed_l2))
|
|
218
281
|
# Return the first block as the response with all nudges appended
|
|
219
282
|
block = guardrail_result.blocked[0]
|
|
220
283
|
nudge_text = block.nudge or "Action blocked by guardrails."
|
|
@@ -230,10 +293,8 @@ async def handle_chat_completions(
|
|
|
230
293
|
# Return a block response — the agent sees this as guidance
|
|
231
294
|
return _make_block_response(block.tool, nudge_text, model=model_name)
|
|
232
295
|
|
|
233
|
-
# All clear
|
|
234
|
-
|
|
235
|
-
# For now, nudges are advisory — we could inject them as system messages
|
|
236
|
-
# in a future iteration. For v0.1, they're logged only.
|
|
296
|
+
# All clear
|
|
297
|
+
logger.info("✅ Request PASSED (%s)", _fmt_elapsed(elapsed_l2))
|
|
237
298
|
|
|
238
299
|
if is_stream:
|
|
239
300
|
return tool_calls_to_sse_events(other_calls, model=model_name)
|
|
@@ -180,9 +180,29 @@ class GuardrailProxyServer:
|
|
|
180
180
|
|
|
181
181
|
async def _handle_models(self, writer: asyncio.StreamWriter) -> None:
|
|
182
182
|
"""GET /v1/models — returns model info."""
|
|
183
|
+
model_info: dict[str, Any] = {
|
|
184
|
+
"id": self._model_name,
|
|
185
|
+
"object": "model",
|
|
186
|
+
"owned_by": "coding-guardrails",
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
# Proxy the backend's model metadata (includes n_ctx)
|
|
190
|
+
try:
|
|
191
|
+
import httpx
|
|
192
|
+
async with httpx.AsyncClient(timeout=5.0) as http:
|
|
193
|
+
resp = await http.get(f"{self._client.base_url}/models")
|
|
194
|
+
if resp.status_code == 200:
|
|
195
|
+
data = resp.json()
|
|
196
|
+
for m in data.get("data", []):
|
|
197
|
+
if "meta" in m:
|
|
198
|
+
model_info["meta"] = m["meta"]
|
|
199
|
+
break
|
|
200
|
+
except Exception:
|
|
201
|
+
pass
|
|
202
|
+
|
|
183
203
|
body = json.dumps({
|
|
184
204
|
"object": "list",
|
|
185
|
-
"data": [
|
|
205
|
+
"data": [model_info],
|
|
186
206
|
})
|
|
187
207
|
await self._send_json(writer, 200, body)
|
|
188
208
|
|
|
@@ -201,10 +221,11 @@ class GuardrailProxyServer:
|
|
|
201
221
|
is_stream = body.get("stream", False)
|
|
202
222
|
msg_count = len(body.get("messages", []))
|
|
203
223
|
tool_count = len(body.get("tools", []))
|
|
204
|
-
logger.info(
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
224
|
+
logger.info("")
|
|
225
|
+
logger.info("━" * 60)
|
|
226
|
+
logger.info(">> POST /v1/chat/completions")
|
|
227
|
+
logger.info(" msgs=%d tools=%d stream=%s model=%s",
|
|
228
|
+
msg_count, tool_count, is_stream, body.get("model", "?"))
|
|
208
229
|
|
|
209
230
|
if self._serialize:
|
|
210
231
|
item = _QueueItem(body=body)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Destructive command blocking.
|
|
2
2
|
|
|
3
3
|
Blocks shell commands that could cause irreversible damage:
|
|
4
|
-
rm -rf /, fork bombs, pipe-to-shell, format disks, etc.
|
|
4
|
+
rm -rf /, fork bombs, pipe-to-shell, format disks, sudo, etc.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
@@ -24,67 +24,78 @@ class CommandSafetyRule:
|
|
|
24
24
|
command_args: Argument names that contain shell commands.
|
|
25
25
|
blocked: Exact command prefixes that are always blocked.
|
|
26
26
|
blocked_patterns: Regex patterns for dangerous commands.
|
|
27
|
-
require_confirmation: Commands that
|
|
27
|
+
require_confirmation: Commands that trigger a confirmation nudge.
|
|
28
28
|
"""
|
|
29
29
|
|
|
30
30
|
command_args: list[str] = field(default_factory=lambda: ["command", "cmd", "script"])
|
|
31
31
|
|
|
32
32
|
blocked: list[str] = field(default_factory=lambda: [
|
|
33
|
+
# Filesystem destruction
|
|
33
34
|
"rm -rf / ",
|
|
34
35
|
"rm -rf /*",
|
|
35
36
|
"rm -rf ~",
|
|
36
|
-
"rm -rf /*",
|
|
37
37
|
"rm -rf ~/*",
|
|
38
38
|
"dd if=",
|
|
39
39
|
"mkfs.",
|
|
40
40
|
":(){ :|:& };:",
|
|
41
|
+
# Privilege escalation
|
|
42
|
+
"sudo ",
|
|
43
|
+
"sudo(",
|
|
44
|
+
"su -",
|
|
45
|
+
"su root",
|
|
46
|
+
# Service manipulation
|
|
47
|
+
"systemctl stop",
|
|
48
|
+
"systemctl disable",
|
|
49
|
+
"systemctl restart",
|
|
50
|
+
"systemctl mask",
|
|
51
|
+
"service stop",
|
|
52
|
+
"shutdown",
|
|
53
|
+
"reboot",
|
|
54
|
+
"init 0",
|
|
55
|
+
"init 6",
|
|
56
|
+
# Disk/device access
|
|
57
|
+
"> /dev/sd",
|
|
41
58
|
])
|
|
42
59
|
|
|
43
60
|
blocked_patterns: list[str] = field(default_factory=lambda: [
|
|
61
|
+
# Permission escalation
|
|
44
62
|
r"chmod\s+777\s+/",
|
|
63
|
+
r"chmod\s+666\s+/",
|
|
64
|
+
# Download + execute (pipe to shell)
|
|
45
65
|
r"curl\s+.*\|\s*(ba)?sh",
|
|
46
66
|
r"wget\s+.*\|\s*(ba)?sh",
|
|
67
|
+
# Download + execute (two-step)
|
|
68
|
+
r"curl\s+.*-o\s+\S+.*&&\s*(ba)?sh\s",
|
|
69
|
+
r"wget\s+.*-O\s+\S+.*&&\s*(ba)?sh\s",
|
|
70
|
+
# Eval/execute fetched content
|
|
71
|
+
r"eval\s+['\"]?\$?\(",
|
|
72
|
+
r"bash\s+-c\s+['\"]?\$?\(",
|
|
73
|
+
r"source\s+<\(",
|
|
74
|
+
r"\.\s+<\(", # dot-source via process substitution
|
|
75
|
+
r"exec\s+<\(",
|
|
76
|
+
# Disk/device redirect
|
|
47
77
|
r">\s*/dev/sd[a-z]",
|
|
78
|
+
# Root filesystem removal (exact end)
|
|
48
79
|
r"rm\s+-rf\s+/\s*$",
|
|
80
|
+
# Git destructive operations
|
|
81
|
+
r"git\s+clean\s+-fdx?",
|
|
82
|
+
r"git\s+reset\s+--hard",
|
|
83
|
+
r"git\s+checkout\s+--\s+\.",
|
|
84
|
+
r"git\s+branch\s+-[dD]\s+(main|master)",
|
|
85
|
+
r"git\s+push\s+.*--force",
|
|
86
|
+
# Credential theft
|
|
87
|
+
r"cat\s+/etc/shadow",
|
|
88
|
+
r"cat\s+/root/.ssh",
|
|
89
|
+
r"cp\s+/etc/shadow",
|
|
49
90
|
])
|
|
50
91
|
|
|
51
92
|
require_confirmation: list[str] = field(default_factory=lambda: [
|
|
52
93
|
"rm -rf",
|
|
53
|
-
"git push --force",
|
|
54
94
|
"DROP TABLE",
|
|
95
|
+
"DELETE FROM",
|
|
96
|
+
"TRUNCATE",
|
|
55
97
|
])
|
|
56
98
|
|
|
57
|
-
_DEFAULTS_BLOCKED: ClassVar[list[str]] = [
|
|
58
|
-
"rm -rf / ",
|
|
59
|
-
"rm -rf /*",
|
|
60
|
-
"rm -rf ~",
|
|
61
|
-
"rm -rf /*",
|
|
62
|
-
"rm -rf ~/*",
|
|
63
|
-
"dd if=",
|
|
64
|
-
"mkfs.",
|
|
65
|
-
":(){ :|:& };:",
|
|
66
|
-
]
|
|
67
|
-
_DEFAULTS_PATTERNS: ClassVar[list[str]] = [
|
|
68
|
-
r"chmod\s+777\s+/",
|
|
69
|
-
r"curl\s+.*\|\s*(ba)?sh",
|
|
70
|
-
r"wget\s+.*\|\s*(ba)?sh",
|
|
71
|
-
r">\s*/dev/sd[a-z]",
|
|
72
|
-
r"rm\s+-rf\s+/\s*$",
|
|
73
|
-
]
|
|
74
|
-
_DEFAULTS_CONFIRM: ClassVar[list[str]] = [
|
|
75
|
-
"rm -rf",
|
|
76
|
-
"git push --force",
|
|
77
|
-
"DROP TABLE",
|
|
78
|
-
]
|
|
79
|
-
|
|
80
|
-
def __post_init__(self) -> None:
|
|
81
|
-
if self.blocked is None:
|
|
82
|
-
object.__setattr__(self, 'blocked', list(self._DEFAULTS_BLOCKED))
|
|
83
|
-
if self.blocked_patterns is None:
|
|
84
|
-
object.__setattr__(self, 'blocked_patterns', list(self._DEFAULTS_PATTERNS))
|
|
85
|
-
if self.require_confirmation is None:
|
|
86
|
-
object.__setattr__(self, 'require_confirmation', list(self._DEFAULTS_CONFIRM))
|
|
87
|
-
|
|
88
99
|
@property
|
|
89
100
|
def name(self) -> str:
|
|
90
101
|
return "command_safety"
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Read-before-edit prerequisite enforcement.
|
|
2
|
+
|
|
3
|
+
Tracks which files the agent has read. Blocks edit/write operations
|
|
4
|
+
on files that haven't been read first.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import ClassVar
|
|
12
|
+
|
|
13
|
+
from coding_guardrails.rules.base import Action, RuleResult, ToolCall
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Tool name matching: prefix-based so 'edit' matches 'edit', 'edit_file',
|
|
17
|
+
# 'Edit', etc. Covers Pi (edit/read/bash), Claude Code (Edit/Read),
|
|
18
|
+
# Aider, OpenCode, and generic agents.
|
|
19
|
+
_DEFAULT_EDIT_TOOLS = ("edit", "write", "create")
|
|
20
|
+
_DEFAULT_READ_TOOLS = ("read", "cat", "head", "tail", "less")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _tool_matches(tool: str, prefixes: tuple[str, ...]) -> bool:
|
|
24
|
+
"""Check if a tool name matches any prefix (case-insensitive)."""
|
|
25
|
+
tool_lower = tool.lower()
|
|
26
|
+
return any(tool_lower.startswith(p) for p in prefixes)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class PrerequisiteRule:
|
|
31
|
+
"""Enforce read-before-edit for file operations.
|
|
32
|
+
|
|
33
|
+
Uses prefix matching so tool names like 'edit', 'edit_file', 'Edit'
|
|
34
|
+
all match. Covers Pi, Claude Code, Aider, OpenCode, and generic agents.
|
|
35
|
+
|
|
36
|
+
Attributes:
|
|
37
|
+
edit_tools: Tool name prefixes that require a prior read.
|
|
38
|
+
read_tools: Tool name prefixes that satisfy the read requirement.
|
|
39
|
+
match_arg: Argument name containing the file path.
|
|
40
|
+
max_violations: Block after this many consecutive violations.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
edit_tools: tuple[str, ...] = _DEFAULT_EDIT_TOOLS
|
|
44
|
+
read_tools: tuple[str, ...] = _DEFAULT_READ_TOOLS
|
|
45
|
+
match_arg: str = "path"
|
|
46
|
+
max_violations: int = 2
|
|
47
|
+
|
|
48
|
+
_read_paths: set[str] = field(default_factory=set, repr=False)
|
|
49
|
+
_violation_count: int = field(default=0, repr=False)
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def name(self) -> str:
|
|
53
|
+
return "prerequisites"
|
|
54
|
+
|
|
55
|
+
def check(self, call: ToolCall) -> RuleResult:
|
|
56
|
+
if not _tool_matches(call.tool, self.edit_tools):
|
|
57
|
+
return RuleResult.allow(call.tool)
|
|
58
|
+
|
|
59
|
+
path = call.args.get(self.match_arg, "")
|
|
60
|
+
if not path:
|
|
61
|
+
return RuleResult.allow(call.tool)
|
|
62
|
+
|
|
63
|
+
# Normalize: expand user, strip trailing slashes
|
|
64
|
+
normalized = os.path.normpath(os.path.expanduser(path))
|
|
65
|
+
|
|
66
|
+
if normalized not in self._read_paths:
|
|
67
|
+
self._violation_count += 1
|
|
68
|
+
if self._violation_count >= self.max_violations:
|
|
69
|
+
return RuleResult.block(
|
|
70
|
+
call.tool,
|
|
71
|
+
nudge=f"You must read {path} before editing it. "
|
|
72
|
+
f"Read the file first.",
|
|
73
|
+
reason=f"edit without read: {path}",
|
|
74
|
+
)
|
|
75
|
+
return RuleResult.nudge(
|
|
76
|
+
call.tool,
|
|
77
|
+
message=f"Consider reading {path} before editing. "
|
|
78
|
+
f"Read the file first.",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# No prerequisite violated — reset counter
|
|
82
|
+
self._violation_count = 0
|
|
83
|
+
return RuleResult.allow(call.tool)
|
|
84
|
+
|
|
85
|
+
def record(self, calls: list[ToolCall]) -> None:
|
|
86
|
+
"""Record which files have been read."""
|
|
87
|
+
for call in calls:
|
|
88
|
+
if _tool_matches(call.tool, self.read_tools):
|
|
89
|
+
path = call.args.get(self.match_arg, "")
|
|
90
|
+
if path:
|
|
91
|
+
normalized = os.path.normpath(os.path.expanduser(path))
|
|
92
|
+
self._read_paths.add(normalized)
|
|
93
|
+
|
|
94
|
+
# Reset violation counter on successful execution
|
|
95
|
+
self._violation_count = 0
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Workflow sequencing — soft nudges for test-after-change.
|
|
2
|
+
|
|
3
|
+
Suggests running tests after code edits. Soft by default (nudge, not block).
|
|
4
|
+
Uses prefix matching so it works with any agent's tool naming convention.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
|
|
11
|
+
from coding_guardrails.rules.base import Action, RuleResult, ToolCall
|
|
12
|
+
from coding_guardrails.rules.prerequisites import _tool_matches
|
|
13
|
+
|
|
14
|
+
# Default trigger/suggest prefixes covering all major agents.
|
|
15
|
+
_DEFAULT_EDIT_PREFIXES = ("edit", "write", "create")
|
|
16
|
+
_DEFAULT_SUGGEST_PREFIXES = ("bash", "shell", "run", "exec")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class SequenceRule:
|
|
21
|
+
"""Suggest workflow steps after certain tool calls.
|
|
22
|
+
|
|
23
|
+
Uses prefix matching: trigger_prefixes="edit" matches 'edit',
|
|
24
|
+
'edit_file', 'Edit', etc.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
trigger_prefixes: Tool name prefixes that trigger the suggestion.
|
|
28
|
+
suggest_prefixes: Tool name prefixes that satisfy the suggestion.
|
|
29
|
+
strength: "soft" (nudge) or "hard" (block until done).
|
|
30
|
+
nudge: Nudge message shown to the agent.
|
|
31
|
+
cooldown: Minimum number of calls between repeated nudges.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
trigger_prefixes: tuple[str, ...] = _DEFAULT_EDIT_PREFIXES
|
|
35
|
+
suggest_prefixes: tuple[str, ...] = _DEFAULT_SUGGEST_PREFIXES
|
|
36
|
+
strength: str = "soft"
|
|
37
|
+
nudge: str = "Consider running tests to verify your changes."
|
|
38
|
+
cooldown: int = 3
|
|
39
|
+
|
|
40
|
+
_calls_since_nudge: int = field(default=0, repr=False)
|
|
41
|
+
_pending: bool = field(default=False, repr=False)
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def name(self) -> str:
|
|
45
|
+
return "sequencing"
|
|
46
|
+
|
|
47
|
+
def check(self, call: ToolCall) -> RuleResult:
|
|
48
|
+
# Trigger: agent just edited/wrote a file
|
|
49
|
+
if _tool_matches(call.tool, self.trigger_prefixes):
|
|
50
|
+
self._pending = True
|
|
51
|
+
self._calls_since_nudge = 0
|
|
52
|
+
|
|
53
|
+
if self.strength == "hard":
|
|
54
|
+
return RuleResult.block(
|
|
55
|
+
call.tool,
|
|
56
|
+
nudge=self.nudge,
|
|
57
|
+
reason=f"hard sequence: {call.tool} → test",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
return RuleResult.allow(call.tool)
|
|
61
|
+
|
|
62
|
+
# Satisfaction: agent is running a command (might be tests)
|
|
63
|
+
if self._pending and _tool_matches(call.tool, self.suggest_prefixes):
|
|
64
|
+
self._pending = False
|
|
65
|
+
self._calls_since_nudge = 0
|
|
66
|
+
return RuleResult.allow(call.tool)
|
|
67
|
+
|
|
68
|
+
# Cooldown nudge: agent hasn't run tests after edits
|
|
69
|
+
if self._pending:
|
|
70
|
+
self._calls_since_nudge += 1
|
|
71
|
+
if self._calls_since_nudge >= self.cooldown:
|
|
72
|
+
self._calls_since_nudge = 0
|
|
73
|
+
return RuleResult.nudge(
|
|
74
|
+
call.tool,
|
|
75
|
+
message=self.nudge,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
return RuleResult.allow(call.tool)
|
|
79
|
+
|
|
80
|
+
def record(self, calls: list[ToolCall]) -> None:
|
|
81
|
+
"""Track if suggested follow-up was executed."""
|
|
82
|
+
for call in calls:
|
|
83
|
+
if self._pending and _tool_matches(call.tool, self.suggest_prefixes):
|
|
84
|
+
self._pending = False
|
|
85
|
+
self._calls_since_nudge = 0
|