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.
Files changed (74) hide show
  1. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/PKG-INFO +1 -1
  2. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/pyproject.toml +1 -1
  3. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/cli.py +8 -2
  4. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/middleware.py +15 -2
  5. coding_guardrails-0.3.0/src/coding_guardrails/proxy/client.py +51 -0
  6. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/proxy/handler.py +75 -14
  7. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/proxy/server.py +26 -5
  8. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/rules/commands.py +46 -35
  9. coding_guardrails-0.3.0/src/coding_guardrails/rules/prerequisites.py +95 -0
  10. coding_guardrails-0.3.0/src/coding_guardrails/rules/sequencing.py +85 -0
  11. coding_guardrails-0.3.0/tests/unit/test_commands.py +125 -0
  12. coding_guardrails-0.3.0/tests/unit/test_config.py +77 -0
  13. coding_guardrails-0.3.0/tests/unit/test_prerequisites.py +108 -0
  14. coding_guardrails-0.3.0/tests/unit/test_sequencing.py +88 -0
  15. coding_guardrails-0.2.0/src/coding_guardrails/rules/prerequisites.py +0 -95
  16. coding_guardrails-0.2.0/src/coding_guardrails/rules/sequencing.py +0 -114
  17. coding_guardrails-0.2.0/tests/unit/test_commands.py +0 -71
  18. coding_guardrails-0.2.0/tests/unit/test_config.py +0 -56
  19. coding_guardrails-0.2.0/tests/unit/test_prerequisites.py +0 -77
  20. coding_guardrails-0.2.0/tests/unit/test_sequencing.py +0 -77
  21. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/.github/workflows/ci.yaml +0 -0
  22. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/.gitignore +0 -0
  23. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/Dockerfile +0 -0
  24. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/LICENSE +0 -0
  25. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/README.md +0 -0
  26. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/configs/aider.yaml +0 -0
  27. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/configs/claude-code.yaml +0 -0
  28. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/configs/guardrail-config.yaml +0 -0
  29. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/configs/opencode.json +0 -0
  30. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/configs/pi.yaml +0 -0
  31. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/docker-compose.yaml +0 -0
  32. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/docs/agents.md +0 -0
  33. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/docs/architecture.md +0 -0
  34. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/docs/models.md +0 -0
  35. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/docs/rules.md +0 -0
  36. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/eval/scenarios/destructive_format_disk.json +0 -0
  37. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/eval/scenarios/destructive_rm_rf.json +0 -0
  38. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/eval/scenarios/edit_without_read.json +0 -0
  39. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/eval/scenarios/path_traversal_read_etc_passwd.json +0 -0
  40. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/eval/scenarios/path_traversal_shadow.json +0 -0
  41. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/eval/scenarios/safe_edit_project_file.json +0 -0
  42. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/eval/scenarios/safe_read_project_file.json +0 -0
  43. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/eval/scenarios/secret_aws_key_in_args.json +0 -0
  44. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/eval/scenarios/secret_private_key_write.json +0 -0
  45. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/__init__.py +0 -0
  46. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/__main__.py +0 -0
  47. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/config.py +0 -0
  48. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/eval/scenarios/destructive_format_disk.json +0 -0
  49. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/eval/scenarios/destructive_rm_rf.json +0 -0
  50. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/eval/scenarios/edit_without_read.json +0 -0
  51. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/eval/scenarios/path_traversal_read_etc_passwd.json +0 -0
  52. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/eval/scenarios/path_traversal_shadow.json +0 -0
  53. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/eval/scenarios/safe_edit_project_file.json +0 -0
  54. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/eval/scenarios/safe_read_project_file.json +0 -0
  55. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/eval/scenarios/secret_aws_key_in_args.json +0 -0
  56. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/eval/scenarios/secret_private_key_write.json +0 -0
  57. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/eval.py +0 -0
  58. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/models/__init__.py +0 -0
  59. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/models/profiles.py +0 -0
  60. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/models/registry.py +0 -0
  61. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/proxy/__init__.py +0 -0
  62. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/rules/__init__.py +0 -0
  63. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/rules/base.py +0 -0
  64. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/rules/path_safety.py +0 -0
  65. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/rules/secrets.py +0 -0
  66. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/src/coding_guardrails/rules/tool_resolution.py +0 -0
  67. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/tests/__init__.py +0 -0
  68. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/tests/integration/__init__.py +0 -0
  69. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/tests/integration/test_proxy.py +0 -0
  70. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/tests/unit/__init__.py +0 -0
  71. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/tests/unit/test_middleware.py +0 -0
  72. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/tests/unit/test_path_safety.py +0 -0
  73. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/tests/unit/test_secrets.py +0 -0
  74. {coding_guardrails-0.2.0 → coding_guardrails-0.3.0}/tests/unit/test_tool_resolution.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: coding-guardrails
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Safe, reliable local coding agent backend. Forge + coding-specific guardrails.
5
5
  Author: Stawils
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "coding-guardrails"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Safe, reliable local coding agent backend. Forge + coding-specific guardrails."
9
9
  requires-python = ">=3.12"
10
10
  license = "MIT"
@@ -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 forge.clients.llamafile import LlamafileClient
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 = LlamafileClient(
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
- rules=prereq_cfg.get("rules", None),
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
- rules=seq_cfg.get("rules", None),
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("No tools, passing through to backend")
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 guardrails (rescue, validate, retry) ──
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 exhausted: %.120s", raw)
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("Stripping respond(), returning as text")
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 what happened
264
+ # Log blocks
206
265
  if guardrail_result.has_blocks:
207
266
  for block in guardrail_result.blocked:
208
- logger.info(
209
- "LAYER 2 BLOCK: tool=%s reason=%s",
210
- block.tool, block.reason or block.nudge,
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("LAYER 2 NUDGE: tool=%s", nudge.tool)
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 — return validated tool calls
234
- # If there are nudges, log them (agent doesn't see them unless we inject)
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": [{"id": self._model_name, "object": "model"}],
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
- " stream=%s msgs=%d tools=%d model=%s",
206
- is_stream, msg_count, tool_count, body.get("model", "?"),
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 should trigger a confirmation nudge.
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