rafter-cli 0.10.2__tar.gz → 0.10.4__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 (101) hide show
  1. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/PKG-INFO +1 -1
  2. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/pyproject.toml +1 -1
  3. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/commands/hook.py +52 -2
  4. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/core/command_interceptor.py +40 -0
  5. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/core/config_manager.py +30 -0
  6. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/core/config_schema.py +14 -0
  7. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/core/policy_loader.py +14 -0
  8. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/core/risk_rules.py +115 -0
  9. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/rafter-security-skill.md +1 -1
  10. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/scanners/secret_patterns.py +28 -0
  11. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/README.md +0 -0
  12. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/__init__.py +0 -0
  13. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/__main__.py +0 -0
  14. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/commands/__init__.py +0 -0
  15. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/commands/agent.py +0 -0
  16. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/commands/agent_components.py +0 -0
  17. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/commands/backend.py +0 -0
  18. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/commands/brief.py +0 -0
  19. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/commands/ci.py +0 -0
  20. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/commands/docs.py +0 -0
  21. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/commands/issues/__init__.py +0 -0
  22. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/commands/issues/dedup.py +0 -0
  23. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/commands/issues/github_client.py +0 -0
  24. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/commands/issues/issue_builder.py +0 -0
  25. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/commands/issues/issues_app.py +0 -0
  26. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/commands/mcp_server.py +0 -0
  27. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/commands/notify.py +0 -0
  28. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/commands/policy.py +0 -0
  29. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/commands/report.py +0 -0
  30. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/commands/scan.py +0 -0
  31. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/commands/sites.py +0 -0
  32. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/commands/skill.py +0 -0
  33. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/commands/skill_remote.py +0 -0
  34. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/core/__init__.py +0 -0
  35. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/core/audit_logger.py +0 -0
  36. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/core/custom_patterns.py +0 -0
  37. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/core/docs_loader.py +0 -0
  38. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/core/hook_control.py +0 -0
  39. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/core/pattern_engine.py +0 -0
  40. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/core/suppression_writer.py +0 -0
  41. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/__init__.py +0 -0
  42. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/agents/__init__.py +0 -0
  43. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/agents/rafter.md +0 -0
  44. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/continue-rules/rafter-code-review.md +0 -0
  45. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/continue-rules/rafter-secure-design.md +0 -0
  46. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/continue-rules/rafter-skill-review.md +0 -0
  47. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/continue-rules/rafter.md +0 -0
  48. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/cursor-rules/rafter-code-review.mdc +0 -0
  49. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/cursor-rules/rafter-secure-design.mdc +0 -0
  50. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/cursor-rules/rafter-skill-review.mdc +0 -0
  51. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/cursor-rules/rafter.mdc +0 -0
  52. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/pre-commit-hook.sh +0 -0
  53. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/pre-push-hook.sh +0 -0
  54. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/skills/__init__.py +0 -0
  55. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/skills/rafter/SKILL.md +0 -0
  56. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/skills/rafter/__init__.py +0 -0
  57. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/skills/rafter/docs/backend.md +0 -0
  58. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/skills/rafter/docs/cli-reference.md +0 -0
  59. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/skills/rafter/docs/finding-triage.md +0 -0
  60. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/skills/rafter/docs/guardrails.md +0 -0
  61. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/skills/rafter/docs/shift-left.md +0 -0
  62. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/skills/rafter-code-review/SKILL.md +0 -0
  63. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/skills/rafter-code-review/docs/api.md +0 -0
  64. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/skills/rafter-code-review/docs/asvs.md +0 -0
  65. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/skills/rafter-code-review/docs/cwe-top25.md +0 -0
  66. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/skills/rafter-code-review/docs/investigation-playbook.md +0 -0
  67. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/skills/rafter-code-review/docs/llm.md +0 -0
  68. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/skills/rafter-code-review/docs/web-app.md +0 -0
  69. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/skills/rafter-secure-design/SKILL.md +0 -0
  70. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/skills/rafter-secure-design/docs/api-design.md +0 -0
  71. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/skills/rafter-secure-design/docs/auth.md +0 -0
  72. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/skills/rafter-secure-design/docs/data-storage.md +0 -0
  73. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/skills/rafter-secure-design/docs/dependencies.md +0 -0
  74. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/skills/rafter-secure-design/docs/deployment.md +0 -0
  75. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/skills/rafter-secure-design/docs/ingestion.md +0 -0
  76. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/skills/rafter-secure-design/docs/standards-pointers.md +0 -0
  77. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/skills/rafter-secure-design/docs/threat-modeling.md +0 -0
  78. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/skills/rafter-skill-review/SKILL.md +0 -0
  79. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/skills/rafter-skill-review/docs/authorship-provenance.md +0 -0
  80. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/skills/rafter-skill-review/docs/changelog-review.md +0 -0
  81. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/skills/rafter-skill-review/docs/data-practices.md +0 -0
  82. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/skills/rafter-skill-review/docs/malware-indicators.md +0 -0
  83. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/skills/rafter-skill-review/docs/prompt-injection.md +0 -0
  84. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/skills/rafter-skill-review/docs/telemetry.md +0 -0
  85. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/windsurf-rules/rafter-code-review.md +0 -0
  86. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/windsurf-rules/rafter-secure-design.md +0 -0
  87. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/windsurf-rules/rafter-skill-review.md +0 -0
  88. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/resources/windsurf-rules/rafter.md +0 -0
  89. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/scanners/__init__.py +0 -0
  90. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/scanners/betterleaks.py +0 -0
  91. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/scanners/git_diff_scan.py +0 -0
  92. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/scanners/regex_scanner.py +0 -0
  93. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/scanners/skill_scanner.py +0 -0
  94. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/scanners/union.py +0 -0
  95. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/utils/__init__.py +0 -0
  96. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/utils/api.py +0 -0
  97. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/utils/binary_manager.py +0 -0
  98. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/utils/formatter.py +0 -0
  99. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/utils/git.py +0 -0
  100. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/utils/git_diff.py +0 -0
  101. {rafter_cli-0.10.2 → rafter_cli-0.10.4}/rafter_cli/utils/skill_manager.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rafter-cli
