rafter-cli 0.11.3__tar.gz → 0.11.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.
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/PKG-INFO +2 -2
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/README.md +1 -1
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/pyproject.toml +1 -1
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/commands/agent.py +10 -6
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/commands/agent_components.py +3 -2
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/commands/hook.py +24 -3
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/commands/issues/issue_builder.py +40 -15
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/commands/issues/issues_app.py +13 -4
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/commands/notify.py +84 -30
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/commands/policy.py +1 -1
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/core/risk_rules.py +226 -2
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/rafter-security-skill.md +1 -1
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/utils/api.py +6 -1
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/__init__.py +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/__main__.py +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/commands/__init__.py +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/commands/backend.py +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/commands/brief.py +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/commands/ci.py +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/commands/docs.py +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/commands/issues/__init__.py +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/commands/issues/dedup.py +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/commands/issues/github_client.py +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/commands/mcp_server.py +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/commands/report.py +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/commands/scan.py +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/commands/sites.py +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/commands/skill.py +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/commands/skill_remote.py +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/core/__init__.py +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/core/audit_logger.py +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/core/command_interceptor.py +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/core/config_manager.py +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/core/config_schema.py +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/core/custom_patterns.py +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/core/docs_loader.py +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/core/hook_control.py +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/core/pattern_engine.py +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/core/policy_loader.py +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/core/suppression_writer.py +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/__init__.py +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/agents/__init__.py +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/agents/rafter.md +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/continue-rules/rafter-code-review.md +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/continue-rules/rafter-secure-design.md +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/continue-rules/rafter-skill-review.md +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/continue-rules/rafter.md +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/cursor-rules/rafter-code-review.mdc +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/cursor-rules/rafter-secure-design.mdc +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/cursor-rules/rafter-skill-review.mdc +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/cursor-rules/rafter.mdc +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/pre-commit-hook.sh +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/pre-push-hook.sh +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/__init__.py +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter/SKILL.md +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter/__init__.py +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter/docs/backend.md +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter/docs/cli-reference.md +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter/docs/finding-triage.md +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter/docs/guardrails.md +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter/docs/shift-left.md +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-code-review/SKILL.md +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-code-review/docs/api.md +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-code-review/docs/asvs.md +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-code-review/docs/cwe-top25.md +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-code-review/docs/investigation-playbook.md +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-code-review/docs/llm.md +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-code-review/docs/web-app.md +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-secure-design/SKILL.md +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-secure-design/docs/api-design.md +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-secure-design/docs/auth.md +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-secure-design/docs/data-storage.md +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-secure-design/docs/dependencies.md +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-secure-design/docs/deployment.md +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-secure-design/docs/ingestion.md +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-secure-design/docs/standards-pointers.md +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-secure-design/docs/threat-modeling.md +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-skill-review/SKILL.md +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-skill-review/docs/authorship-provenance.md +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-skill-review/docs/changelog-review.md +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-skill-review/docs/data-practices.md +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-skill-review/docs/malware-indicators.md +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-skill-review/docs/prompt-injection.md +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-skill-review/docs/telemetry.md +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/windsurf-rules/rafter-code-review.md +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/windsurf-rules/rafter-secure-design.md +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/windsurf-rules/rafter-skill-review.md +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/windsurf-rules/rafter.md +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/scanners/__init__.py +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/scanners/betterleaks.py +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/scanners/git_diff_scan.py +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/scanners/git_history_scan.py +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/scanners/regex_scanner.py +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/scanners/secret_patterns.py +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/scanners/skill_scanner.py +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/scanners/union.py +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/utils/__init__.py +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/utils/binary_manager.py +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/utils/formatter.py +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/utils/git.py +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/utils/git_diff.py +0 -0
- {rafter_cli-0.11.3 → rafter_cli-0.11.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.11.
|
|
3
|
+
Version: 0.11.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
|
|
@@ -166,7 +166,7 @@ Export Rafter policy for agent platforms.
|
|
|
166
166
|
|
|
167
167
|
```bash
|
|
168
168
|
# Filter high-severity vulnerabilities (SARIF levels: error, warning, note)
|
|
169
|
-
rafter get SCAN_ID --format json | jq '.vulnerabilities[] | select(.
|
|
169
|
+
rafter get SCAN_ID --format json | jq '.vulnerabilities[] | select(.severity=="error")'
|
|
170
170
|
|
|
171
171
|
# CI gate
|
|
172
172
|
if rafter get SCAN_ID --format json | jq -e '.vulnerabilities | length > 0'; then
|
|
@@ -139,7 +139,7 @@ Export Rafter policy for agent platforms.
|
|
|
139
139
|
|
|
140
140
|
```bash
|
|
141
141
|
# Filter high-severity vulnerabilities (SARIF levels: error, warning, note)
|
|
142
|
-
rafter get SCAN_ID --format json | jq '.vulnerabilities[] | select(.
|
|
142
|
+
rafter get SCAN_ID --format json | jq '.vulnerabilities[] | select(.severity=="error")'
|
|
143
143
|
|
|
144
144
|
# CI gate
|
|
145
145
|
if rafter get SCAN_ID --format json | jq -e '.vulnerabilities | length > 0'; then
|
|
@@ -223,7 +223,7 @@ def _install_claude_code_hooks(root: Path, quiet: bool = False) -> None:
|
|
|
223
223
|
post_hook = {"type": "command", "command": post_command}
|
|
224
224
|
settings["hooks"]["PreToolUse"].extend([
|
|
225
225
|
{"matcher": "Bash", "hooks": [pre_hook]},
|
|
226
|
-
{"matcher": "Write|Edit", "hooks": [pre_hook]},
|
|
226
|
+
{"matcher": "Write|Edit|MultiEdit", "hooks": [pre_hook]},
|
|
227
227
|
])
|
|
228
228
|
# Narrow to tools that produce scannable output (shell output + file
|
|
229
229
|
# writes). Firing posttool on every tool — including Read and MCP calls —
|
|
@@ -733,9 +733,9 @@ def _install_gemini_mcp(root: Path, quiet: bool = False) -> bool:
|
|
|
733
733
|
|
|
734
734
|
# Cursor hook events covered by rafter (rf-svn3).
|
|
735
735
|
_CURSOR_HOOK_EVENTS: tuple[tuple[str, str], ...] = (
|
|
736
|
-
("preToolUse", "
|
|
737
|
-
("postToolUse", "
|
|
738
|
-
("beforeShellExecution", "
|
|
736
|
+
("preToolUse", "hook pretool --format cursor"),
|
|
737
|
+
("postToolUse", "hook posttool --format cursor"),
|
|
738
|
+
("beforeShellExecution", "hook pretool --format cursor"),
|
|
739
739
|
)
|
|
740
740
|
|
|
741
741
|
# Skills shipped as both Cursor rules and Claude Code / Codex / Gemini skills.
|
|
@@ -803,7 +803,7 @@ def _install_gemini_hooks(root: Path, quiet: bool = False) -> None:
|
|
|
803
803
|
hooks = settings.setdefault("hooks", {})
|
|
804
804
|
rafter_bin = _resolve_rafter_path()
|
|
805
805
|
for event, sub, matcher in (
|
|
806
|
-
("BeforeTool", "pretool", "run_shell_command|write_file|replace
|
|
806
|
+
("BeforeTool", "pretool", "run_shell_command|write_file|replace"),
|
|
807
807
|
("AfterTool", "posttool", ".*"),
|
|
808
808
|
):
|
|
809
809
|
entries = hooks.get(event) or []
|
|
@@ -842,7 +842,11 @@ def _install_cursor_hooks(root: Path, quiet: bool = False) -> None:
|
|
|
842
842
|
config.setdefault("version", 1)
|
|
843
843
|
config.setdefault("hooks", {})
|
|
844
844
|
|
|
845
|
-
|
|
845
|
+
# The resolved path, as for the Claude Code and Codex hooks: Cursor runs
|
|
846
|
+
# hooks without the user's shell PATH, where a bare `rafter` may not resolve.
|
|
847
|
+
rafter_bin = _resolve_rafter_path()
|
|
848
|
+
for event, args in _CURSOR_HOOK_EVENTS:
|
|
849
|
+
command = f"{rafter_bin} {args}"
|
|
846
850
|
entries = config["hooks"].get(event)
|
|
847
851
|
if not isinstance(entries, list):
|
|
848
852
|
entries = []
|
|
@@ -205,7 +205,7 @@ def _claude_code_hooks() -> ComponentSpec:
|
|
|
205
205
|
del hooks["SessionStart"]
|
|
206
206
|
hooks["PreToolUse"].extend([
|
|
207
207
|
{"matcher": "Bash", "hooks": [pre]},
|
|
208
|
-
{"matcher": "Write|Edit", "hooks": [pre]},
|
|
208
|
+
{"matcher": "Write|Edit|MultiEdit", "hooks": [pre]},
|
|
209
209
|
])
|
|
210
210
|
# Narrow to tools that produce scannable output (shell output + file
|
|
211
211
|
# writes); avoids firing posttool on every Read/MCP call (latency).
|
|
@@ -594,8 +594,9 @@ def _gemini_hooks() -> ComponentSpec:
|
|
|
594
594
|
h["AfterTool"] = _filter_hooks(h["AfterTool"], lambda e: _hook_entry_has_rafter(e, "rafter hook posttool"))
|
|
595
595
|
# Explicit Gemini built-in tool names per geminicli.com/docs/hooks/reference
|
|
596
596
|
# (rf-044o verification).
|
|
597
|
+
# No tool is named `edit`: Gemini's edit tool is `replace`.
|
|
597
598
|
h["BeforeTool"].append({
|
|
598
|
-
"matcher": "run_shell_command|write_file|replace
|
|
599
|
+
"matcher": "run_shell_command|write_file|replace",
|
|
599
600
|
"hooks": [{"type": "command", "command": "rafter hook pretool --format gemini", "timeout": 5000}],
|
|
600
601
|
})
|
|
601
602
|
h["AfterTool"].append({
|
|
@@ -455,8 +455,9 @@ def _writes_rafter_security_config(file_path: str) -> bool:
|
|
|
455
455
|
def _evaluate_write(tool_input: dict) -> dict:
|
|
456
456
|
# rf-vnxs: checked FIRST, and before the no-content early return, because a
|
|
457
457
|
# truncating write with empty content disables the hook just as well as one
|
|
458
|
-
# carrying JSON.
|
|
459
|
-
|
|
458
|
+
# carrying JSON. apply_patch names its targets only in the patch headers.
|
|
459
|
+
targets = [tool_input.get("file_path", ""), *tool_input.get("patch_paths", [])]
|
|
460
|
+
if any(_writes_rafter_security_config(t) for t in targets):
|
|
460
461
|
return {
|
|
461
462
|
"decision": "deny",
|
|
462
463
|
"reason": (
|
|
@@ -572,7 +573,8 @@ def _normalize_write_input(tool_name: str, tool_input: dict) -> dict:
|
|
|
572
573
|
|
|
573
574
|
Continue's Write names its path ``filepath``; MultiEdit and Windsurf's
|
|
574
575
|
pre_write_code carry ``edits[].new_string``; Codex's apply_patch carries
|
|
575
|
-
the whole patch in ``command
|
|
576
|
+
the whole patch in ``command``, with its target paths in the patch
|
|
577
|
+
headers (``patch_paths``).
|
|
576
578
|
"""
|
|
577
579
|
out = dict(tool_input)
|
|
578
580
|
if "file_path" not in out and isinstance(tool_input.get("filepath"), str):
|
|
@@ -585,9 +587,28 @@ def _normalize_write_input(tool_name: str, tool_input: dict) -> dict:
|
|
|
585
587
|
)
|
|
586
588
|
elif tool_name == "apply_patch" and isinstance(tool_input.get("command"), str):
|
|
587
589
|
out["content"] = tool_input["command"]
|
|
590
|
+
if tool_name == "apply_patch" and isinstance(tool_input.get("command"), str):
|
|
591
|
+
out["patch_paths"] = _apply_patch_paths(tool_input["command"])
|
|
588
592
|
return out
|
|
589
593
|
|
|
590
594
|
|
|
595
|
+
# The apply_patch format carries its targets only in hunk headers, which Codex
|
|
596
|
+
# matches on the trimmed line (codex-rs/apply-patch, apply_patch.lark).
|
|
597
|
+
_APPLY_PATCH_HEADERS = ("*** Add File: ", "*** Update File: ", "*** Delete File: ", "*** Move to: ")
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _apply_patch_paths(patch: str) -> list[str]:
|
|
601
|
+
"""Every path an apply_patch names."""
|
|
602
|
+
paths = []
|
|
603
|
+
for raw in patch.split("\n"):
|
|
604
|
+
line = raw.strip()
|
|
605
|
+
for header in _APPLY_PATCH_HEADERS:
|
|
606
|
+
if line.startswith(header):
|
|
607
|
+
paths.append(line[len(header):].strip())
|
|
608
|
+
break
|
|
609
|
+
return paths
|
|
610
|
+
|
|
611
|
+
|
|
591
612
|
def _deny_on_error(exc: Exception) -> dict:
|
|
592
613
|
reason = f"rafter hook error: {type(exc).__name__}: {exc}; denying to be safe"
|
|
593
614
|
print(reason, file=sys.stderr)
|
|
@@ -17,10 +17,28 @@ class IssueDraft:
|
|
|
17
17
|
|
|
18
18
|
@dataclass
|
|
19
19
|
class BackendVulnerability:
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
"""Backend vulnerability from `GET /api/static/scan` (`rafter get <id> --format json`).
|
|
21
|
+
|
|
22
|
+
The real API shape (`toRafterFindings()` on the backend) is
|
|
23
|
+
`rule_id`/`severity`/`title`/`description`/`file_path`/`line_start`/
|
|
24
|
+
`line_end` plus optional `cwe`/`confidence`. `level`/`message`/`file`/
|
|
25
|
+
`line` are the pre-rename names, kept as a fallback for old cached
|
|
26
|
+
payloads and existing tests, never the primary source.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
rule_id: str = ""
|
|
30
|
+
severity: str = ""
|
|
31
|
+
title: str = ""
|
|
32
|
+
description: str = ""
|
|
33
|
+
file_path: str | None = None
|
|
34
|
+
line_start: int | None = None
|
|
35
|
+
line_end: int | None = None
|
|
36
|
+
cwe: str | None = None
|
|
37
|
+
confidence: str | None = None
|
|
38
|
+
# Pre-rename names.
|
|
39
|
+
level: str = ""
|
|
40
|
+
message: str = ""
|
|
41
|
+
file: str = ""
|
|
24
42
|
line: int | None = None
|
|
25
43
|
|
|
26
44
|
|
|
@@ -67,24 +85,31 @@ def _truncate(s: str, max_len: int) -> str:
|
|
|
67
85
|
|
|
68
86
|
|
|
69
87
|
def build_from_backend_vulnerability(vuln: BackendVulnerability) -> IssueDraft:
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
88
|
+
rule_id = vuln.rule_id or "unknown-rule"
|
|
89
|
+
level = vuln.severity or vuln.level or "note"
|
|
90
|
+
summary = vuln.title or vuln.message or "Security finding"
|
|
91
|
+
details = vuln.description or vuln.message or summary
|
|
92
|
+
file = vuln.file_path or vuln.file or "unknown file"
|
|
93
|
+
line = vuln.line_start if vuln.line_start is not None else vuln.line
|
|
94
|
+
|
|
95
|
+
sev = _severity_label(level)
|
|
96
|
+
emoji = _severity_emoji(level)
|
|
97
|
+
fp = fingerprint(file, rule_id)
|
|
73
98
|
|
|
74
|
-
title = f"{emoji} [{sev.upper()}] {
|
|
99
|
+
title = f"{emoji} [{sev.upper()}] {rule_id}: {_truncate(summary, 80)}"
|
|
75
100
|
|
|
76
101
|
body = f"## Security Finding\n\n"
|
|
77
|
-
body += f"**Rule:** `{
|
|
102
|
+
body += f"**Rule:** `{rule_id}`\n"
|
|
78
103
|
body += f"**Severity:** {sev}\n"
|
|
79
|
-
body += f"**File:** `{
|
|
80
|
-
if
|
|
81
|
-
body += f" (line {
|
|
104
|
+
body += f"**File:** `{file}`"
|
|
105
|
+
if line:
|
|
106
|
+
body += f" (line {line})"
|
|
82
107
|
body += "\n\n"
|
|
83
|
-
body += f"### Description\n\n{
|
|
84
|
-
body += f"### Remediation\n\nReview and fix the finding in `{
|
|
108
|
+
body += f"### Description\n\n{details}\n\n"
|
|
109
|
+
body += f"### Remediation\n\nReview and fix the finding in `{file}`.\n"
|
|
85
110
|
body += "\n---\n*Created by [Rafter CLI](https://rafter.so) — security for AI builders*\n"
|
|
86
111
|
|
|
87
|
-
labels = ["security", f"severity:{sev}", f"rule:{
|
|
112
|
+
labels = ["security", f"severity:{sev}", f"rule:{rule_id}"]
|
|
88
113
|
|
|
89
114
|
return IssueDraft(
|
|
90
115
|
title=title,
|
|
@@ -268,10 +268,19 @@ def _drafts_from_backend(scan_id: str, api_key: str | None) -> list[IssueDraft]:
|
|
|
268
268
|
return [
|
|
269
269
|
build_from_backend_vulnerability(
|
|
270
270
|
BackendVulnerability(
|
|
271
|
-
rule_id=v
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
271
|
+
rule_id=v.get("rule_id") or v.get("ruleId") or "",
|
|
272
|
+
severity=v.get("severity") or "",
|
|
273
|
+
title=v.get("title") or "",
|
|
274
|
+
description=v.get("description") or "",
|
|
275
|
+
file_path=v.get("file_path"),
|
|
276
|
+
line_start=v.get("line_start"),
|
|
277
|
+
line_end=v.get("line_end"),
|
|
278
|
+
cwe=v.get("cwe"),
|
|
279
|
+
confidence=v.get("confidence"),
|
|
280
|
+
# Pre-rename names — fallback for old cached payloads.
|
|
281
|
+
level=v.get("level") or "",
|
|
282
|
+
message=v.get("message") or "",
|
|
283
|
+
file=v.get("file") or "",
|
|
275
284
|
line=v.get("line"),
|
|
276
285
|
)
|
|
277
286
|
)
|
|
@@ -57,19 +57,81 @@ def _detect_platform(url: str) -> str:
|
|
|
57
57
|
return "generic"
|
|
58
58
|
|
|
59
59
|
|
|
60
|
+
def _severity_tier(raw: str | None) -> str:
|
|
61
|
+
"""Map a raw severity/level to a display tier.
|
|
62
|
+
|
|
63
|
+
Uses the same rules as the GitHub Action (github-action/action.yml) and
|
|
64
|
+
``rafter get``, so notify agrees with both. A value outside the known set is
|
|
65
|
+
kept verbatim so a finding is never silently dropped.
|
|
66
|
+
"""
|
|
67
|
+
s = (raw or "").lower()
|
|
68
|
+
if s == "critical":
|
|
69
|
+
return "critical"
|
|
70
|
+
if s in ("error", "high"):
|
|
71
|
+
return "high"
|
|
72
|
+
if s in ("warning", "medium"):
|
|
73
|
+
return "medium"
|
|
74
|
+
if s in ("note", "low"):
|
|
75
|
+
return "low"
|
|
76
|
+
return s or "unknown"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _normalize_scan(scan_data: dict) -> dict:
|
|
80
|
+
"""Read findings from the real scan shape (``vulnerabilities[]``).
|
|
81
|
+
|
|
82
|
+
Falls back to the legacy ``findings[]`` for old cached payloads. Counts are
|
|
83
|
+
derived from each finding's own severity — never from ``summary`` — so a
|
|
84
|
+
finding cannot be lost to a summary field this code does not read. "Clean"
|
|
85
|
+
is therefore reported only when there are genuinely zero findings.
|
|
86
|
+
"""
|
|
87
|
+
raw = scan_data.get("vulnerabilities")
|
|
88
|
+
if not isinstance(raw, list):
|
|
89
|
+
raw = scan_data.get("findings")
|
|
90
|
+
if not isinstance(raw, list):
|
|
91
|
+
raw = []
|
|
92
|
+
|
|
93
|
+
counts = {"critical": 0, "high": 0, "medium": 0, "low": 0}
|
|
94
|
+
findings = []
|
|
95
|
+
for v in raw:
|
|
96
|
+
tier = _severity_tier(v.get("severity") or v.get("level"))
|
|
97
|
+
if tier in counts:
|
|
98
|
+
counts[tier] += 1
|
|
99
|
+
title = (
|
|
100
|
+
v.get("title")
|
|
101
|
+
or v.get("rule_id")
|
|
102
|
+
or v.get("ruleId")
|
|
103
|
+
or v.get("message")
|
|
104
|
+
or "Unknown"
|
|
105
|
+
)
|
|
106
|
+
file = v.get("file_path") or v.get("file")
|
|
107
|
+
line = v.get("line_start") if v.get("line_start") is not None else v.get("line")
|
|
108
|
+
location = v.get("location") or file or ""
|
|
109
|
+
if not v.get("location") and file and line is not None:
|
|
110
|
+
location = f"{file}:{line}"
|
|
111
|
+
findings.append({"severity": tier, "title": title, "location": location})
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
"findings": findings,
|
|
115
|
+
"critical": counts["critical"],
|
|
116
|
+
"high": counts["high"],
|
|
117
|
+
"medium": counts["medium"],
|
|
118
|
+
"low": counts["low"],
|
|
119
|
+
"total": len(findings),
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
60
123
|
def _format_slack_payload(scan_data: dict) -> dict:
|
|
61
124
|
"""Format scan results as a Slack message with Block Kit."""
|
|
62
125
|
status = scan_data.get("status", "unknown")
|
|
63
126
|
repo = scan_data.get("repository_name", "unknown")
|
|
64
127
|
scan_id = scan_data.get("scan_id", "")
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
total = critical + high + medium + low
|
|
128
|
+
norm = _normalize_scan(scan_data)
|
|
129
|
+
findings = norm["findings"]
|
|
130
|
+
critical = norm["critical"]
|
|
131
|
+
high = norm["high"]
|
|
132
|
+
medium = norm["medium"]
|
|
133
|
+
low = norm["low"]
|
|
134
|
+
total = norm["total"]
|
|
73
135
|
|
|
74
136
|
# Status emoji
|
|
75
137
|
if status == "completed" and total == 0:
|
|
@@ -140,12 +202,9 @@ def _format_slack_payload(scan_data: dict) -> dict:
|
|
|
140
202
|
if findings:
|
|
141
203
|
finding_lines = []
|
|
142
204
|
for f in findings[:5]:
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
line = f"• `[{sev}]` {title}"
|
|
147
|
-
if location:
|
|
148
|
-
line += f" — {location}"
|
|
205
|
+
line = f"• `[{f['severity'].upper()}]` {f['title']}"
|
|
206
|
+
if f["location"]:
|
|
207
|
+
line += f" — {f['location']}"
|
|
149
208
|
finding_lines.append(line)
|
|
150
209
|
|
|
151
210
|
if len(findings) > 5:
|
|
@@ -179,14 +238,13 @@ def _format_discord_payload(scan_data: dict) -> dict:
|
|
|
179
238
|
status = scan_data.get("status", "unknown")
|
|
180
239
|
repo = scan_data.get("repository_name", "unknown")
|
|
181
240
|
scan_id = scan_data.get("scan_id", "")
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
total = critical + high + medium + low
|
|
241
|
+
norm = _normalize_scan(scan_data)
|
|
242
|
+
findings = norm["findings"]
|
|
243
|
+
critical = norm["critical"]
|
|
244
|
+
high = norm["high"]
|
|
245
|
+
medium = norm["medium"]
|
|
246
|
+
low = norm["low"]
|
|
247
|
+
total = norm["total"]
|
|
190
248
|
|
|
191
249
|
# Embed color
|
|
192
250
|
if status == "completed" and total == 0:
|
|
@@ -237,12 +295,9 @@ def _format_discord_payload(scan_data: dict) -> dict:
|
|
|
237
295
|
if findings:
|
|
238
296
|
finding_lines = []
|
|
239
297
|
for f in findings[:5]:
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
line = f"• `[{sev}]` {title}"
|
|
244
|
-
if location:
|
|
245
|
-
line += f" — {location}"
|
|
298
|
+
line = f"• `[{f['severity'].upper()}]` {f['title']}"
|
|
299
|
+
if f["location"]:
|
|
300
|
+
line += f" — {f['location']}"
|
|
246
301
|
finding_lines.append(line)
|
|
247
302
|
|
|
248
303
|
if len(findings) > 5:
|
|
@@ -271,8 +326,7 @@ def _format_generic_payload(scan_data: dict) -> dict:
|
|
|
271
326
|
"""Format scan results as a plain JSON payload with text field."""
|
|
272
327
|
status = scan_data.get("status", "unknown")
|
|
273
328
|
repo = scan_data.get("repository_name", "unknown")
|
|
274
|
-
|
|
275
|
-
total = sum(summary.get(k, 0) for k in ("critical", "high", "medium", "low"))
|
|
329
|
+
total = _normalize_scan(scan_data)["total"]
|
|
276
330
|
|
|
277
331
|
if status == "completed" and total == 0:
|
|
278
332
|
status_text = "Clean — no issues found"
|
|
@@ -23,7 +23,7 @@ def _generate_claude_config() -> str:
|
|
|
23
23
|
"hooks": [{"type": "command", "command": "rafter hook pretool"}],
|
|
24
24
|
},
|
|
25
25
|
{
|
|
26
|
-
"matcher": "Write|Edit",
|
|
26
|
+
"matcher": "Write|Edit|MultiEdit",
|
|
27
27
|
"hooks": [{"type": "command", "command": "rafter hook pretool"}],
|
|
28
28
|
},
|
|
29
29
|
],
|
|
@@ -978,10 +978,231 @@ def modifies_rafter_security_config(command: str) -> bool:
|
|
|
978
978
|
"""
|
|
979
979
|
if _disarms_rafter(command):
|
|
980
980
|
return True
|
|
981
|
+
if _writes_rafter_dir(command):
|
|
982
|
+
return True
|
|
981
983
|
sanitized = sanitize_command_for_matching(command)
|
|
982
984
|
return sanitized != command and _disarms_rafter(sanitized)
|
|
983
985
|
|
|
984
986
|
|
|
987
|
+
def _writes_rafter_dir(command: str) -> bool:
|
|
988
|
+
"""sable-36oe: the SHELL route to rafter's own directory (``~/.rafter``).
|
|
989
|
+
|
|
990
|
+
The Write/Edit route already refuses it (rf-vnxs, ``_writes_rafter_security_config``
|
|
991
|
+
in commands/hook.py); ``rm ~/.rafter/config.json`` disarms the gate exactly
|
|
992
|
+
as ``rafter agent config set agent.hooks.enabled false`` does.
|
|
993
|
+
|
|
994
|
+
Deny-by-default scoped to the path, NOT a blocklist of write verbs, which
|
|
995
|
+
are unbounded. A command is protected when a statement that NAMES the
|
|
996
|
+
rafter dir is not a known read-only command without an output redirect.
|
|
997
|
+
Statements that do not name it are not constrained; a cd INTO the dir is
|
|
998
|
+
itself not read-only, which keeps ``cd ~/.rafter && echo x > config.json``
|
|
999
|
+
blocked. Checked on the raw text (a quoted reference
|
|
1000
|
+
counts) and on the normalized view (brace lists, ANSI-C quotes). Mirrors
|
|
1001
|
+
node/src/core/risk-rules.ts ``writesRafterDir`` decision for decision.
|
|
1002
|
+
"""
|
|
1003
|
+
if _rafter_dir_scan(command):
|
|
1004
|
+
return True
|
|
1005
|
+
normalized = normalize_for_matching(command)
|
|
1006
|
+
return normalized != command and _rafter_dir_scan(normalized)
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
_RAFTER_DIR_NAME = ".rafter"
|
|
1010
|
+
_NAME_CHAR = re.compile(r"[a-z0-9_.-]", re.IGNORECASE)
|
|
1011
|
+
_GLOB_CHAR = re.compile(r"[*?\[]")
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def _is_name_char(c: str) -> bool:
|
|
1015
|
+
"""Characters that continue a file name: ``.rafter.yml`` is not ``.rafter``."""
|
|
1016
|
+
return bool(_NAME_CHAR.fullmatch(c))
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def _names_rafter_dir(text: str, globs: bool) -> bool:
|
|
1020
|
+
"""True if ``text`` names the rafter dir as a whole path component.
|
|
1021
|
+
|
|
1022
|
+
Case-insensitive (macOS file systems are). With ``globs``, an unquoted
|
|
1023
|
+
component that could glob to it (``.raf*``, ``.r?fter``, ``.*``) counts:
|
|
1024
|
+
bash never lets a wildcard match a leading dot, so only a component that
|
|
1025
|
+
starts with a literal "." qualifies, and the match is over-approximated as
|
|
1026
|
+
"the literal characters outside brackets appear in order in 'rafter'".
|
|
1027
|
+
"""
|
|
1028
|
+
t = text.lower()
|
|
1029
|
+
n = len(_RAFTER_DIR_NAME)
|
|
1030
|
+
i = t.find(_RAFTER_DIR_NAME)
|
|
1031
|
+
while i != -1:
|
|
1032
|
+
before = t[i - 1] if i > 0 else ""
|
|
1033
|
+
after = t[i + n] if i + n < len(t) else ""
|
|
1034
|
+
if (before == "" or not _is_name_char(before)) and (after == "" or not _is_name_char(after)):
|
|
1035
|
+
return True
|
|
1036
|
+
i = t.find(_RAFTER_DIR_NAME, i + 1)
|
|
1037
|
+
if not globs:
|
|
1038
|
+
return False
|
|
1039
|
+
for comp in t.split("/"):
|
|
1040
|
+
if not comp or comp[0] != "." or not _GLOB_CHAR.search(comp):
|
|
1041
|
+
continue
|
|
1042
|
+
want = "rafter"
|
|
1043
|
+
k = 0
|
|
1044
|
+
in_bracket = False
|
|
1045
|
+
ok = True
|
|
1046
|
+
for c in comp[1:]:
|
|
1047
|
+
if in_bracket:
|
|
1048
|
+
if c == "]":
|
|
1049
|
+
in_bracket = False
|
|
1050
|
+
continue
|
|
1051
|
+
if c == "[":
|
|
1052
|
+
in_bracket = True
|
|
1053
|
+
continue
|
|
1054
|
+
if c in ("*", "?"):
|
|
1055
|
+
continue
|
|
1056
|
+
while k < len(want) and want[k] != c:
|
|
1057
|
+
k += 1
|
|
1058
|
+
if k == len(want):
|
|
1059
|
+
ok = False
|
|
1060
|
+
break
|
|
1061
|
+
k += 1
|
|
1062
|
+
if ok:
|
|
1063
|
+
return True
|
|
1064
|
+
return False
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
#: Read-only commands: with no output redirect they can neither write a file
|
|
1068
|
+
#: nor run another program. NOT here: sed (its ``w``/``e`` commands write and
|
|
1069
|
+
#: execute without -i), less (``-o`` writes a log), tee/cp/mv/rm/ln/chmod/
|
|
1070
|
+
#: touch/truncate/dd, and anything that runs a program.
|
|
1071
|
+
_READ_ONLY_EXECS = {
|
|
1072
|
+
"cat", "more", "head", "tail", "ls", "stat", "file", "wc",
|
|
1073
|
+
"grep", "egrep", "fgrep", "rg", "jq", "diff", "cmp",
|
|
1074
|
+
"md5sum", "sha1sum", "sha224sum", "sha256sum", "sha384sum", "sha512sum",
|
|
1075
|
+
"shasum", "b2sum", "cksum",
|
|
1076
|
+
"find", "test", "[", "realpath", "readlink", "du",
|
|
1077
|
+
"cd", "pushd", "popd",
|
|
1078
|
+
}
|
|
1079
|
+
#: find actions that delete, run a program, or write a file.
|
|
1080
|
+
_FIND_WRITE_ACTIONS = {
|
|
1081
|
+
"-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprint", "-fprint0", "-fprintf", "-fls",
|
|
1082
|
+
}
|
|
1083
|
+
#: Only a bare name or a system bin dir: ``./cat`` is whatever the agent wrote there.
|
|
1084
|
+
_SYSTEM_BIN = re.compile(r"^(?:/usr(?:/local)?|/opt/homebrew)?/s?bin/[^/]+$")
|
|
1085
|
+
_WRITE_REDIRECTS = {">", ">>"}
|
|
1086
|
+
_FILE_COMPILE_FLAG = re.compile(r"^-[A-Za-z]*C")
|
|
1087
|
+
_FD_DUP_TARGET = re.compile(r"^(?:\d+|-)$")
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
class _RafterDirScan:
|
|
1091
|
+
__slots__ = ("protected",)
|
|
1092
|
+
|
|
1093
|
+
def __init__(self) -> None:
|
|
1094
|
+
self.protected = False
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
def _rafter_dir_scan(command: str) -> bool:
|
|
1098
|
+
"""True if some statement names the rafter dir and is not read-only."""
|
|
1099
|
+
acc = _RafterDirScan()
|
|
1100
|
+
_scan_rafter_dir_statements(command, 0, acc)
|
|
1101
|
+
return acc.protected
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
def _scan_rafter_dir_statements(command: str, depth: int, acc: _RafterDirScan) -> None:
|
|
1105
|
+
# Past the recursion cap, or on a desynchronized parse, the structure is
|
|
1106
|
+
# unknown: FAIL CLOSED, a reference anywhere in the text protects it.
|
|
1107
|
+
if depth > _MAX_SANITIZE_DEPTH:
|
|
1108
|
+
if _names_rafter_dir(command, False):
|
|
1109
|
+
acc.protected = True
|
|
1110
|
+
return
|
|
1111
|
+
pieces, unterminated = _tokenize(command)
|
|
1112
|
+
if unterminated and _names_rafter_dir(command, False):
|
|
1113
|
+
acc.protected = True
|
|
1114
|
+
|
|
1115
|
+
stmt: list[_Piece] = []
|
|
1116
|
+
for i, p in enumerate(pieces):
|
|
1117
|
+
for sub in p.substs:
|
|
1118
|
+
_scan_rafter_dir_statements(sub, depth + 1, acc)
|
|
1119
|
+
if p.op is not None and p.op in _CHAIN_OPS:
|
|
1120
|
+
# ``>|``, ``>&``, ``&>``, ``&>>`` are redirects spelled with a chain
|
|
1121
|
+
# character; splitting on them would orphan the target from its ``>``.
|
|
1122
|
+
prev = pieces[i - 1] if i > 0 else None
|
|
1123
|
+
nxt = pieces[i + 1] if i + 1 < len(pieces) else None
|
|
1124
|
+
after_redirect = (
|
|
1125
|
+
p.op not in ("&&", "||") and prev is not None
|
|
1126
|
+
and prev.end == p.start and prev.op in _WRITE_REDIRECTS
|
|
1127
|
+
)
|
|
1128
|
+
before_redirect = (
|
|
1129
|
+
p.op == "&" and nxt is not None
|
|
1130
|
+
and nxt.start == p.end and nxt.op in _WRITE_REDIRECTS
|
|
1131
|
+
)
|
|
1132
|
+
if not after_redirect and not before_redirect:
|
|
1133
|
+
_scan_rafter_dir_statement(stmt, acc)
|
|
1134
|
+
stmt = []
|
|
1135
|
+
continue
|
|
1136
|
+
stmt.append(p)
|
|
1137
|
+
_scan_rafter_dir_statement(stmt, acc)
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
def _scan_rafter_dir_statement(stmt: list[_Piece], acc: _RafterDirScan) -> None:
|
|
1141
|
+
argv0 = -1
|
|
1142
|
+
redirect = False
|
|
1143
|
+
names = False
|
|
1144
|
+
for i, p in enumerate(stmt):
|
|
1145
|
+
if p.op is not None:
|
|
1146
|
+
if p.op in _WRITE_REDIRECTS and not _discards_output(stmt, i):
|
|
1147
|
+
redirect = True
|
|
1148
|
+
continue
|
|
1149
|
+
prev_op = stmt[i - 1].op if i > 0 else None
|
|
1150
|
+
is_redirect_target = prev_op is not None and prev_op in _REDIRECT_OPS
|
|
1151
|
+
# Executing a binary that lives in the dir (``~/.rafter/bin/betterleaks
|
|
1152
|
+
# version``) is not a reference; its arguments still are. Only an
|
|
1153
|
+
# UNQUOTED argv0 is a path the shell runs: a quoted one is a heredoc
|
|
1154
|
+
# body line or other code, never a binary.
|
|
1155
|
+
if argv0 == -1 and not is_redirect_target:
|
|
1156
|
+
argv0 = i
|
|
1157
|
+
if not p.quoted and not _ENV_ASSIGNMENT.match(p.text):
|
|
1158
|
+
continue
|
|
1159
|
+
if _names_rafter_dir(p.text, not p.quoted):
|
|
1160
|
+
names = True
|
|
1161
|
+
if names and (redirect or not _statement_read_only(stmt, argv0)):
|
|
1162
|
+
acc.protected = True
|
|
1163
|
+
|
|
1164
|
+
|
|
1165
|
+
def _discards_output(stmt: list[_Piece], i: int) -> bool:
|
|
1166
|
+
"""``2>/dev/null``, ``2>&1``, ``>&-``: an output redirect that writes no file."""
|
|
1167
|
+
j = i + 1
|
|
1168
|
+
dup = False
|
|
1169
|
+
if j < len(stmt) and stmt[j].op == "&" and stmt[j].start == stmt[i].end:
|
|
1170
|
+
dup = True
|
|
1171
|
+
j += 1
|
|
1172
|
+
if j >= len(stmt):
|
|
1173
|
+
return False
|
|
1174
|
+
target = stmt[j]
|
|
1175
|
+
if target.op is not None or target.substs:
|
|
1176
|
+
return False
|
|
1177
|
+
return bool(_FD_DUP_TARGET.match(target.text)) if dup else target.text == "/dev/null"
|
|
1178
|
+
|
|
1179
|
+
|
|
1180
|
+
def _statement_read_only(stmt: list[_Piece], argv0: int) -> bool:
|
|
1181
|
+
if argv0 == -1:
|
|
1182
|
+
return True # empty, or only an input redirect
|
|
1183
|
+
head = stmt[argv0]
|
|
1184
|
+
if not head.quoted and _ENV_ASSIGNMENT.match(head.text):
|
|
1185
|
+
return False
|
|
1186
|
+
if "/" in head.text and not _SYSTEM_BIN.match(head.text):
|
|
1187
|
+
return False
|
|
1188
|
+
exec_ = head.text[head.text.rfind("/") + 1:]
|
|
1189
|
+
if exec_ not in _READ_ONLY_EXECS:
|
|
1190
|
+
return False
|
|
1191
|
+
args = [p for p in stmt[argv0 + 1:] if p.op is None]
|
|
1192
|
+
if exec_ == "find":
|
|
1193
|
+
return not any(a.text in _FIND_WRITE_ACTIONS for a in args)
|
|
1194
|
+
if exec_ == "rg": # --pre runs a program on every file searched
|
|
1195
|
+
return not any(a.text.startswith("--pre") for a in args)
|
|
1196
|
+
if exec_ == "file": # -C compiles a magic file, writing it
|
|
1197
|
+
return not any(a.text == "--compile" or _FILE_COMPILE_FLAG.match(a.text) for a in args)
|
|
1198
|
+
if exec_ in ("cd", "pushd", "popd"):
|
|
1199
|
+
# Claude Code's Bash tool keeps its cwd between calls, so a cd INTO the
|
|
1200
|
+
# dir would make the next call's bare ``config.json`` a write there
|
|
1201
|
+
# with no reference left to catch. cd elsewhere stays read-only.
|
|
1202
|
+
return not any(a.substs or _names_rafter_dir(a.text, not a.quoted) for a in args)
|
|
1203
|
+
return True
|
|
1204
|
+
|
|
1205
|
+
|
|
985
1206
|
def shell_program_from_stream(command: str) -> bool:
|
|
986
1207
|
"""True if a SHELL receives its program through a channel rather than as a path.
|
|
987
1208
|
|
|
@@ -1013,10 +1234,13 @@ def shell_program_from_stream(command: str) -> bool:
|
|
|
1013
1234
|
cur: list[_Piece] = []
|
|
1014
1235
|
for piece in pieces:
|
|
1015
1236
|
if piece.op is not None and piece.op in _CHAIN_OPS:
|
|
1016
|
-
segments.append(cur)
|
|
1237
|
+
segments.append(cur)
|
|
1238
|
+
enders.append(piece.op)
|
|
1239
|
+
cur = []
|
|
1017
1240
|
continue
|
|
1018
1241
|
cur.append(piece)
|
|
1019
|
-
segments.append(cur)
|
|
1242
|
+
segments.append(cur)
|
|
1243
|
+
enders.append(None)
|
|
1020
1244
|
|
|
1021
1245
|
def _resolve(seg: list[_Piece]) -> str:
|
|
1022
1246
|
"""Exec of a segment, skipping wrappers, their flags and env assignments.
|
|
@@ -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.11.
|
|
4
|
+
version: 0.11.4
|
|
5
5
|
homepage: https://rafter.so
|
|
6
6
|
metadata:
|
|
7
7
|
openclaw:
|
|
@@ -69,7 +69,12 @@ def handle_403(resp: "requests.Response") -> int:
|
|
|
69
69
|
if isinstance(body, dict) and "scan_mode" in body:
|
|
70
70
|
mode = body["scan_mode"]
|
|
71
71
|
limit = body.get("limit", "?")
|
|
72
|
-
used
|
|
72
|
+
# The API sends ``remaining``, not ``used``; ``used`` is kept for older servers.
|
|
73
|
+
used = body.get("used")
|
|
74
|
+
if used is None:
|
|
75
|
+
remaining = body.get("remaining")
|
|
76
|
+
numeric = all(isinstance(v, int) and not isinstance(v, bool) for v in (limit, remaining))
|
|
77
|
+
used = limit - remaining if numeric else limit
|
|
73
78
|
print(
|
|
74
79
|
f"Error: {mode.capitalize()} scan limit reached ({used}/{limit} used this billing period).\n"
|
|
75
80
|
f"Upgrade your plan or wait for your quota to reset.",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/continue-rules/rafter-code-review.md
RENAMED
|
File without changes
|
{rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/continue-rules/rafter-secure-design.md
RENAMED
|
File without changes
|
{rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/continue-rules/rafter-skill-review.md
RENAMED
|
File without changes
|
|
File without changes
|
{rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/cursor-rules/rafter-code-review.mdc
RENAMED
|
File without changes
|
{rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/cursor-rules/rafter-secure-design.mdc
RENAMED
|
File without changes
|
{rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/cursor-rules/rafter-skill-review.mdc
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter/docs/cli-reference.md
RENAMED
|
File without changes
|
{rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter/docs/finding-triage.md
RENAMED
|
File without changes
|
{rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter/docs/guardrails.md
RENAMED
|
File without changes
|
{rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter/docs/shift-left.md
RENAMED
|
File without changes
|
{rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-code-review/SKILL.md
RENAMED
|
File without changes
|
{rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-code-review/docs/api.md
RENAMED
|
File without changes
|
{rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-code-review/docs/asvs.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-code-review/docs/llm.md
RENAMED
|
File without changes
|
|
File without changes
|
{rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-secure-design/SKILL.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-skill-review/SKILL.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/windsurf-rules/rafter-code-review.md
RENAMED
|
File without changes
|
{rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/windsurf-rules/rafter-secure-design.md
RENAMED
|
File without changes
|
{rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/windsurf-rules/rafter-skill-review.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|