rafter-cli 0.8.2__tar.gz → 0.8.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 (95) hide show
  1. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/PKG-INFO +1 -1
  2. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/pyproject.toml +1 -1
  3. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/commands/agent.py +277 -11
  4. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/commands/agent_components.py +57 -0
  5. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/commands/scan.py +7 -0
  6. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/core/policy_loader.py +45 -4
  7. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/rafter-security-skill.md +1 -1
  8. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/skills/rafter/SKILL.md +2 -2
  9. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/scanners/secret_patterns.py +7 -0
  10. rafter_cli-0.8.4/rafter_cli/utils/skill_manager.py +45 -0
  11. rafter_cli-0.8.2/rafter_cli/utils/skill_manager.py +0 -22
  12. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/README.md +0 -0
  13. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/__init__.py +0 -0
  14. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/__main__.py +0 -0
  15. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/commands/__init__.py +0 -0
  16. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/commands/backend.py +0 -0
  17. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/commands/brief.py +0 -0
  18. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/commands/ci.py +0 -0
  19. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/commands/docs.py +0 -0
  20. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/commands/hook.py +0 -0
  21. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/commands/issues/__init__.py +0 -0
  22. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/commands/issues/dedup.py +0 -0
  23. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/commands/issues/github_client.py +0 -0
  24. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/commands/issues/issue_builder.py +0 -0
  25. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/commands/issues/issues_app.py +0 -0
  26. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/commands/mcp_server.py +0 -0
  27. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/commands/notify.py +0 -0
  28. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/commands/policy.py +0 -0
  29. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/commands/report.py +0 -0
  30. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/commands/skill.py +0 -0
  31. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/commands/skill_remote.py +0 -0
  32. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/core/__init__.py +0 -0
  33. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/core/audit_logger.py +0 -0
  34. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/core/command_interceptor.py +0 -0
  35. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/core/config_manager.py +0 -0
  36. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/core/config_schema.py +0 -0
  37. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/core/custom_patterns.py +0 -0
  38. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/core/docs_loader.py +0 -0
  39. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/core/pattern_engine.py +0 -0
  40. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/core/risk_rules.py +0 -0
  41. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/__init__.py +0 -0
  42. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/agents/__init__.py +0 -0
  43. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/agents/rafter.md +0 -0
  44. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/continue-rules/rafter-code-review.md +0 -0
  45. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/continue-rules/rafter-secure-design.md +0 -0
  46. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/continue-rules/rafter-skill-review.md +0 -0
  47. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/continue-rules/rafter.md +0 -0
  48. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/cursor-rules/rafter-code-review.mdc +0 -0
  49. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/cursor-rules/rafter-secure-design.mdc +0 -0
  50. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/cursor-rules/rafter-skill-review.mdc +0 -0
  51. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/cursor-rules/rafter.mdc +0 -0
  52. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/pre-commit-hook.sh +0 -0
  53. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/pre-push-hook.sh +0 -0
  54. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/skills/__init__.py +0 -0
  55. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/skills/rafter/__init__.py +0 -0
  56. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/skills/rafter/docs/backend.md +0 -0
  57. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/skills/rafter/docs/cli-reference.md +0 -0
  58. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/skills/rafter/docs/finding-triage.md +0 -0
  59. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/skills/rafter/docs/guardrails.md +0 -0
  60. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/skills/rafter/docs/shift-left.md +0 -0
  61. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/skills/rafter-code-review/SKILL.md +0 -0
  62. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/skills/rafter-code-review/docs/api.md +0 -0
  63. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/skills/rafter-code-review/docs/asvs.md +0 -0
  64. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/skills/rafter-code-review/docs/cwe-top25.md +0 -0
  65. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/skills/rafter-code-review/docs/investigation-playbook.md +0 -0
  66. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/skills/rafter-code-review/docs/llm.md +0 -0
  67. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/skills/rafter-code-review/docs/web-app.md +0 -0
  68. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/skills/rafter-secure-design/SKILL.md +0 -0
  69. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/skills/rafter-secure-design/docs/api-design.md +0 -0
  70. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/skills/rafter-secure-design/docs/auth.md +0 -0
  71. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/skills/rafter-secure-design/docs/data-storage.md +0 -0
  72. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/skills/rafter-secure-design/docs/dependencies.md +0 -0
  73. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/skills/rafter-secure-design/docs/deployment.md +0 -0
  74. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/skills/rafter-secure-design/docs/ingestion.md +0 -0
  75. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/skills/rafter-secure-design/docs/standards-pointers.md +0 -0
  76. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/skills/rafter-secure-design/docs/threat-modeling.md +0 -0
  77. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/skills/rafter-skill-review/SKILL.md +0 -0
  78. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/skills/rafter-skill-review/docs/authorship-provenance.md +0 -0
  79. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/skills/rafter-skill-review/docs/changelog-review.md +0 -0
  80. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/skills/rafter-skill-review/docs/data-practices.md +0 -0
  81. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/skills/rafter-skill-review/docs/malware-indicators.md +0 -0
  82. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/skills/rafter-skill-review/docs/prompt-injection.md +0 -0
  83. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/skills/rafter-skill-review/docs/telemetry.md +0 -0
  84. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/windsurf-rules/rafter-code-review.md +0 -0
  85. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/windsurf-rules/rafter-secure-design.md +0 -0
  86. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/windsurf-rules/rafter-skill-review.md +0 -0
  87. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/resources/windsurf-rules/rafter.md +0 -0
  88. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/scanners/__init__.py +0 -0
  89. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/scanners/betterleaks.py +0 -0
  90. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/scanners/regex_scanner.py +0 -0
  91. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/utils/__init__.py +0 -0
  92. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/utils/api.py +0 -0
  93. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/utils/binary_manager.py +0 -0
  94. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/utils/formatter.py +0 -0
  95. {rafter_cli-0.8.2 → rafter_cli-0.8.4}/rafter_cli/utils/git.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rafter-cli