3
- Version: 0.10.2
3
+ Version: 0.10.4
4
4
  Summary: Rafter CLI — the default security agent for AI workflows. Free for individuals and open source.
5
5
  License: MIT
6
6
  Author: Rafter Team
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "rafter-cli"
3
- version = "0.10.2"
3
+ version = "0.10.4"
4
4
  description = "Rafter CLI — the default security agent for AI workflows. Free for individuals and open source."
5
5
  authors = ["Rafter Team <hello@rafter.so>"]
6
6
  license = "MIT"
@@ -6,10 +6,12 @@ import math
6
6
  import os
7
7
  import subprocess
8
8
  import sys
9
+ from pathlib import Path
9
10
 
10
11
  import typer
11
12
 
12
13
  from ..core.audit_logger import AuditLogger
14
+ from ..core.risk_rules import modifies_rafter_security_config
13
15
  from ..core.command_interceptor import CommandInterceptor
14
16
  from ..scanners.regex_scanner import RegexScanner
15
17
 
@@ -37,6 +39,17 @@ _RISK_DESCRIPTIONS = {
37
39
 
38
40
  def _format_blocked_message(command: str, evaluation) -> str:
39
41
  cmd_display = command[:60] + "..." if len(command) > 60 else command
42
+ # rf-vnxs: say what actually happened. "irreversible system damage" is the
43
+ # wrong sentence for a config write, and a wrong reason teaches the agent to
44
+ # go looking for a way around a rule it was never told the shape of.
45
+ if modifies_rafter_security_config(command):
46
+ return (
47
+ f"\u2717 Rafter blocked: {cmd_display}\n"
48
+ f" Rule: rafter security configuration is protected (rf-vnxs)\n"
49
+ f" Risk: CRITICAL\u2014this command reconfigures or removes rafter's own\n"
50
+ f" guardrail. The gate cannot approve its own disarming. A person\n"
51
+ f" must make this change outside an agent session."
52
+ )
40
53
  rule = evaluation.matched_pattern or "policy violation"
41
54
  label = _RISK_LABELS.get(evaluation.risk_level, evaluation.risk_level.upper())
42
55
  desc = _RISK_DESCRIPTIONS.get(evaluation.risk_level, "")
@@ -54,8 +67,9 @@ def _format_approval_message(command: str, evaluation) -> str:
54
67
  f" Rule: {rule}\n"
55
68
  f" Risk: {label}\u2014{desc}\n"
56
69
  f"\n"
57
- f'To approve: rafter agent exec --approve "{command}"\n'
58
- f"To configure: rafter agent config set agent.riskLevel minimal"
70
+ f'To approve: a person must run `rafter agent exec "{command}"` at an\n'
71
+ f" interactive terminal and confirm. There is no flag that\n"
72
+ f" skips the prompt."
59
73
  )
60
74
 
61
75
 
@@ -376,7 +390,43 @@ def _evaluate_bash(command: str, control=None) -> dict:
376
390
  return {"decision": "allow"}
377
391
 
378
392
 
393
+ def _writes_rafter_security_config(file_path: str) -> bool:
394
+ """True if this write targets rafter's own configuration directory.
395
+
396
+ rf-vnxs, fourth route. Blocking the CLI invocation is not enough: the config
397
+ is a FILE, and an agent with a Write/Edit tool does not need the CLI to
398
+ disable the hook — it can write ``~/.rafter/config.json`` directly. Three
399
+ independent routes to the unblockable tier had already been found one at a
400
+ time in this codebase; this is the one that would have been the fourth.
401
+ """
402
+ if not file_path:
403
+ return False
404
+ try:
405
+ from ..core.config_schema import get_rafter_dir
406
+
407
+ target = Path(file_path).expanduser().resolve()
408
+ rafter_dir = get_rafter_dir().expanduser().resolve()
409
+ except Exception:
410
+ return False
411
+ return target == rafter_dir or rafter_dir in target.parents
412
+
413
+
379
414
  def _evaluate_write(tool_input: dict) -> dict:
415
+ # rf-vnxs: checked FIRST, and before the no-content early return, because a
416
+ # truncating write with empty content disables the hook just as well as one
417
+ # carrying JSON.
418
+ if _writes_rafter_security_config(tool_input.get("file_path", "")):
419
+ return {
420
+ "decision": "deny",
421
+ "reason": (
422
+ "\u2717 Rafter blocked: write to rafter's own configuration\n"
423
+ " Rule: rafter security configuration is protected (rf-vnxs)\n"
424
+ " Risk: CRITICAL\u2014editing this file reconfigures or removes the\n"
425
+ " guardrail. The gate cannot approve its own disarming. A\n"
426
+ " person must make this change outside an agent session."
427
+ ),
428
+ }
429
+
380
430
  content = tool_input.get("content", "") or tool_input.get("new_string", "")
381
431
  if not content:
382
432
  return {"decision": "allow"}
@@ -7,6 +7,7 @@ from dataclasses import dataclass
7
7
  from .audit_logger import AuditLogger
8
8
  from .config_manager import ConfigManager
9
9
  from .risk_rules import (
10
+ is_chained_command,
10
11
  assess_command_risk,
11
12
  match_critical_pattern,
12
13
  sanitize_command_for_matching,
@@ -69,6 +70,45 @@ class CommandInterceptor:
69
70
  matched_pattern=pattern,
70
71
  )
71
72
 
73
+ # Check the positive allowlist. Deliberately AFTER blocked_patterns and
74
+ # BEFORE require_approval: a deny rule always wins, and an allow rule's
75
+ # whole job is to suppress the approval prompt for a known-safe command.
76
+ #
77
+ # Two guards keep an allowlist from becoming a hole in the guard rail:
78
+ #
79
+ # - A ``critical`` command is never allowlistable. NOTE: this guard is
80
+ # currently UNREACHABLE — evaluate() hard-blocks critical above,
81
+ # before this loop — and a mutation sweep proved it: deleting the
82
+ # guard leaves every test green. It is kept as defence in depth,
83
+ # because it becomes the only protection the day that early block is
84
+ # narrowed. Do not write a test claiming to exercise it; such a test
85
+ # passes with the guard deleted.
86
+ # - A match does not apply when the command holds more than one
87
+ # statement. Patterns are unanchored by request, so without this
88
+ # "^git push" would wave through ``rm -rf / && git push`` — or, via
89
+ # the newline the original regex missed, anything on a second line.
90
+ allowed = getattr(policy, "allowed_patterns", None) or []
91
+ if not isinstance(allowed, list):
92
+ # Defence in depth behind the validator: iterating a str yields
93
+ # CHARACTERS, and a leading "^" then matches every command.
94
+ allowed = []
95
+ for pattern in allowed:
96
+ if not self._matches(command, pattern):
97
+ continue
98
+ if is_chained_command(command):
99
+ # Fall through to normal classification rather than allowing.
100
+ break
101
+ if risk_level == "critical":
102
+ break
103
+ return CommandEvaluation(
104
+ command=command,
105
+ risk_level="low",
106
+ allowed=True,
107
+ requires_approval=False,
108
+ reason=f"Matches allowed pattern: {pattern}",
109
+ matched_pattern=pattern,
110
+ )
111
+
72
112
  # Check approval patterns