3
- Version: 0.8.2
3
+ Version: 0.8.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.8.2"
3
+ version = "0.8.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"
@@ -248,6 +248,7 @@ def _print_dry_run_plan(
248
248
  want_windsurf: bool,
249
249
  want_continue: bool,
250
250
  want_aider: bool,
251
+ want_hermes: bool,
251
252
  want_betterleaks: bool,
252
253
  risk_level: str,
253
254
  ) -> None:
@@ -355,6 +356,11 @@ def _print_dry_run_plan(
355
356
  W(root / "RAFTER.md", "rafter:start/end marker block")
356
357
  W(root / ".aider.conf.yml", "appends RAFTER.md to read: list; strips legacy mcp-server-command line")
357
358
 
359
+ if want_hermes:
360
+ print()
361
+ print("Hermes (--with-hermes):")
362
+ W(root / ".hermes" / "config.yaml", "mcp_servers.rafter entry merged into existing YAML")
363
+
358
364
  if want_openclaw:
359
365
  print()
360
366
  print("OpenClaw (--with-openclaw):")
@@ -987,6 +993,44 @@ def _install_aider_read(root: Path) -> bool:
987
993
  return True
988
994
 
989
995
 
996
+ def _install_hermes_mcp(root: Path) -> bool:
997
+ """Install MCP server config for Hermes (<root>/.hermes/config.yaml).
998
+
999
+ Hermes uses a YAML config with an ``mcp_servers:`` block (snake_case,
1000
+ unlike Cursor/Windsurf/Claude Code which use ``mcpServers`` camelCase).
1001
+ Schema per server is ``{command, args, env}``. We use PyYAML (already
1002
+ imported) to merge in the rafter entry while preserving any existing
1003
+ servers.
1004
+
1005
+ Hooks (preToolUse/postToolUse equivalents) deferred to a follow-on bead
1006
+ pending confirmation Hermes exposes a hook surface — landing MCP-only as
1007
+ v0 mirrors how Gemini and Continue.dev were initially shipped (sable-gyw).
1008
+ """
1009
+ hermes_dir = root / ".hermes"
1010
+ config_path = hermes_dir / "config.yaml"
1011
+
1012
+ hermes_dir.mkdir(parents=True, exist_ok=True)
1013
+
1014
+ config: dict[str, Any] = {}
1015
+ if config_path.exists():
1016
+ try:
1017
+ loaded = yaml.safe_load(config_path.read_text())
1018
+ if isinstance(loaded, dict):
1019
+ config = loaded
1020
+ except yaml.YAMLError:
1021
+ rprint(fmt.warning("Existing Hermes config.yaml was not valid YAML, creating new one"))
1022
+
1023
+ mcp_servers = config.get("mcp_servers")
1024
+ if not isinstance(mcp_servers, dict):
1025
+ mcp_servers = {}
1026
+ config["mcp_servers"] = mcp_servers
1027
+ mcp_servers["rafter"] = {**_RAFTER_MCP_ENTRY}
1028
+
1029
+ config_path.write_text(yaml.safe_dump(config, sort_keys=False))
1030
+ rprint(fmt.success(f"Installed Rafter MCP server to {config_path}"))
1031
+ return True
1032
+
1033
+
990
1034
  @agent_app.command()
991
1035
  def init(
992
1036
  risk_level: str = typer.Option("moderate", "--risk-level", help="minimal, moderate, or aggressive"),
@@ -999,6 +1043,7 @@ def init(
999
1043
  with_cursor: bool = typer.Option(False, "--with-cursor", help="Install Cursor integration"),
1000
1044
  with_windsurf: bool = typer.Option(False, "--with-windsurf", help="Install Windsurf integration"),
1001
1045
  with_continue: bool = typer.Option(False, "--with-continue", help="Install Continue.dev integration"),
1046
+ with_hermes: bool = typer.Option(False, "--with-hermes", help="Install Hermes integration"),
1002
1047
  all_integrations: bool = typer.Option(False, "--all", help="Install all detected integrations and download Betterleaks"),
1003
1048
  update: bool = typer.Option(False, "--update", help="Re-download betterleaks and reinstall integrations without resetting config"),
1004
1049
  local: bool = typer.Option(
@@ -1037,6 +1082,7 @@ def init(
1037
1082
  has_windsurf = scope == "user" and (home / ".codeium" / "windsurf").exists()
1038
1083
  has_continue_dev = scope == "user" and (home / ".continue").exists()
1039
1084
  has_aider = scope == "user" and (home / ".aider.conf.yml").exists()
1085
+ has_hermes = scope == "user" and (home / ".hermes").exists()
1040
1086
 
1041
1087
  # Resolve opt-in flags. In --local scope, --all is restricted to platforms with
1042
1088
  # a project-local config story (claudeCode, codex, gemini, cursor).
@@ -1057,6 +1103,10 @@ def init(
1057
1103
  # Aider can install at --local scope (writes RAFTER.md + .aider.conf.yml
1058
1104
  # in cwd) since rf-du2o.
1059
1105
  want_aider = with_aider or all_integrations
1106
+ # Hermes: MCP-only v0 (no hooks confirmed yet). User scope only — Hermes
1107
+ # reads ~/.hermes/config.yaml; project-local install story isn't
1108
+ # established. Excluded from --all in --local for the same reason (sable-gyw).
1109
+ want_hermes = with_hermes or (all_integrations and not local)
1060
1110
  want_betterleaks = with_betterleaks or (all_integrations and not local)
1061
1111
 
1062
1112
  # Show detected environments
@@ -1077,6 +1127,8 @@ def init(
1077
1127
  detected.append("Continue.dev")
1078
1128
  if has_aider:
1079
1129
  detected.append("Aider")
1130
+ if has_hermes:
1131
+ detected.append("Hermes")
1080
1132
 
1081
1133
  if detected:
1082
1134
  rprint(fmt.info(f"Detected environments: {', '.join(detected)}"))
@@ -1102,6 +1154,8 @@ def init(
1102
1154
  rprint(fmt.warning("Continue.dev requested but not detected (~/.continue not found)"))
1103
1155
  if want_aider and not has_aider:
1104
1156
  rprint(fmt.warning("Aider requested but not detected (~/.aider.conf.yml not found)"))
1157
+ if want_hermes and not has_hermes:
1158
+ rprint(fmt.warning("Hermes requested but not detected (~/.hermes not found)"))
1105
1159
 
1106
1160
  # --dry-run: print every file path the command would touch, then exit
1107
1161
  # before any filesystem write happens (rf-hrtd). Built from the same
@@ -1118,6 +1172,7 @@ def init(
1118
1172
  want_windsurf=want_windsurf and (has_windsurf or local),
1119
1173
  want_continue=want_continue and (has_continue_dev or local),
1120
1174
  want_aider=want_aider and (has_aider or local),
1175
+ want_hermes=want_hermes and has_hermes,
1121
1176
  want_betterleaks=want_betterleaks,
1122
1177
  risk_level=risk_level,
1123
1178
  )
@@ -1312,6 +1367,18 @@ def init(
1312
1367
  except Exception as e:
1313
1368
  rprint(fmt.error(f"Failed to install Aider integration: {e}"))
1314
1369
 
1370
+ # Install Hermes integration if opted in (sable-gyw).
1371
+ # User scope only — Hermes reads ~/.hermes/config.yaml. MCP-only v0;
1372
+ # hooks deferred pending confirmation Hermes exposes a hook surface.
1373
+ hermes_ok = False
1374
+ if want_hermes and has_hermes:
1375
+ try:
1376
+ hermes_ok = _install_hermes_mcp(root)
1377
+ if hermes_ok:
1378
+ manager.set("agent.environments.hermes.enabled", True)
1379
+ except Exception as e:
1380
+ rprint(fmt.error(f"Failed to install Hermes integration: {e}"))
1381
+
1315
1382
  # Install global instruction files for platforms that support them
1316
1383
  _install_global_instructions(
1317
1384
  claude_code=claude_code_ok,
@@ -1327,7 +1394,7 @@ def init(
1327
1394
  rprint(fmt.success("Agent security initialized!"))
1328
1395
  rprint()
1329
1396
 
1330
- any_integration = openclaw_ok or claude_code_ok or codex_ok or gemini_ok or cursor_ok or windsurf_ok or continue_ok or aider_ok
1397
+ any_integration = openclaw_ok or claude_code_ok or codex_ok or gemini_ok or cursor_ok or windsurf_ok or continue_ok or aider_ok or hermes_ok
1331
1398
 
1332
1399
  if any_integration:
1333
1400
  rprint("Next steps:")
@@ -1372,6 +1439,8 @@ def init(
1372
1439
  rprint(" rafter agent init --with-continue # Continue.dev only")
1373
1440
  if has_aider:
1374
1441
  rprint(" rafter agent init --with-aider # Aider only")
1442
+ if has_hermes:
1443
+ rprint(" rafter agent init --with-hermes # Hermes only")
1375
1444
  else:
1376
1445
  rprint("No agent environments detected. Install an agent tool and re-run with --with-<tool>.")
1377
1446
 
@@ -1424,6 +1493,68 @@ def _scan_file(file_path: str, engine: str, custom_patterns=None) -> list[ScanRe
1424
1493
  return [r] if r.matches else []
1425
1494
 
1426
1495
 
1496
+ def _path_matches_exclude_pattern(rel_path: str, pattern: str) -> bool:
1497
+ """Mirror of Node ``pathMatchesExcludePattern`` (sable-yz0).
1498
+
1499
+ Rules (any one matches → exclude):
1500
+ 1. Exact match: ``rel_path == pattern``.
1501
+ 2. Directory-prefix: ``rel_path`` starts with ``pattern + "/"``.
1502
+ Trailing ``/`` on the pattern is ignored.
1503
+ 3. Dir-name anywhere: any segment of ``rel_path`` equals ``pattern``.
1504
+ Preserves the RegexScanner walker's existing dir-name behavior.
1505
+ 4. Glob: if pattern has ``* ? [``, use fnmatch with auto-anchor
1506
+ (try ``pattern`` and ``**/pattern``) — mirrors Node ``matchGlob``.
1507
+ """
1508
+ import fnmatch as _fnmatch
1509
+ rel = rel_path.replace("\\", "/")
1510
+ p = pattern.replace("\\", "/").rstrip("/")
1511
+ if not p:
1512
+ return False
1513
+ if rel == p:
1514
+ return True
1515
+ if rel.startswith(p + "/"):
1516
+ return True
1517
+ if p in rel.split("/"):
1518
+ return True
1519
+ if any(c in p for c in "*?["):
1520
+ if _fnmatch.fnmatch(rel, p):
1521
+ return True
1522
+ if not p.startswith("/") and not p.startswith("**"):
1523
+ if _fnmatch.fnmatch(rel, "**/" + p) or _fnmatch.fnmatch(rel, "*/" + p):
1524
+ return True
1525
+ return False
1526
+
1527
+
1528
+ def _apply_exclude_paths(
1529
+ results: list[ScanResult],
1530
+ exclude_paths: list[str] | None,
1531
+ scan_root: str,
1532
+ ) -> list[ScanResult]:
1533
+ """Strip findings whose path matches any ``scan.exclude_paths`` entry.
1534
+
1535
+ Chokepoint that fixes sable-yz0 across both scan engines AND the staged
1536
+ / diff modes. See the Node ``applyExcludePaths`` docstring for the full
1537
+ rationale — both engines previously bypassed the policy on the
1538
+ happy-path, and the existing walker-level filter only matched
1539
+ directory NAMES, missing every multi-segment path users wrote.
1540
+ """
1541
+ if not exclude_paths:
1542
+ return results
1543
+ root = os.path.abspath(scan_root).replace("\\", "/")
1544
+ kept: list[ScanResult] = []
1545
+ for r in results:
1546
+ abs_p = os.path.abspath(r.file).replace("\\", "/")
1547
+ if abs_p == root:
1548
+ rel = ""
1549
+ elif abs_p.startswith(root + "/"):
1550
+ rel = abs_p[len(root) + 1 :]
1551
+ else:
1552
+ rel = abs_p
1553
+ if not any(_path_matches_exclude_pattern(rel, pat) for pat in exclude_paths):
1554
+ kept.append(r)
1555
+ return kept
1556
+
1557
+
1427
1558
  def _scan_directory(
1428
1559
  dir_path: str,
1429
1560
  engine: str,
@@ -1442,13 +1573,15 @@ def _scan_directory(
1442
1573
  try:
1443
1574
  bl = BetterleaksScanner()
1444
1575
  results = bl.scan_directory(dir_path, use_git=history)
1445
- return [ScanResult(file=r.file, matches=r.matches) for r in results]
1576
+ results = [ScanResult(file=r.file, matches=r.matches) for r in results]
1446
1577
  except Exception:
1447
1578
  scanner = RegexScanner(custom)
1448
- return scanner.scan_directory(dir_path, exclude_paths=exclude, respect_gitignore=respect_gitignore)
1579
+ results = scanner.scan_directory(dir_path, exclude_paths=exclude, respect_gitignore=respect_gitignore)
1449
1580
  else:
1450
1581
  scanner = RegexScanner(custom)
1451
- return scanner.scan_directory(dir_path, exclude_paths=exclude, respect_gitignore=respect_gitignore)
1582
+ results = scanner.scan_directory(dir_path, exclude_paths=exclude, respect_gitignore=respect_gitignore)
1583
+ # sable-yz0 — post-filter chokepoint. See _apply_exclude_paths docstring.
1584
+ return _apply_exclude_paths(results, exclude, dir_path)
1452
1585
 
1453
1586
 
1454
1587
  def _output_scan_results(
@@ -2449,6 +2582,34 @@ def _check_aider() -> _CheckResult:
2449
2582
  return _CheckResult(name, True, f"RAFTER.md + read: entry in {conf}")
2450
2583
 
2451
2584
 
2585
+ def _check_hermes() -> _CheckResult:
2586
+ """Check if Hermes integration is healthy (sable-gyw).
2587
+
2588
+ Hermes uses ~/.hermes/config.yaml with a snake_case ``mcp_servers:`` block
2589
+ (MCP-only v0 — no hook surface confirmed yet).
2590
+ """
2591
+ name = "Hermes"
2592
+ home = Path.home()
2593
+ hermes_dir = home / ".hermes"
2594
+
2595
+ if not hermes_dir.exists():
2596
+ return _CheckResult(name, False, "Not detected — run 'rafter agent init --with-hermes' to enable", optional=True)
2597
+
2598
+ config_path = hermes_dir / "config.yaml"
2599
+ if not config_path.exists():
2600
+ return _CheckResult(name, False, f"Config not found: {config_path} — run 'rafter agent init --with-hermes'", optional=True)
2601
+
2602
+ try:
2603
+ loaded = yaml.safe_load(config_path.read_text()) or {}
2604
+ except (OSError, yaml.YAMLError) as e:
2605
+ return _CheckResult(name, False, f"Cannot read config: {e}", optional=True)
2606
+
2607
+ servers = loaded.get("mcp_servers") if isinstance(loaded, dict) else None
2608
+ if not (isinstance(servers, dict) and servers.get("rafter")):
2609
+ return _CheckResult(name, False, "Rafter MCP server not configured — run 'rafter agent init --with-hermes'", optional=True)
2610
+ return _CheckResult(name, True, "MCP server configured")
2611
+
2612
+
2452
2613
  def _probe_claude_code() -> _CheckResult:
2453
2614
  """Runtime probe of the Claude Code hook integration (rf-65zg).
2454
2615
 
@@ -2549,6 +2710,7 @@ def verify(
2549
2710
  _check_windsurf(),
2550
2711
  _check_continue_dev(),
2551
2712
  _check_aider(),
2713
+ _check_hermes(),
2552
2714
  ]
2553
2715
 
2554
2716
  if probe:
@@ -2882,18 +3044,24 @@ def update_betterleaks(
2882
3044
  # ── agent status ─────────────────────────────────────────────────────────
2883
3045
 
2884
3046
  @agent_app.command("status")
2885
- def status():
3047
+ def status(
3048
+ json_output: bool = typer.Option(False, "--json", help="Output status as JSON"),
3049
+ ):
2886
3050
  """Show agent security status dashboard."""
2887
3051
  from ..core.config_schema import get_audit_log_path, get_rafter_dir
2888
3052
 
2889
3053
  rafter_dir = get_rafter_dir()
2890
3054
  audit_path = get_audit_log_path()
3055
+ config_path = rafter_dir / "config.json"
3056
+
3057
+ if json_output:
3058
+ print(json.dumps(_agent_status_json(config_path, audit_path), indent=2))
3059
+ return
2891
3060
 
2892
3061
  print("Rafter Agent Status")
2893
3062
  print("=" * 50)
2894
3063
 
2895
3064
  # --- Config ---
2896
- config_path = rafter_dir / "config.json"
2897
3065
  if config_path.exists():
2898
3066
  try:
2899
3067
  cfg = ConfigManager().load()
@@ -2946,11 +3114,18 @@ def status():
2946
3114
  print(f"PostToolUse: {posttool_status}")
2947
3115
 
2948
3116
  # --- OpenClaw skill ---
2949
- skill_path = Path.home() / ".openclaw" / "skills" / "rafter-security.md"
2950
- if skill_path.exists():
2951
- print(f"OpenClaw: skill installed ({skill_path})")
2952
- elif (Path.home() / ".openclaw").exists():
2953
- print("OpenClaw: detected but skill missing — run: rafter agent init --with-openclaw")
3117
+ # rf-zgwj moved the skill to the canonical ClawHub workspace path
3118
+ # (~/.openclaw/workspace/skills/rafter-security/SKILL.md) and strips the
3119
+ # legacy flat file. Detect via SkillManager so this matches `agent verify`
3120
+ # and the installer — checking the legacy path here is a false negative.
3121
+ skill_manager = SkillManager()
3122
+ if skill_manager.is_rafter_skill_installed():
3123
+ print(f"OpenClaw: skill installed ({skill_manager.get_rafter_skill_path()})")
3124
+ elif skill_manager.is_openclaw_installed():
3125
+ if skill_manager.has_legacy_rafter_skill():
3126
+ print(f"OpenClaw: legacy skill at {skill_manager.get_legacy_rafter_skill_path()} (not loaded) — run: rafter agent init --with-openclaw to migrate")
3127
+ else:
3128
+ print("OpenClaw: detected but skill missing — run: rafter agent init --with-openclaw")
2954
3129
  else:
2955
3130
  print("OpenClaw: not detected (optional)")
2956
3131
 
@@ -2971,6 +3146,7 @@ def status():
2971
3146
  {"name": "Cursor", "flag": "--with-cursor", "config_dir": home / ".cursor", "config_file": home / ".cursor" / "mcp.json", "needle": "rafter"},
2972
3147
  {"name": "Windsurf", "flag": "--with-windsurf", "config_dir": home / ".codeium" / "windsurf", "config_file": home / ".codeium" / "windsurf" / "mcp_config.json", "needle": "rafter"},
2973
3148
  {"name": "Continue.dev", "flag": "--with-continue", "config_dir": home / ".continue", "config_file": home / ".continue" / "config.json", "needle": "rafter"},
3149
+ {"name": "Hermes", "flag": "--with-hermes", "config_dir": home / ".hermes", "config_file": home / ".hermes" / "config.yaml", "needle": "rafter"},
2974
3150
  ]
2975
3151
 
2976
3152
  for agent in mcp_agents:
@@ -3030,6 +3206,96 @@ def status():
3030
3206
  print()
3031
3207
 
3032
3208
 
3209
+ def _agent_status_json(config_path: Path, audit_path: Path) -> dict[str, Any]:
3210
+ return {
3211
+ "installed": config_path.exists(),
3212
+ "version": __version__,
3213
+ "agents_detected": _detect_agent_platforms(),
3214
+ "hooks_installed": _detect_git_hooks(),
3215
+ "betterleaks_available": _betterleaks_available(),
3216
+ "config_path": _format_home_path(config_path),
3217
+ "audit_log_path": _format_home_path(audit_path),
3218
+ }
3219
+
3220
+
3221
+ def _detect_agent_platforms() -> list[str]:
3222
+ home = Path.home()
3223
+ candidates = [
3224
+ ("claude-code", home / ".claude"),
3225
+ ("openclaw", home / ".openclaw"),
3226
+ ("codex", home / ".codex"),
3227
+ ("gemini", home / ".gemini"),
3228
+ ("cursor", home / ".cursor"),
3229
+ ("windsurf", home / ".codeium" / "windsurf"),
3230
+ ("continue", home / ".continue"),
3231
+ ("aider", home / ".aider.conf.yml"),
3232
+ ("hermes", home / ".hermes"),
3233
+ ]
3234
+ return [name for name, path in candidates if path.exists()]
3235
+
3236
+
3237
+ def _detect_git_hooks() -> list[str]:
3238
+ hooks: set[str] = set()
3239
+ specs = [
3240
+ ("pre-commit", "Rafter Security Pre-Commit Hook"),
3241
+ ("pre-push", "Rafter Security Pre-Push Hook"),
3242
+ ]
3243
+ home = Path.home()
3244
+
3245
+ for hook_name, marker in specs:
3246
+ if _file_contains(home / ".rafter" / "git-hooks" / hook_name, marker):
3247
+ hooks.add(hook_name)
3248
+
3249
+ try:
3250
+ git_dir = subprocess.run(
3251
+ ["git", "rev-parse", "--git-dir"],
3252
+ capture_output=True,
3253
+ text=True,
3254
+ timeout=5,
3255
+ check=True,
3256
+ ).stdout.strip()
3257
+ hooks_dir = Path(git_dir).resolve() / "hooks"
3258
+ for hook_name, marker in specs:
3259
+ if _file_contains(hooks_dir / hook_name, marker):
3260
+ hooks.add(hook_name)
3261
+ except Exception:
3262
+ pass
3263
+
3264
+ return sorted(hooks)
3265
+
3266
+
3267
+ def _betterleaks_available() -> bool:
3268
+ bm = BinaryManager()
3269
+ if shutil.which("betterleaks"):
3270
+ try:
3271
+ result = subprocess.run(
3272
+ ["betterleaks", "version"],
3273
+ capture_output=True,
3274
+ text=True,
3275
+ timeout=5,
3276
+ )
3277
+ if result.returncode == 0:
3278
+ return True
3279
+ except Exception:
3280
+ pass
3281
+ return bm.get_betterleaks_path().exists() or bool(bm.find_legacy_gitleaks())
3282
+
3283
+
3284
+ def _file_contains(path: Path, needle: str) -> bool:
3285
+ try:
3286
+ return needle in path.read_text(encoding="utf-8")
3287
+ except Exception:
3288
+ return False
3289
+
3290
+
3291
+ def _format_home_path(path: Path) -> str:
3292
+ home = Path.home()
3293
+ try:
3294
+ return f"~/{path.relative_to(home).as_posix()}"
3295
+ except ValueError:
3296
+ return str(path)
3297
+
3298
+
3033
3299
  # ── baseline ─────────────────────────────────────────────────────────
3034
3300
 
3035
3301
  _BASELINE_PATH = Path.home() / ".rafter" / "baseline.json"
@@ -839,6 +839,62 @@ def _continue_mcp() -> ComponentSpec:
839
839
  )
840
840
 
841
841
 
842
+ def _hermes_mcp() -> ComponentSpec:
843
+ """Hermes MCP server entry (~/.hermes/config.yaml).
844
+
845
+ Hermes uses a YAML config with a snake_case ``mcp_servers:`` block (unlike
846
+ the camelCase ``mcpServers`` of Cursor/Windsurf/Claude Code). MCP-only v0 —
847
+ hooks deferred pending confirmation Hermes exposes a hook surface (sable-gyw).
848
+ """
849
+ home = Path.home()
850
+ detect_dir = home / ".hermes"
851
+ config_path = detect_dir / "config.yaml"
852
+
853
+ def read_yaml() -> dict[str, Any]:
854
+ if not config_path.exists():
855
+ return {}
856
+ try:
857
+ loaded = yaml.safe_load(config_path.read_text())
858
+ except (OSError, yaml.YAMLError):
859
+ return {}
860
+ return loaded if isinstance(loaded, dict) else {}
861
+
862
+ def is_installed() -> bool:
863
+ servers = read_yaml().get("mcp_servers")
864
+ return isinstance(servers, dict) and bool(servers.get("rafter"))
865
+
866
+ def install() -> None:
867
+ detect_dir.mkdir(parents=True, exist_ok=True)
868
+ cfg = read_yaml()
869
+ servers = cfg.get("mcp_servers")
870
+ if not isinstance(servers, dict):
871
+ servers = {}
872
+ cfg["mcp_servers"] = servers
873
+ servers["rafter"] = dict(RAFTER_MCP_ENTRY)
874
+ config_path.write_text(yaml.safe_dump(cfg))
875
+
876
+ def uninstall() -> None:
877
+ if not config_path.exists():
878
+ return
879
+ cfg = read_yaml()
880
+ servers = cfg.get("mcp_servers")
881
+ if isinstance(servers, dict) and "rafter" in servers:
882
+ del servers["rafter"]
883
+ config_path.write_text(yaml.safe_dump(cfg))
884
+
885
+ return ComponentSpec(
886
+ id="hermes.mcp",
887
+ platform="hermes",
888
+ kind="mcp",
889
+ description="Hermes MCP server entry (~/.hermes/config.yaml)",
890
+ detect_dir=detect_dir,
891
+ path=config_path,
892
+ is_installed=is_installed,
893
+ install=install,
894
+ uninstall=uninstall,
895
+ )
896
+
897
+
842
898
  _AIDER_LEGACY_MCP_BLOCK_RE = re.compile(
843
899
  r"\n?#\s*Rafter security MCP server\s*\nmcp-server-command:\s*rafter\s+mcp\s+serve\s*\n?",
844
900
  )
@@ -997,6 +1053,7 @@ def get_registry() -> list[ComponentSpec]:
997
1053
  _continue_rules(),
998
1054
  _continue_mcp(),
999
1055
  _aider_read(),
1056
+ _hermes_mcp(),
1000
1057
  _openclaw_skill(),
1001
1058
  ]
1002
1059
  return _REGISTRY
@@ -107,6 +107,7 @@ def scan_local(
107
107
  _output_sarif,
108
108
  _watch_and_scan,
109
109
  _apply_baseline,
110
+ _apply_exclude_paths,
110
111
  _load_baseline_entries,
111
112
  )
112
113
  from ..core.config_manager import ConfigManager
@@ -164,6 +165,9 @@ def scan_local(
164
165
  resolved = os.path.join(repo_root, f)
165
166
  if os.path.isfile(resolved):
166
167
  all_results.extend(_scan_file(resolved, eng, custom_patterns))
168
+ # sable-yz0 — honor scan.exclude_paths in --diff mode too.
169
+ exclude = scan_cfg.exclude_paths if scan_cfg else None
170
+ all_results = _apply_exclude_paths(all_results, exclude, repo_root)
167
171
  filtered = _apply_baseline(all_results, baseline_entries)
168
172
  _output_scan_results(filtered, json_output, quiet, f"files changed since {diff}", format=format, suppressions=suppressions)
169
173
  return
@@ -201,6 +205,9 @@ def scan_local(
201
205
  resolved = os.path.join(repo_root, f)
202
206
  if os.path.isfile(resolved):
203
207
  all_results.extend(_scan_file(resolved, eng, custom_patterns))
208
+ # sable-yz0 — honor scan.exclude_paths in --staged mode too.
209
+ exclude = scan_cfg.exclude_paths if scan_cfg else None
210
+ all_results = _apply_exclude_paths(all_results, exclude, repo_root)
204
211
  filtered = _apply_baseline(all_results, baseline_entries)
205
212
  _output_scan_results(filtered, json_output, quiet, "staged files", format=format, suppressions=suppressions)
206
213
  return
@@ -9,20 +9,41 @@ from pathlib import Path
9
9
  from ..utils.git import get_git_root
10
10
 
11
11
 
12
+ #: Policy file candidates, in precedence order (sable-c1c).
13
+ #:
14
+ #: The cloud scanner (rafter-backend) reads ``.rafter/config.yml`` (subdir
15
+ #: + ``config.yml``), while the CLI canonical is ``.rafter.yml``. The CLI
16
+ #: reads both indefinitely so customers writing either shape get the same
17
+ #: behavior locally. Canonical dotfile takes precedence; backend file is
18
+ #: the fallback. Schema compatibility (top-level ``exclude_paths`` /
19
+ #: ``custom_patterns`` vs nested ``scan.*``) is handled in ``_map_policy``.
20
+ POLICY_FILE_CANDIDATES: list[Path] = [
21
+ Path(".rafter.yml"),
22
+ Path(".rafter.yaml"),
23
+ Path(".rafter") / "config.yml",
24
+ Path(".rafter") / "config.yaml",
25
+ ]
26
+
27
+ # Back-compat alias for code outside this module that imported the previous
28
+ # constant. Resolves to the canonical-dotfile names only (subset).
12
29
  POLICY_FILENAMES = [".rafter.yml", ".rafter.yaml"]
13
30
  _KNOWN_DOC_KEYS = {"id", "path", "url", "description", "tags", "cache"}
14
31
 
15
32
 
16
33
  def find_policy_file() -> Path | None:
17
- """Walk from cwd up to git root looking for a policy file."""
34
+ """Walk from cwd up to git root looking for a policy file.
35
+
36
+ Returns the first candidate that exists, in the precedence order
37
+ declared by ``POLICY_FILE_CANDIDATES``.
38
+ """
18
39
  cwd = Path.cwd()
19
40
  root = get_git_root()
20
41
  stop = Path(root) if root else cwd.anchor and Path(cwd.anchor)
21
42
 
22
43
  current = cwd
23
44
  while True:
24
- for name in POLICY_FILENAMES:
25
- candidate = current / name
45
+ for candidate_rel in POLICY_FILE_CANDIDATES:
46
+ candidate = current / candidate_rel
26
47
  if candidate.exists():
27
48
  return candidate
28
49
  parent = current.parent
@@ -85,6 +106,22 @@ def _map_policy(raw: dict) -> dict:
85
106
  for p in scan["custom_patterns"]
86
107
  ]
87
108
 
109
+ # sable-c1c — backend flat-shape compat. rafter-backend reads
110
+ # exclude_paths / custom_patterns at the top level (no `scan:` nesting),
111
+ # so customers writing the backend shape get the same behavior locally.
112
+ # Nested form takes precedence if both are present in the same file.
113
+ if "scan" not in policy or not policy["scan"].get("exclude_paths"):
114
+ if isinstance(raw.get("exclude_paths"), list):
115
+ policy.setdefault("scan", {})
116
+ policy["scan"]["exclude_paths"] = raw["exclude_paths"]
117
+ if "scan" not in policy or not policy["scan"].get("custom_patterns"):
118
+ if isinstance(raw.get("custom_patterns"), list):
119
+ policy.setdefault("scan", {})
120
+ policy["scan"]["custom_patterns"] = [
121
+ {"name": p.get("name", ""), "regex": p.get("regex", ""), "severity": p.get("severity", "high")}
122
+ for p in raw["custom_patterns"]
123
+ ]
124
+
88
125
  ignore = raw.get("ignore")
89
126
  if isinstance(ignore, list):
90
127
  rules: list[dict] = []
@@ -179,7 +216,11 @@ def _derive_doc_id(source: str, kind: str) -> str:
179
216
  return hashlib.sha256(source.encode("utf-8")).hexdigest()[:8]
180
217
 
181
218
 
182
- _VALID_TOP_LEVEL_KEYS = {"version", "risk_level", "command_policy", "scan", "ignore", "audit", "docs"}
219
+ _VALID_TOP_LEVEL_KEYS = {
220
+ "version", "risk_level", "command_policy", "scan", "ignore", "audit", "docs",
221
+ # sable-c1c — backend flat-shape compat keys.
222
+ "exclude_paths", "custom_patterns",
223
+ }
183
224
  _VALID_RISK_LEVELS = {"minimal", "moderate", "aggressive"}
184
225
  _VALID_COMMAND_MODES = {"allow-all", "approve-dangerous", "deny-list"}
185
226
  _VALID_LOG_LEVELS = {"debug", "info", "warn", "error"}
@@ -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.8.2
4
+ version: 0.8.4
5
5
  homepage: https://rafter.so
6
6
  metadata:
7
7
  openclaw:
@@ -114,5 +114,5 @@ export RAFTER_API_KEY="..." # or put it in .env
114
114
 
115
115
  Without a key, only `rafter secrets` works — that's secret-hygiene, not code review. If security matters for the task, flag the missing key to the user rather than silently accepting the narrower scan.
116
116
 
117
- ## Strengthen the Project
118
- Not wired in yet? `rafter agent install-hook` (pre-commit), `rafter ci init` (CI workflow), `.rafter.yml` (policy). Per-platform setup: `rafter brief setup/<platform>`.
117
+ ## Setup
118
+ Not installed? `npm i -g @rafter-security/cli` (Node) or `pip install rafter-cli` (Python). `npx` form is `npx @rafter-security/cli` — the bare `npx rafter-cli` resolves to an unrelated package. Inside Cursor's sandbox or any session where writing `~/.rafter` triggers a prompt, prefer `rafter agent init --local --with-<platform>` — writes `./.rafter/` + `./.<platform>/` instead of `$HOME`. Not wired yet? `rafter agent install-hook` (pre-commit), `rafter ci init` (CI), `.rafter.yml` (policy). Per-platform: `rafter brief setup/<platform>`.
@@ -88,6 +88,13 @@ DEFAULT_SECRET_PATTERNS: list[Pattern] = [
88
88
  severity="critical",
89
89
  description="Twilio API Key detected",
90
90
  ),
91
+ # HashiCorp Vault
92
+ Pattern(
93
+ name="HashiCorp Vault Token",
94
+ regex=r"hvs\.[a-zA-Z0-9_-]{90,}",
95
+ severity="critical",
96
+ description="HashiCorp Vault service token detected",
97
+ ),
91
98
  # Generic
92
99
  Pattern(
93
100
  name="Generic API Key",
@@ -0,0 +1,45 @@
1
+ """Skill manager for OpenClaw integration — Python port of Node skill-manager.ts."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+
6
+
7
+ class SkillManager:
8
+ """Manage OpenClaw skill installation and detection.
9
+
10
+ OpenClaw auto-discovers ClawHub-shaped skills from
11
+ ``<workspace>/skills/<skill>/SKILL.md`` (default workspace
12
+ ``~/.openclaw/workspace/``). rafter ≤ 0.7.7 wrote a loose
13
+ ``~/.openclaw/skills/<name>.md`` file that OpenClaw never read; the canonical
14
+ path was adopted in rf-zgwj. Detection here must match the installer and
15
+ ``agent verify`` — checking the legacy path is a false negative.
16
+ """
17
+
18
+ def get_openclaw_root(self) -> Path:
19
+ return Path.home() / ".openclaw"
20
+
21
+ def get_openclaw_skills_dir(self) -> Path:
22
+ return self.get_openclaw_root() / "workspace" / "skills"
23
+
24
+ def get_rafter_skill_dir(self) -> Path:
25
+ return self.get_openclaw_skills_dir() / "rafter-security"
26
+
27
+ def get_rafter_skill_path(self) -> Path:
28
+ return self.get_rafter_skill_dir() / "SKILL.md"
29
+
30
+ def get_legacy_rafter_skill_path(self) -> Path:
31
+ """Legacy install path used by rafter ≤ 0.7.7. Removed on reinstall."""
32
+ return self.get_openclaw_root() / "skills" / "rafter-security.md"
33
+
34
+ def is_openclaw_installed(self) -> bool:
35
+ """Detect the platform root (~/.openclaw). A fresh OpenClaw install has
36
+ no workspace skills dir yet, so checking the skills dir gives a
37
+ false-negative until at least one skill is written."""
38
+ return self.get_openclaw_root().exists()
39
+
40
+ def has_legacy_rafter_skill(self) -> bool:
41
+ """True when the rafter ≤ 0.7.7 flat file is present (migration note)."""
42
+ return self.get_legacy_rafter_skill_path().exists()
43
+
44
+ def is_rafter_skill_installed(self) -> bool:
45
+ return self.get_rafter_skill_path().exists()
@@ -1,22 +0,0 @@
1
- """Skill manager for OpenClaw integration — Python port of Node skill-manager.ts."""
2
- from __future__ import annotations
3
-
4
- import importlib.resources
5
- import re
6
- from pathlib import Path
7
-
8
-
9
- class SkillManager:
10
- """Manage OpenClaw skill installation and detection."""
11
-
12
- def get_openclaw_skills_dir(self) -> Path:
13
- return Path.home() / ".openclaw" / "skills"
14
-
15
- def get_rafter_skill_path(self) -> Path:
16
- return self.get_openclaw_skills_dir() / "rafter-security.md"
17
-
18
- def is_openclaw_installed(self) -> bool:
19
- return self.get_openclaw_skills_dir().exists()
20
-
21
- def is_rafter_skill_installed(self) -> bool:
22
- return self.get_rafter_skill_path().exists()
File without changes