73
113
  for pattern in policy.require_approval:
74
114
  if self._matches(command, pattern):
@@ -206,6 +206,17 @@ class ConfigManager:
206
206
  if key in cp and (not isinstance(cp[key], list) or not all(isinstance(v, str) for v in cp[key])):
207
207
  print(f'rafter: config "commandPolicy.{key}" must be an array of strings — using default.', file=sys.stderr)
208
208
  del cp[key]
209
+ # Every sibling key is validated; this one was added to node's
210
+ # validator and not to python's. A bare STRING is the shape
211
+ # `rafter agent config set agent.commandPolicy.allowedPatterns
212
+ # '^git status'` actually writes — json.loads fails, the raw string
213
+ # is stored — and python then iterates its CHARACTERS, so the first
214
+ # one, "^", matches every command and the allowlist allows
215
+ # everything. Node warned and fell back to []; python did not.
216
+ for key in ("allowedPatterns", "allowed_patterns"):
217
+ if key in cp and (not isinstance(cp[key], list) or not all(isinstance(v, str) for v in cp[key])):
218
+ print(f'rafter: config "commandPolicy.{key}" must be an array of strings — using default.', file=sys.stderr)
219
+ del cp[key]
209
220
  for key in ("allowProjectOverride", "allow_project_override"):
210
221
  if key in cp and not isinstance(cp[key], bool):
211
222
  print(f'rafter: config "commandPolicy.{key}" must be a boolean — ignoring (project policies cannot loosen command policy).', file=sys.stderr)
@@ -514,6 +525,8 @@ def merge_command_policy(target, project: dict, allow_override: bool) -> None:
514
525
  target.blocked_patterns = project["blocked_patterns"]
515
526
  if project.get("require_approval") is not None:
516
527
  target.require_approval = project["require_approval"]
528
+ if project.get("allowed_patterns") is not None:
529
+ target.allowed_patterns = project["allowed_patterns"]
517
530
  return
518
531
 
519
532
  mode = project.get("mode")
@@ -540,3 +553,20 @@ def merge_command_policy(target, project: dict, allow_override: bool) -> None:
540
553
  target.require_approval = _union_patterns(
541
554
  target.require_approval, project["require_approval"]
542
555
  )
556
+
557
+ # allowed_patterns is NOT unioned, and that asymmetry is the whole point.
558
+ # Unioning blocked_patterns or require_approval can only ever ADD
559
+ # restriction, so a project contributing to them is safe. An allowlist is
560
+ # the opposite: it is a grant. Union it and a cloned repo ships
561
+ # command_policy: {allowed_patterns: [".*"]}
562
+ # and waves every non-critical command through — precisely the bypass the
563
+ # floor exists to prevent (sable-nz4y / rf-adth). So the owner's allowlist
564
+ # stands and the project's is refused unless the owner opted in.
565
+ if project.get("allowed_patterns") is not None:
566
+ print(
567
+ "rafter: project policy sets agent.commandPolicy.allowed_patterns, which can "
568
+ "only loosen command policy — ignoring. Set "
569
+ "agent.commandPolicy.allowProjectOverride: true in your global config to "
570
+ "allow project policies to loosen command policy.",
571
+ file=sys.stderr,
572
+ )
@@ -56,6 +56,20 @@ class CommandPolicyConfig:
56
56
  #: would be no floor at all. Default (absent/False) keeps the floor: a
57
57
  #: project policy may tighten command policy, never loosen it.
58
58
  allow_project_override: bool = False
59
+ #: Positive allowlist: unanchored regexes that force a command to ``low``
60
+ #: and skip the approval prompt. For the known-safe command that would
61
+ #: otherwise trip a broad risk tier — the motivating case being
62
+ #: ``git push --force-with-lease`` to a feature branch on a repo whose main
63
+ #: is protected server-side.
64
+ #:
65
+ #: Three properties make this safe to put on a guard rail, and all three are
66
+ #: enforced in CommandInterceptor, not here:
67
+ #: 1. blocked_patterns always wins. An allowlist never re-opens what a
68
+ #: deny rule closed.
69
+ #: 2. A ``critical`` command is never allowlistable.
70
+ #: 3. A match does not apply when the command contains a chain operator,
71
+ #: so ``^git push`` cannot wave through ``rm -rf / && git push``.
72
+ allowed_patterns: list[str] = field(default_factory=list)
59
73
 
60
74
 
61
75
  @dataclass
@@ -94,6 +94,13 @@ def _map_policy(raw: dict) -> dict:
94
94
  policy["command_policy"]["blocked_patterns"] = cp["blocked_patterns"]
95
95
  if isinstance(cp.get("require_approval"), list):
96
96
  policy["command_policy"]["require_approval"] = cp["require_approval"]
97
+ # Without this the allowlist is unreachable from .rafter.yml: the
98
+ # interceptor reads allowed_patterns off the merged config and nothing
99
+ # ever put it there. Any new command_policy key needs a line HERE and
100
+ # in config_manager's merge, or it is documentation for a feature that
101
+ # does not run. (rf-3n1i)
102
+ if isinstance(cp.get("allowed_patterns"), list):
103
+ policy["command_policy"]["allowed_patterns"] = cp["allowed_patterns"]
97
104
 
98
105
  scan = raw.get("scan")
99
106
  if isinstance(scan, dict):
@@ -252,6 +259,13 @@ def _validate_policy(policy: dict, raw: dict) -> dict:
252
259
  if "mode" in cp and cp["mode"] not in _VALID_COMMAND_MODES:
253
260
  print('Warning: "command_policy.mode" must be one of: allow-all, approve-dangerous, deny-list \u2014 ignoring.', file=sys.stderr)
254
261
  del cp["mode"]
262
+ if "allowed_patterns" in cp:
263
+ if not isinstance(cp["allowed_patterns"], list) or not all(isinstance(v, str) for v in cp["allowed_patterns"]):
264
+ print(
265
+ 'rafter: "command_policy.allowed_patterns" must be a list of strings — ignoring.',
266
+ file=sys.stderr,
267
+ )
268
+ cp.pop("allowed_patterns", None)
255
269
  if "blocked_patterns" in cp:
256
270
  if not isinstance(cp["blocked_patterns"], list) or not all(isinstance(v, str) for v in cp["blocked_patterns"]):
257
271
  print('Warning: "command_policy.blocked_patterns" must be an array of strings \u2014 ignoring.', file=sys.stderr)
@@ -129,6 +129,8 @@ _TEXT_FLAGS = {
129
129
  # Operators that chain independent commands.
130
130
  _CHAIN_OPS = {";", "&&", "||", "|", "&"}
131
131
 
132
+
133
+
132
134
  # Operators whose following token is a redirect target (a path — never data).
133
135
  _REDIRECT_OPS = {">", ">>", "<", "<<"}
134
136
 
@@ -348,6 +350,21 @@ def _tokenize(s: str) -> tuple[list[_Piece], bool]:
348
350
  return pieces, unterminated
349
351
 
350
352
 
353
+ def is_chained_command(command: str) -> bool:
354
+ r"""True if the command contains more than one statement.
355
+
356
+ Asked of the TOKENIZER rather than a regex. The first version of this was
357
+ ``re.compile(r"[;|&]|&&|\|\|")``, which omits the newline — and a newline
358
+ has been a statement separator in this module since rf-6pqx. With
359
+ ``^git push origin feature/`` allowlisted, ``git push origin feature/x`` and
360
+ a second line holding ``git push --force origin main`` classified ``allow``.
361
+ The tokenizer already normalises ``\n`` to ``;``, so routing the question
362
+ through it removes the second, narrower definition instead of widening it.
363
+ """
364
+ pieces, _ = _tokenize(command)
365
+ return any(p.op is not None and p.op in _CHAIN_OPS for p in pieces)
366
+
367
+
351
368
  def _exec_name(text: str) -> str:
352
369
  """`/usr/bin/rm` -> `rm`; used to classify the executable of a segment."""
353
370
  return text[text.rfind("/") + 1:].lower()
@@ -774,8 +791,106 @@ def sanitize_command_for_matching(command: str) -> str:
774
791
  return _sanitize(command, 0)
775
792
 
776
793
 
794
+ # rf-vnxs — reconfiguring rafter's OWN security controls is a PROTECTED CLASS.
795
+ #
796
+ # The gate must never approve its own disarming. On 0.10.1 it did: the hook
797
+ # rated `rafter agent config set agent.hooks.enabled false` as `low`, allowed
798
+ # it, and every later command — including the CRITICAL tier that no policy,
799
+ # mode or deny-list can override — was then permitted. Three steps, no
800
+ # malformed command, and it persists to config.json.
801
+ #
802
+ # Matched STRUCTURALLY (resolved exec + argv walk), never by string: every one
803
+ # of these was observed ALLOWED on the published artifact — extra whitespace,
804
+ # `FALSE`, `npx @rafter-security/cli …`, `pnpm dlx`, `sudo -E rafter …`, an
805
+ # absolute path, the `agent disable` component route, and three separate
806
+ # self-disabling keys. A blacklist of one string is one alias from being inert.
807
+ _RAFTER_EXECS = {"rafter", "rafter-cli"}
808
+ _PACKAGE_RUNNERS = {"npx", "bunx", "pnpx", "dlx", "pnpm", "yarn", "bun"}
809
+ _RAFTER_PACKAGE = re.compile(r"^(?:@rafter-security/cli|rafter-cli|rafter)(?:@[\w.^~*-]+)?$")
810
+
811
+ # Config keys that gate the hook. A namespace, not a leaf — see hook_control.
812
+ _PROTECTED_CONFIG_KEY = re.compile(r"^(?:agent\.)?(?:hooks|commandpolicy)\b|^agent\.risklevel$")
813
+
814
+
815
+ def _statement_disarms_rafter(words: list[_Piece]) -> bool:
816
+ """True if this statement's argv reconfigures or removes a rafter control.
817
+
818
+ The rafter invocation is looked for at ANY position, not just as the
819
+ statement's own exec: a shell carrying a command string puts it in the
820
+ operand, so `bash -c "rafter agent config set …"` and `bash -c "$(echo
821
+ rafter …)"` both reduce to a statement whose exec is `bash`. A quoted word
822
+ is skipped on the raw pass so `echo 'rafter agent config set …'` stays
823
+ prose; the sanitized pass sees operands that are actually executed already
824
+ unwrapped.
825
+ """
826
+ for j in range(len(words)):
827
+ if words[j].quoted:
828
+ continue
829
+ k = j
830
+ exec_ = _exec_name(words[k].text)
831
+ k += 1
832
+
833
+ if exec_ in _PACKAGE_RUNNERS:
834
+ while k < len(words) and (words[k].text.startswith("-") or words[k].text.lower() in ("dlx", "exec")):
835
+ k += 1
836
+ if k >= len(words) or not _RAFTER_PACKAGE.match(words[k].text.lower()):
837
+ continue
838
+ k += 1
839
+ exec_ = "rafter"
840
+ if exec_ not in _RAFTER_EXECS:
841
+ continue
842
+
843
+ args = [w.text.lower() for w in words[k:] if not w.text.startswith("-")]
844
+ for n in range(len(args) - 1):
845
+ if args[n] == "config" and args[n + 1] == "set":
846
+ key = args[n + 2] if n + 2 < len(args) else None
847
+ if key and _PROTECTED_CONFIG_KEY.match(key):
848
+ return True
849
+ if args[n] == "agent" and args[n + 1] == "disable":
850
+ return True
851
+ return False
852
+
853
+
854
+ def _disarms_rafter(command: str, depth: int = 0) -> bool:
855
+ """True if any statement (or command substitution) disarms rafter."""
856
+ if depth > _MAX_SANITIZE_DEPTH:
857
+ return False
858
+ pieces, _ = _tokenize(command)
859
+ statements: list[list[_Piece]] = []
860
+ stmt: list[_Piece] = []
861
+ for p in pieces:
862
+ if p.op is not None:
863
+ if p.op in _CHAIN_OPS or p.op in ("\n", "\r"):
864
+ statements.append(stmt)
865
+ stmt = []
866
+ continue
867
+ stmt.append(p)
868
+ for sub in (p.substs or []):
869
+ if _disarms_rafter(sub, depth + 1):
870
+ return True
871
+ statements.append(stmt)
872
+ return any(_statement_disarms_rafter(st) for st in statements)
873
+
874
+
875
+ def modifies_rafter_security_config(command: str) -> bool:
876
+ """True if the command disarms rafter, checked on raw AND sanitized text.
877
+
878
+ Raw catches the ordinary invocation; sanitized catches a shell running the
879
+ OUTPUT of something else, whose own exec is `echo` and which a walk over
880
+ the raw argv therefore reads, correctly, as a print.
881
+ """
882
+ if _disarms_rafter(command):
883
+ return True
884
+ sanitized = sanitize_command_for_matching(command)
885
+ return sanitized != command and _disarms_rafter(sanitized)
886
+
887
+
777
888
  def assess_command_risk(command: str) -> str:
778
889
  """Assess risk level of a command string."""
890
+ # rf-vnxs: checked before the pattern loops. The disarm is an ordinary
891
+ # well-formed invocation — nothing malformed for the sanitizer to normalise.
892
+ if modifies_rafter_security_config(command):
893
+ return "critical"
779
894
  cmd = sanitize_command_for_matching(command).strip()
780
895
  if not cmd:
781
896
  return "low"
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: rafter-security
3
3
  description: Security toolkit for AI workflows. Use when scanning code or repos for vulnerabilities, auditing third-party skills/MCPs/agent configs before installing, evaluating shell commands before running them, or generating secure design questions for new features. Provides `rafter run` (remote SAST + SCA, needs RAFTER_API_KEY), `rafter secrets` (offline secrets-only), `rafter agent exec --dry-run` (command-risk classification), and `rafter skill review`.
4
- version: 0.10.2
4
+ version: 0.10.4
5
5
  homepage: https://rafter.so
6
6
  metadata:
7
7
  openclaw:
@@ -81,6 +81,34 @@ DEFAULT_SECRET_PATTERNS: list[Pattern] = [
81
81
  severity="critical",
82
82
  description="Stripe Restricted API Key detected",
83
83
  ),
84
+ # OpenAI (rf-f5is / se-wagv, external report). The hook's Write gate is
85
+ # regex-only and this file had NO OpenAI rule at all, so `sk-proj-` and
86
+ # legacy keys were ALLOWED through the gate at any length while
87
+ # `rafter secrets` caught them via betterleaks. The two engines disagreed
88
+ # and the one guarding writes was the blind one.
89
+ #
90
+ # No `(?i)`: these prefixes and their base62 bodies are case-sensitive, and
91
+ # the convention here is that prefixed vendor tokens (ghp_, AKIA, AIza, xox)
92
+ # match case-sensitively. Lower-casing them would only add false positives.
93
+ Pattern(
94
+ name="OpenAI API Key",
95
+ regex=r"sk-(proj|svcacct|admin)-[A-Za-z0-9_-]{40,}",
96
+ severity="critical",
97
+ description="OpenAI project/service/admin API key detected",
98
+ ),
99
+ Pattern(
100
+ name="OpenAI API Key (legacy)",
101
+ regex=r"sk-[A-Za-z0-9]{20}T3BlbkFJ[A-Za-z0-9]{20}",
102
+ severity="critical",
103
+ description="OpenAI legacy API key detected",
104
+ ),
105
+ # Supabase (rf-f5is / se-wagv). Detected by NEITHER engine at any length.
106
+ Pattern(
107
+ name="Supabase Secret Key",
108
+ regex=r"sb_secret_[A-Za-z0-9_-]{20,}",
109
+ severity="critical",
110
+ description="Supabase secret key detected",
111
+ ),
84
112
  # Twilio
85
113
  Pattern(
86
114
  name="Twilio API Key",
File without changes