@aporthq/aport-agent-guardrails 1.0.8

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 (237) hide show
  1. package/LICENSE +217 -0
  2. package/README.md +481 -0
  3. package/bin/agent-guardrails +133 -0
  4. package/bin/aport-create-passport.sh +444 -0
  5. package/bin/aport-cursor-hook.sh +90 -0
  6. package/bin/aport-guardrail-api.sh +108 -0
  7. package/bin/aport-guardrail-bash.sh +394 -0
  8. package/bin/aport-guardrail-v2.sh +5 -0
  9. package/bin/aport-guardrail.sh +5 -0
  10. package/bin/aport-resolve-paths.sh +71 -0
  11. package/bin/aport-status.sh +276 -0
  12. package/bin/frameworks/crewai.sh +49 -0
  13. package/bin/frameworks/cursor.sh +95 -0
  14. package/bin/frameworks/langchain.sh +48 -0
  15. package/bin/frameworks/n8n.sh +36 -0
  16. package/bin/frameworks/openclaw.sh +19 -0
  17. package/bin/lib/allowlist.sh +18 -0
  18. package/bin/lib/common.sh +28 -0
  19. package/bin/lib/config.sh +46 -0
  20. package/bin/lib/constants.sh +232 -0
  21. package/bin/lib/detect.sh +65 -0
  22. package/bin/lib/error.sh +269 -0
  23. package/bin/lib/passport.sh +19 -0
  24. package/bin/lib/templates/.gitkeep +1 -0
  25. package/bin/lib/templates/config.yaml +6 -0
  26. package/bin/lib/validation.sh +206 -0
  27. package/bin/openclaw +660 -0
  28. package/docs/ADDING_A_FRAMEWORK.md +87 -0
  29. package/docs/AGENTS.md.example +40 -0
  30. package/docs/CODE_REVIEW.md +192 -0
  31. package/docs/DEPLOYMENT_READINESS.md +81 -0
  32. package/docs/FAQ_SECURITY_SCANNERS.md +373 -0
  33. package/docs/FRAMEWORK_ROADMAP.md +41 -0
  34. package/docs/HOSTED_PASSPORT_SETUP.md +362 -0
  35. package/docs/IMPLEMENTING_YOUR_OWN_EVALUATOR.md +433 -0
  36. package/docs/OPENCLAW_COMPATIBILITY.md +73 -0
  37. package/docs/OPENCLAW_LOCAL_INTEGRATION.md +596 -0
  38. package/docs/OPENCLAW_TOOLS_AND_POLICIES.md +54 -0
  39. package/docs/QUICKSTART.md +470 -0
  40. package/docs/QUICKSTART_OPENCLAW_PLUGIN.md +470 -0
  41. package/docs/README.md +28 -0
  42. package/docs/RELEASE.md +87 -0
  43. package/docs/REPO_LAYOUT.md +47 -0
  44. package/docs/SKILLS_ECOSYSTEM_ANALYSIS_FEB17.md +1260 -0
  45. package/docs/TOOL_POLICY_MAPPING.md +46 -0
  46. package/docs/UPGRADE.md +46 -0
  47. package/docs/VERIFICATION_METHODS.md +97 -0
  48. package/docs/assets/README.md +8 -0
  49. package/docs/assets/porter.svg +54 -0
  50. package/docs/development/ERROR_CODES.md +616 -0
  51. package/docs/frameworks/GITHUB_ISSUE_PROPOSALS.md +1105 -0
  52. package/docs/frameworks/crewai.md +114 -0
  53. package/docs/frameworks/cursor.md +159 -0
  54. package/docs/frameworks/langchain.md +72 -0
  55. package/docs/frameworks/n8n.md +40 -0
  56. package/docs/frameworks/openclaw.md +40 -0
  57. package/docs/launch/ADD_APORT_AWESOME_LISTS_INSTRUCTIONS.md +146 -0
  58. package/docs/launch/ANNOUNCEMENT_GUIDE.md +266 -0
  59. package/docs/launch/AWESOME_REPOS.md +53 -0
  60. package/docs/launch/CURSOR_VSCODE_HOOKS_RESEARCH.md +77 -0
  61. package/docs/launch/DEMO_TERMINAL_OUTPUT.txt +48 -0
  62. package/docs/launch/DRY_AND_PLAN_CHECKLIST.md +47 -0
  63. package/docs/launch/EVIDENCE_README.md +61 -0
  64. package/docs/launch/EVIDENCE_TERMINAL_CAPTURE.txt +10 -0
  65. package/docs/launch/FRAMEWORK_SUPPORT_PLAN.md +1640 -0
  66. package/docs/launch/LAUNCH_READINESS_CHECKLIST.md +237 -0
  67. package/docs/launch/LAUNCH_STRATEGY_SUMMARY.md +464 -0
  68. package/docs/launch/OPENCLAW_FEEDBACK_AND_FIXES.md +85 -0
  69. package/docs/launch/POST_1_VALENTINE_IMPROVED.md +233 -0
  70. package/docs/launch/POST_2_GUARDRAIL_IMPROVED.md +369 -0
  71. package/docs/launch/PRE_LAUNCH_FIXES.md +766 -0
  72. package/docs/launch/QUICK_LAUNCH_CHECKLIST.md +400 -0
  73. package/docs/launch/READINESS_SUMMARY.md +262 -0
  74. package/docs/launch/README.md +68 -0
  75. package/docs/launch/USER_STORIES.md +327 -0
  76. package/docs/launch/scripts/add-aport-awesome-pr.sh +69 -0
  77. package/docs/operations/MONITORING.md +588 -0
  78. package/docs/reviews/2026-02-18-staff-review.md +268 -0
  79. package/extensions/openclaw-aport/README.md +415 -0
  80. package/extensions/openclaw-aport/index.js +625 -0
  81. package/extensions/openclaw-aport/openclaw-aport.js +7 -0
  82. package/extensions/openclaw-aport/openclaw.plugin.json +46 -0
  83. package/extensions/openclaw-aport/package.json +36 -0
  84. package/extensions/openclaw-aport/test.js +307 -0
  85. package/external/aport-policies/README.md +363 -0
  86. package/external/aport-policies/agent.session.create.v1/README.md +345 -0
  87. package/external/aport-policies/agent.session.create.v1/policy.json +162 -0
  88. package/external/aport-policies/agent.tool.register.v1/README.md +361 -0
  89. package/external/aport-policies/agent.tool.register.v1/policy.json +172 -0
  90. package/external/aport-policies/code.release.publish.v1/README.md +51 -0
  91. package/external/aport-policies/code.release.publish.v1/policy.json +121 -0
  92. package/external/aport-policies/code.repository.merge.v1/README.md +287 -0
  93. package/external/aport-policies/code.repository.merge.v1/express.example.js +332 -0
  94. package/external/aport-policies/code.repository.merge.v1/fastapi.example.py +370 -0
  95. package/external/aport-policies/code.repository.merge.v1/policy.json +162 -0
  96. package/external/aport-policies/data.export.create.v1/README.md +226 -0
  97. package/external/aport-policies/data.export.create.v1/express.example.js +172 -0
  98. package/external/aport-policies/data.export.create.v1/fastapi.example.py +165 -0
  99. package/external/aport-policies/data.export.create.v1/policy.json +133 -0
  100. package/external/aport-policies/data.report.ingest.v1/README.md +134 -0
  101. package/external/aport-policies/data.report.ingest.v1/express.example.js +105 -0
  102. package/external/aport-policies/data.report.ingest.v1/minimal-example.js +68 -0
  103. package/external/aport-policies/data.report.ingest.v1/policy.json +174 -0
  104. package/external/aport-policies/finance.crypto.trade.v1/README.md +146 -0
  105. package/external/aport-policies/finance.crypto.trade.v1/express.example.js +109 -0
  106. package/external/aport-policies/finance.crypto.trade.v1/minimal-example.js +65 -0
  107. package/external/aport-policies/finance.crypto.trade.v1/policy.json +176 -0
  108. package/external/aport-policies/finance.payment.charge.v1/README.md +326 -0
  109. package/external/aport-policies/finance.payment.charge.v1/express.example.js +250 -0
  110. package/external/aport-policies/finance.payment.charge.v1/fastapi.example.py +227 -0
  111. package/external/aport-policies/finance.payment.charge.v1/minimal-example.js +64 -0
  112. package/external/aport-policies/finance.payment.charge.v1/policy.json +224 -0
  113. package/external/aport-policies/finance.payment.charge.v1/tests/contexts.jsonl +12 -0
  114. package/external/aport-policies/finance.payment.charge.v1/tests/expected.jsonl +12 -0
  115. package/external/aport-policies/finance.payment.charge.v1/tests/passport.instance.json +42 -0
  116. package/external/aport-policies/finance.payment.charge.v1/tests/passport.template.json +40 -0
  117. package/external/aport-policies/finance.payment.charge.v1/tests/payments-charge-policy.test.js +817 -0
  118. package/external/aport-policies/finance.payment.charge.v1/tests/test_payments_charge_policy.py +486 -0
  119. package/external/aport-policies/finance.payment.payout.v1/README.md +78 -0
  120. package/external/aport-policies/finance.payment.payout.v1/policy.json +181 -0
  121. package/external/aport-policies/finance.payment.refund.v1/README.md +275 -0
  122. package/external/aport-policies/finance.payment.refund.v1/express.example.js +167 -0
  123. package/external/aport-policies/finance.payment.refund.v1/fastapi.example.py +136 -0
  124. package/external/aport-policies/finance.payment.refund.v1/minimal-example.js +183 -0
  125. package/external/aport-policies/finance.payment.refund.v1/policy.json +216 -0
  126. package/external/aport-policies/finance.payment.refund.v1/tests/refunds-policy.test.js +924 -0
  127. package/external/aport-policies/finance.payment.refund.v1/tests/test_refunds_policy.py +778 -0
  128. package/external/aport-policies/finance.transaction.execute.v1/README.md +309 -0
  129. package/external/aport-policies/finance.transaction.execute.v1/express.example.js +261 -0
  130. package/external/aport-policies/finance.transaction.execute.v1/fastapi.example.py +231 -0
  131. package/external/aport-policies/finance.transaction.execute.v1/minimal-example.js +78 -0
  132. package/external/aport-policies/finance.transaction.execute.v1/policy.json +189 -0
  133. package/external/aport-policies/finance.transaction.execute.v1/tests/contexts.jsonl +12 -0
  134. package/external/aport-policies/finance.transaction.execute.v1/tests/expected.jsonl +12 -0
  135. package/external/aport-policies/finance.transaction.execute.v1/tests/passport.instance.json +42 -0
  136. package/external/aport-policies/finance.transaction.execute.v1/tests/passport.template.json +42 -0
  137. package/external/aport-policies/finance.transaction.execute.v1/tests/test_transactions_policy.py +214 -0
  138. package/external/aport-policies/finance.transaction.execute.v1/tests/transactions-policy.test.js +306 -0
  139. package/external/aport-policies/governance.data.access.v1/README.md +292 -0
  140. package/external/aport-policies/governance.data.access.v1/express.example.js +321 -0
  141. package/external/aport-policies/governance.data.access.v1/fastapi.example.py +279 -0
  142. package/external/aport-policies/governance.data.access.v1/minimal-example.js +65 -0
  143. package/external/aport-policies/governance.data.access.v1/policy.json +208 -0
  144. package/external/aport-policies/governance.data.access.v1/tests/contexts.jsonl +12 -0
  145. package/external/aport-policies/governance.data.access.v1/tests/data-access-policy.test.js +308 -0
  146. package/external/aport-policies/governance.data.access.v1/tests/expected.jsonl +12 -0
  147. package/external/aport-policies/governance.data.access.v1/tests/passport.instance.json +56 -0
  148. package/external/aport-policies/governance.data.access.v1/tests/passport.template.json +56 -0
  149. package/external/aport-policies/governance.data.access.v1/tests/test_data_access_policy.py +214 -0
  150. package/external/aport-policies/legal.contract.review.v1/README.md +109 -0
  151. package/external/aport-policies/legal.contract.review.v1/policy.json +378 -0
  152. package/external/aport-policies/legal.contract.review.v1/tests/legal-contract-review-policy.test.js +609 -0
  153. package/external/aport-policies/legal.contract.review.v1/tests/passport.template.json +49 -0
  154. package/external/aport-policies/mcp.tool.execute.v1/README.md +301 -0
  155. package/external/aport-policies/mcp.tool.execute.v1/policy.json +141 -0
  156. package/external/aport-policies/messaging.message.send.v1/README.md +230 -0
  157. package/external/aport-policies/messaging.message.send.v1/express.example.js +183 -0
  158. package/external/aport-policies/messaging.message.send.v1/fastapi.example.py +193 -0
  159. package/external/aport-policies/messaging.message.send.v1/policy.json +144 -0
  160. package/external/aport-policies/policy-template.json +107 -0
  161. package/external/aport-policies/system.command.execute.v1/README.md +275 -0
  162. package/external/aport-policies/system.command.execute.v1/policy.json +146 -0
  163. package/external/aport-spec/CONTRIBUTING.md +273 -0
  164. package/external/aport-spec/LICENSE +21 -0
  165. package/external/aport-spec/README.md +168 -0
  166. package/external/aport-spec/conformance/README.md +294 -0
  167. package/external/aport-spec/conformance/cases/data.export.v1/contexts/allow_users.json +6 -0
  168. package/external/aport-spec/conformance/cases/data.export.v1/contexts/deny_pii.json +6 -0
  169. package/external/aport-spec/conformance/cases/data.export.v1/expected/allow_users.decision.json +19 -0
  170. package/external/aport-spec/conformance/cases/data.export.v1/expected/deny_pii.decision.json +19 -0
  171. package/external/aport-spec/conformance/cases/data.export.v1/passports/template.json +29 -0
  172. package/external/aport-spec/conformance/cases/payments.refunds.v1/contexts/allow_50usd.json +9 -0
  173. package/external/aport-spec/conformance/cases/payments.refunds.v1/contexts/deny_150usd.json +9 -0
  174. package/external/aport-spec/conformance/cases/payments.refunds.v1/contexts/deny_currency.json +9 -0
  175. package/external/aport-spec/conformance/cases/payments.refunds.v1/expected/allow_50usd.decision.json +19 -0
  176. package/external/aport-spec/conformance/cases/payments.refunds.v1/expected/deny_150usd.decision.json +19 -0
  177. package/external/aport-spec/conformance/cases/payments.refunds.v1/expected/deny_currency.decision.json +19 -0
  178. package/external/aport-spec/conformance/cases/payments.refunds.v1/passports/template.json +42 -0
  179. package/external/aport-spec/conformance/package.json +44 -0
  180. package/external/aport-spec/conformance/pnpm-lock.yaml +642 -0
  181. package/external/aport-spec/conformance/src/cases.ts +371 -0
  182. package/external/aport-spec/conformance/src/ed25519.ts +167 -0
  183. package/external/aport-spec/conformance/src/jcs.ts +85 -0
  184. package/external/aport-spec/conformance/src/runner.ts +533 -0
  185. package/external/aport-spec/conformance/src/validators.ts +185 -0
  186. package/external/aport-spec/conformance/test-runner.js +315 -0
  187. package/external/aport-spec/conformance/tsconfig.json +21 -0
  188. package/external/aport-spec/error-schema.json +192 -0
  189. package/external/aport-spec/index.json +12 -0
  190. package/external/aport-spec/integrations/clawmoat/README.md +12 -0
  191. package/external/aport-spec/integrations/shield/README.md +245 -0
  192. package/external/aport-spec/integrations/shield/adapters/index.js +116 -0
  193. package/external/aport-spec/integrations/shield/adapters/system-command-execute.js +133 -0
  194. package/external/aport-spec/integrations/shield/test/README.md +58 -0
  195. package/external/aport-spec/integrations/shield/test/shield.md +40 -0
  196. package/external/aport-spec/integrations/shield/test/test-shield-to-verify.js +274 -0
  197. package/external/aport-spec/metrics-schema.json +504 -0
  198. package/external/aport-spec/oap/CHANGELOG.md +54 -0
  199. package/external/aport-spec/oap/VERSION.md +40 -0
  200. package/external/aport-spec/oap/capability-registry.md +229 -0
  201. package/external/aport-spec/oap/conformance.md +257 -0
  202. package/external/aport-spec/oap/decision-schema.json +114 -0
  203. package/external/aport-spec/oap/examples/context.refund.usd.50.json +9 -0
  204. package/external/aport-spec/oap/examples/decision.allow.sample.json +20 -0
  205. package/external/aport-spec/oap/examples/decision.deny.sample.json +23 -0
  206. package/external/aport-spec/oap/examples/passport.instance.v1.json +50 -0
  207. package/external/aport-spec/oap/examples/passport.template.v1.json +71 -0
  208. package/external/aport-spec/oap/oap-spec.md +426 -0
  209. package/external/aport-spec/oap/passport-schema.json +396 -0
  210. package/external/aport-spec/oap/security.md +213 -0
  211. package/external/aport-spec/oap/vc/context-oap-v1.jsonld +137 -0
  212. package/external/aport-spec/oap/vc/examples/oap-decision-vc.json +37 -0
  213. package/external/aport-spec/oap/vc/examples/oap-passport-vc.json +68 -0
  214. package/external/aport-spec/oap/vc/tools/INTEGRATION.md +375 -0
  215. package/external/aport-spec/oap/vc/tools/README.md +278 -0
  216. package/external/aport-spec/oap/vc/tools/examples/decision-to-vc.js +66 -0
  217. package/external/aport-spec/oap/vc/tools/examples/passport-to-vc.js +83 -0
  218. package/external/aport-spec/oap/vc/tools/examples/vc-to-decision.js +77 -0
  219. package/external/aport-spec/oap/vc/tools/examples/vc-to-passport.js +94 -0
  220. package/external/aport-spec/oap/vc/tools/package.json +38 -0
  221. package/external/aport-spec/oap/vc/tools/pnpm-lock.yaml +472 -0
  222. package/external/aport-spec/oap/vc/tools/src/cli.ts +226 -0
  223. package/external/aport-spec/oap/vc/tools/src/crypto-utils.ts +427 -0
  224. package/external/aport-spec/oap/vc/tools/src/index.ts +653 -0
  225. package/external/aport-spec/oap/vc/tools/src/test.ts +148 -0
  226. package/external/aport-spec/oap/vc/tools/src/vp.ts +382 -0
  227. package/external/aport-spec/oap/vc/tools/test-simple.js +214 -0
  228. package/external/aport-spec/oap/vc/tools/tsconfig.json +19 -0
  229. package/external/aport-spec/oap/vc/vc-mapping.md +443 -0
  230. package/external/aport-spec/passport-schema.json +586 -0
  231. package/external/aport-spec/rate-limiting.md +136 -0
  232. package/external/aport-spec/transport-profile.md +325 -0
  233. package/external/aport-spec/webhook-spec.md +314 -0
  234. package/package.json +70 -0
  235. package/skills/aport-agent-guardrail/SKILL.md +314 -0
  236. package/src/evaluator.js +252 -0
  237. package/src/server/index.js +72 -0
@@ -0,0 +1,625 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * APort OpenClaw Plugin
4
+ *
5
+ * Registers before_tool_call hook for deterministic policy enforcement.
6
+ * Calls APort guardrail (local or API) before every tool execution.
7
+ * Returns { block?, blockReason?, params?, reasons?, reasonSummary? }. On allow, reasons from APort are propagated for UX.
8
+ *
9
+ * Installation:
10
+ * openclaw plugins install /path/to/aport-agent-guardrails/extensions/openclaw-aport
11
+ *
12
+ * Configuration (in config.yaml):
13
+ * plugins:
14
+ * entries:
15
+ * openclaw-aport:
16
+ * enabled: true
17
+ * config:
18
+ * mode: local # "local" | "api"
19
+ * passportFile: ~/.openclaw/aport/passport.json # Omit when using agentId (hosted)
20
+ * agentId: ap_... # Optional: hosted passport from aport.io (API fetches passport)
21
+ * guardrailScript: ~/.openclaw/.skills/aport-guardrail-bash.sh
22
+ * apiUrl: https://api.aport.io # For API mode
23
+ * # apiKey optional: set APORT_API_KEY env var if your API requires it
24
+ * failClosed: true # Block on error
25
+ *
26
+ * Decisions (local mode): Written to <config_dir>/decisions/<timestamp>-<id>.json and left for
27
+ * audit. The guardrail script also appends a one-line summary to <config_dir>/audit.log (when
28
+ * passport is in config/aport/, audit lives in config/aport/audit.log). Decisions
29
+ * follow OAP v1.0 schema (see agent-passport spec/oap/decision-schema.json). Local mode uses
30
+ * unsigned/local-unsigned; API mode can return signed decisions (chained audit in agent-passport).
31
+ */
32
+
33
+ import { spawn } from "child_process";
34
+ import { createHash } from "crypto";
35
+ import { readFile, mkdir } from "fs/promises";
36
+ import { join, dirname } from "path";
37
+ import { homedir } from "os";
38
+
39
+ export default function (api) {
40
+ const id = "openclaw-aport";
41
+ const name = "APort Guardrails";
42
+
43
+ // Plugin config from plugins.entries.openclaw-aport.config (OpenClaw passes api.pluginConfig)
44
+ const config = api.pluginConfig || {};
45
+ const mode = config.mode || "local";
46
+ const agentId = config.agentId || null;
47
+ const passportFile = expandPath(
48
+ config.passportFile || "~/.openclaw/aport/passport.json",
49
+ );
50
+ const guardrailScript = expandPath(
51
+ config.guardrailScript || "~/.openclaw/.skills/aport-guardrail-bash.sh",
52
+ );
53
+ const apiUrl =
54
+ config.apiUrl || process.env.APORT_API_URL || "https://api.aport.io";
55
+ const apiKey = config.apiKey || process.env.APORT_API_KEY;
56
+ const failClosed = config.failClosed !== false; // Default true
57
+ const allowUnmappedTools = config.allowUnmappedTools !== false; // Default true = allow unmapped (custom skills, ClawHub); set false for strict
58
+ // When true (default), every before_tool_call runs a fresh APort verify; we never reuse a previous decision (passport/limits may have changed).
59
+ const alwaysVerifyEachToolCall = config.alwaysVerifyEachToolCall !== false;
60
+ // When true (default), exec is mapped to system.command.execute.v1 and checked against passport allowed_commands.
61
+ // When false, exec is not mapped (unmapped tools allowed by default) so OpenClaw can run any command — no guardrail for exec (use only if you rely on other controls).
62
+ const mapExecToPolicy = config.mapExecToPolicy !== false;
63
+
64
+ const log = (msg) => api.logger?.info?.(msg);
65
+ const warn = (msg) => api.logger?.warn?.(msg);
66
+ const err = (msg) => api.logger?.error?.(msg);
67
+
68
+ /**
69
+ * One-line summary for ALLOW/BLOCKED logs — tool + context hint. No I/O, no heavy work.
70
+ * Keeps logs scannable and screenshot-friendly (e.g. "system.command.execute - mkdir test").
71
+ */
72
+ function decisionLogSummary(effectiveToolName, policyName, context) {
73
+ if (policyName === "system.command.execute.v1" && context?.command) {
74
+ const cmd = String(context.command).replace(/\s+/g, " ").trim();
75
+ return cmd.length > 52 ? cmd.slice(0, 52) + "…" : cmd;
76
+ }
77
+ if (policyName === "messaging.message.send.v1") {
78
+ const to = context?.recipient ?? context?.to ?? "";
79
+ return to ? `send → ${String(to).slice(0, 32)}` : "send";
80
+ }
81
+ if (policyName?.startsWith("code.repository.")) return "repo";
82
+ if (policyName?.startsWith("mcp.")) return "mcp tool";
83
+ return policyName?.replace(/\.v\d+$/, "") ?? effectiveToolName;
84
+ }
85
+
86
+ /** Format decision.reasons (OAP code + message) for logs and UX; used for both allow and deny. */
87
+ function formatReasons(decision) {
88
+ const reasons = decision.reasons || [];
89
+ const primaryMessage = reasons[0]?.message || decision.reason || "";
90
+ const codes = reasons.map((r) => r.code).filter(Boolean);
91
+ const codeList = codes.length ? codes.join(", ") : "";
92
+ const lines =
93
+ reasons.length > 0
94
+ ? reasons
95
+ .map((r) => ` • ${r.code || "oap.unknown"}: ${r.message || ""}`)
96
+ .join("\n")
97
+ : primaryMessage
98
+ ? ` • ${primaryMessage}`
99
+ : "";
100
+ return { reasons, codeList, lines, primaryMessage };
101
+ }
102
+
103
+ /**
104
+ * Detect if exec is actually invoking our guardrail script (e.g. agent/skill runs
105
+ * "aport-guardrail.sh messaging.message.send '{}'" or "aport-guardrail.sh system.command.execute '{\"command\":\"mkdir ...\"}'").
106
+ * If so, return { innerToolName, innerContext } so we evaluate the inner tool's policy, not exec as a shell command.
107
+ * @param {string} command - params.command from exec
108
+ * @returns {{ innerToolName: string, innerContext: object } | null}
109
+ */
110
+ function parseGuardrailInvocation(command) {
111
+ if (typeof command !== "string" || !command.includes("aport-guardrail"))
112
+ return null;
113
+ const match = command.match(
114
+ /aport-guardrail[^\s]*\s+(\S+)\s+['"]([\s\S]*)['"]\s*$/,
115
+ );
116
+ if (!match) return null;
117
+ const innerToolName = match[1];
118
+ let innerContext = {};
119
+ try {
120
+ const jsonStr = match[2].trim();
121
+ if (jsonStr) innerContext = JSON.parse(jsonStr);
122
+ } catch (_) {
123
+ return null;
124
+ }
125
+ return { innerToolName, innerContext };
126
+ }
127
+
128
+ /** Collect all string values from a nested object (like openclaw-shield). */
129
+ function collectStrings(value) {
130
+ const out = [];
131
+ if (typeof value === "string") {
132
+ out.push(value);
133
+ } else if (Array.isArray(value)) {
134
+ for (const v of value) out.push(...collectStrings(v));
135
+ } else if (value && typeof value === "object") {
136
+ for (const v of Object.values(value)) out.push(...collectStrings(v));
137
+ }
138
+ return out;
139
+ }
140
+
141
+ /**
142
+ * Normalize context for exec / system.command.execute so the actual shell command
143
+ * is always in context.command. OpenClaw uses one tool "exec" for all commands (cp, mkdir, etc.);
144
+ * the policy checks the command string against allowed_commands, so we must pass the real command.
145
+ * Per https://docs.openclaw.ai/tools/exec the tool takes "command" (required). Gateway may
146
+ * pass it as params.command, event.input, or nested; we also fall back to first long string in params/event.
147
+ * @param {object} params - event.params from before_tool_call
148
+ * @param {object} [event] - full event in case gateway puts command on event.input/event.arguments
149
+ */
150
+ function normalizeExecContext(params, event) {
151
+ const src =
152
+ event && typeof event === "object"
153
+ ? { ...event, ...params }
154
+ : params || {};
155
+ if (typeof src !== "object") return { command: "" };
156
+ const raw =
157
+ src.command ??
158
+ src.cmd ??
159
+ (src.arguments &&
160
+ typeof src.arguments === "object" &&
161
+ src.arguments.command) ??
162
+ (src.input && typeof src.input === "object" && src.input.command) ??
163
+ (typeof src.input === "string" && src.input.trim().length > 0
164
+ ? src.input
165
+ : null) ??
166
+ (src.args && typeof src.args === "object" && src.args.command) ??
167
+ (src.invocation &&
168
+ typeof src.invocation === "object" &&
169
+ src.invocation.command) ??
170
+ (src.payload && typeof src.payload === "object" && src.payload.command) ??
171
+ (Array.isArray(src.args) && src.args.length > 0
172
+ ? src.args.join(" ")
173
+ : src.args?.[0]);
174
+ let full = typeof raw === "string" ? raw : raw != null ? String(raw) : "";
175
+ if (!full) {
176
+ const strings = collectStrings(src);
177
+ const likeCommand = (s) =>
178
+ typeof s === "string" && s.length > 2 && s.trim().length > 0;
179
+ const withSpace = strings.filter(
180
+ (s) => likeCommand(s) && s.includes(" "),
181
+ );
182
+ const candidate = withSpace[0] ?? strings.find(likeCommand);
183
+ if (candidate) full = candidate.trim();
184
+ }
185
+ const out = { ...params, command: full, full_command: full };
186
+ if (params && params.workdir !== undefined && out.cwd === undefined)
187
+ out.cwd = params.workdir;
188
+ return out;
189
+ }
190
+
191
+ log(
192
+ `[${name}] Loaded: mode=${mode}, ${agentId ? `agentId=${agentId}` : `passportFile=${passportFile}`}, unmapped=${allowUnmappedTools ? "allow" : "block"}, alwaysVerify=${alwaysVerifyEachToolCall}, mapExec=${mapExecToPolicy}`,
193
+ );
194
+
195
+ /**
196
+ * before_tool_call hook - Runs before EVERY tool execution.
197
+ * We never reuse a previous decision: each call triggers a fresh APort verify (passport/limits may have changed).
198
+ *
199
+ * @param {object} event - { toolName, params, ... }
200
+ * @param {object} ctx - OpenClaw context
201
+ * @returns {Promise<object>} - { block?, blockReason?, params?, reasons? (OAP), reasonSummary? }
202
+ */
203
+ api.on("before_tool_call", async (event, ctx) => {
204
+ const { toolName, params } = event;
205
+
206
+ try {
207
+ // Map OpenClaw tool names to APort policy names. If mapExecToPolicy is false, exec is unmapped (never blocked).
208
+ const policyName =
209
+ toolName === "exec" && !mapExecToPolicy
210
+ ? null
211
+ : mapToolToPolicy(toolName);
212
+
213
+ if (!policyName) {
214
+ // No policy mapping: allow by default so custom skills / ClawHub / built-in tools work; block only if allowUnmappedTools is false (strict)
215
+ if (allowUnmappedTools) {
216
+ log(`[${name}] ALLOW: ${toolName} - (unmapped, no policy)`);
217
+ return {};
218
+ }
219
+ log(
220
+ `[${name}] BLOCKED: ${toolName} - no policy mapping (allowUnmappedTools=false)`,
221
+ );
222
+ return {
223
+ block: true,
224
+ blockReason: `🛡️ APort: Tool "${toolName}" has no policy mapping. Unmapped tools are blocked (allowUnmappedTools: false). Set allowUnmappedTools: true in config to allow custom skills and ClawHub tools.`,
225
+ };
226
+ }
227
+
228
+ log(`[${name}] Checking tool: ${toolName} → policy: ${policyName}`);
229
+
230
+ // For exec: the "command" may be (1) a real shell command (mkdir, npm, etc.) or
231
+ // (2) an invocation of our guardrail script (e.g. aport-guardrail.sh messaging.message.send '{}').
232
+ // In case (2) we evaluate the inner tool's policy, not exec as a shell command.
233
+ let effectivePolicyName = policyName;
234
+ let effectiveToolName = toolName;
235
+ let context =
236
+ policyName === "system.command.execute.v1"
237
+ ? normalizeExecContext(params, event)
238
+ : params;
239
+
240
+ if (policyName === "system.command.execute.v1" && context.command) {
241
+ const guardrailInvocation = parseGuardrailInvocation(context.command);
242
+ if (guardrailInvocation) {
243
+ const { innerToolName, innerContext } = guardrailInvocation;
244
+ const innerPolicy = mapToolToPolicy(innerToolName);
245
+ if (innerPolicy) {
246
+ effectivePolicyName = innerPolicy;
247
+ effectiveToolName = innerToolName;
248
+ context =
249
+ innerPolicy === "system.command.execute.v1"
250
+ ? normalizeExecContext(innerContext, { params: innerContext })
251
+ : innerContext;
252
+ log(
253
+ `[${name}] exec delegates to inner tool: ${innerToolName} → policy: ${innerPolicy}`,
254
+ );
255
+ }
256
+ }
257
+ const cmd = context.command || "";
258
+ log(
259
+ `[${name}] exec params.command → effective policy=${effectivePolicyName} context.command=${cmd ? `"${cmd.slice(0, 60)}${cmd.length > 60 ? "…" : ""}"` : "(n/a)"}`,
260
+ );
261
+ }
262
+
263
+ // Allow exec with no command (probe/placeholder) without calling guardrail so we don't block pre-checks.
264
+ if (effectivePolicyName === "system.command.execute.v1") {
265
+ const cmdStr =
266
+ typeof context.command === "string" ? context.command.trim() : "";
267
+ if (!cmdStr) {
268
+ log(`[${name}] ALLOW: exec - (empty command, skip)`);
269
+ return {};
270
+ }
271
+ }
272
+
273
+ // Every call runs a fresh verify — no cache. Each invocation gets a unique decision file path; we never reuse a previous decision.
274
+ // Local mode: guardrail script maps tool names via case "exec.run|exec.*|system.*" etc. Raw "exec" does not match, so pass policy-derived name (e.g. system.command.execute) so the script recognizes it.
275
+ const scriptToolName = effectivePolicyName.replace(/\.v\d+$/, "");
276
+ let decision;
277
+ if (mode === "api") {
278
+ decision = await verifyViaAPI(effectivePolicyName, context, {
279
+ apiUrl,
280
+ apiKey,
281
+ passportFile: agentId ? null : passportFile,
282
+ agentId,
283
+ });
284
+ } else {
285
+ decision = await verifyViaScript(scriptToolName, context, {
286
+ guardrailScript,
287
+ passportFile,
288
+ });
289
+ }
290
+
291
+ // Tamper check is non-core: run after we return so it never blocks the tool call
292
+ if (!decision.allow && decision.content_hash) {
293
+ const decisionId = decision.decision_id;
294
+ setImmediate(() => {
295
+ if (!verifyDecisionIntegrity(decision)) {
296
+ warn(
297
+ `[${name}] Decision ${decisionId} may be tampered (content_hash mismatch)`,
298
+ );
299
+ }
300
+ });
301
+ }
302
+
303
+ if (!decision.allow) {
304
+ const {
305
+ reasons,
306
+ codeList,
307
+ lines: reasonLines,
308
+ primaryMessage,
309
+ } = formatReasons(decision);
310
+ const message = primaryMessage || "Policy denied";
311
+ log(
312
+ `[${name}] BLOCKED: ${effectiveToolName} - ${message}${codeList ? ` (${codeList})` : ""}`,
313
+ );
314
+ const isCommandNotAllowed =
315
+ effectivePolicyName === "system.command.execute.v1" &&
316
+ reasons.some((r) => r.code === "oap.command_not_allowed");
317
+ if (isCommandNotAllowed) {
318
+ if (agentId) {
319
+ warn(
320
+ `[${name}] Hosted passport (agent_id: ${agentId}). Add allowed_commands at aport.io or use "*" to allow all (blocked patterns still apply).`,
321
+ );
322
+ } else {
323
+ try {
324
+ const passportData = await readFile(passportFile, "utf8");
325
+ const passport = JSON.parse(passportData);
326
+ const allowed =
327
+ passport?.limits?.["system.command.execute"]?.allowed_commands;
328
+ warn(
329
+ `[${name}] Passport allowed_commands: ${JSON.stringify(allowed)} — add "*" or the command (e.g. ls) to fix. File: ${passportFile}`,
330
+ );
331
+ } catch (_) {
332
+ warn(
333
+ `[${name}] Could not read passport for diagnostic: ${passportFile}`,
334
+ );
335
+ }
336
+ }
337
+ }
338
+ const hint = isCommandNotAllowed
339
+ ? "\nFor shell commands (cp, mkdir, npm, etc.), add them to limits.allowed_commands in your passport."
340
+ : "";
341
+ const passportHint = agentId
342
+ ? `To allow this action, update limits at aport.io (hosted passport: ${agentId})`
343
+ : `To allow this action, update limits in your passport: ${passportFile}`;
344
+ const blockReason = [
345
+ "🛡️ APort Policy Denied",
346
+ "",
347
+ `Policy: ${effectivePolicyName}`,
348
+ "",
349
+ "Reasons (OAP codes):",
350
+ reasonLines || ` • ${message}`,
351
+ "",
352
+ passportHint,
353
+ hint,
354
+ ].join("\n");
355
+ return {
356
+ block: true,
357
+ blockReason,
358
+ reasons,
359
+ };
360
+ }
361
+
362
+ const {
363
+ reasons,
364
+ codeList,
365
+ lines: reasonLines,
366
+ primaryMessage,
367
+ } = formatReasons(decision);
368
+ const reasonSummary =
369
+ reasonLines || primaryMessage
370
+ ? ["APort allowed", reasonLines || primaryMessage]
371
+ .filter(Boolean)
372
+ .join("\n")
373
+ : undefined;
374
+ const allowSummary = decisionLogSummary(
375
+ effectiveToolName,
376
+ effectivePolicyName,
377
+ context,
378
+ );
379
+ log(`[${name}] ALLOW: ${effectiveToolName} - ${allowSummary}`);
380
+ return {
381
+ reasons: decision.reasons?.length ? decision.reasons : undefined,
382
+ reasonSummary: reasonSummary || undefined,
383
+ };
384
+ } catch (error) {
385
+ err(`[${name}] Error evaluating policy: ${error.message}`);
386
+
387
+ if (failClosed) {
388
+ // Fail closed - block on error
389
+ return {
390
+ block: true,
391
+ blockReason: `🛡️ APort Policy Error (fail-closed)\n\nError: ${error.message}\n\nCheck configuration at plugins.entries.openclaw-aport.config`,
392
+ };
393
+ } else {
394
+ // Fail open - allow on error (not recommended)
395
+ warn(`[${name}] Allowing tool despite error (failClosed=false)`);
396
+ return {};
397
+ }
398
+ }
399
+ });
400
+
401
+ /**
402
+ * after_tool_call hook - Runs after successful tool execution
403
+ * Optional: For audit logging
404
+ */
405
+ api.on("after_tool_call", async (event, ctx) => {
406
+ const { toolName, params, result } = event;
407
+ log(`[${name}] Tool completed: ${toolName}`);
408
+ // Could log to audit trail here
409
+ });
410
+
411
+ log(`[${name}] Registered hooks: before_tool_call, after_tool_call`);
412
+ }
413
+
414
+ /**
415
+ * Map OpenClaw tool names to APort policy names.
416
+ * Exported for tests; used by before_tool_call to decide if we run guardrail and for API mode.
417
+ */
418
+ export function mapToolToPolicy(toolName) {
419
+ // Normalize tool name
420
+ const tool = toolName.toLowerCase();
421
+
422
+ // Git/Code operations
423
+ if (tool.match(/git\.(create_pr|merge|push|commit)/))
424
+ return "code.repository.merge.v1";
425
+ if (tool.startsWith("git.")) return "code.repository.merge.v1";
426
+
427
+ // System commands / exec (include bare "exec" - OpenClaw may send this for run-command tools)
428
+ if (tool === "exec") return "system.command.execute.v1";
429
+ if (tool.match(/exec\.(run|shell)/)) return "system.command.execute.v1";
430
+ if (tool.startsWith("exec.")) return "system.command.execute.v1";
431
+ if (tool.startsWith("system.command.")) return "system.command.execute.v1";
432
+ if (tool === "bash" || tool === "shell" || tool === "command")
433
+ return "system.command.execute.v1";
434
+
435
+ // Messaging
436
+ if (tool.startsWith("message.")) return "messaging.message.send.v1";
437
+ if (tool.startsWith("messaging.")) return "messaging.message.send.v1";
438
+ if (tool.match(/sms|whatsapp|slack|email/))
439
+ return "messaging.message.send.v1";
440
+
441
+ // MCP tools
442
+ if (tool.startsWith("mcp.")) return "mcp.tool.execute.v1";
443
+
444
+ // Agent sessions
445
+ if (tool.match(/agent\.session|session\.create/))
446
+ return "agent.session.create.v1";
447
+ if (tool.startsWith("session.")) return "agent.session.create.v1";
448
+
449
+ // Tool registration
450
+ if (tool.match(/agent\.tool|tool\.register/)) return "agent.tool.register.v1";
451
+
452
+ // Financial operations
453
+ if (tool.match(/payment\.refund|refund/)) return "finance.payment.refund.v1";
454
+ if (tool.match(/payment\.charge|charge/)) return "finance.payment.charge.v1";
455
+ if (tool.startsWith("finance.")) return "finance.payment.refund.v1";
456
+
457
+ // Data operations
458
+ if (tool.match(/database\.(write|insert|update|delete)/))
459
+ return "data.export.create.v1";
460
+ if (tool.match(/data\.export|export/)) return "data.export.create.v1";
461
+
462
+ // No mapping found
463
+ return null;
464
+ }
465
+
466
+ /**
467
+ * Canonicalize object for hashing (sort keys at every level, like jq -c -S).
468
+ * Must match guardrail script's jq --sort-keys output so content_hash verifies.
469
+ * Exported for tests.
470
+ */
471
+ export function canonicalize(obj) {
472
+ if (obj === null || typeof obj !== "object") return JSON.stringify(obj);
473
+ if (Array.isArray(obj)) return "[" + obj.map(canonicalize).join(",") + "]";
474
+ const keys = Object.keys(obj).sort();
475
+ const parts = keys.map((k) => JSON.stringify(k) + ":" + canonicalize(obj[k]));
476
+ return "{" + parts.join(",") + "}";
477
+ }
478
+
479
+ /**
480
+ * Verify local decision file integrity (content_hash). Returns true if valid or no hash (legacy).
481
+ * If the file was edited or moved, the hash will not match. Exported for tests.
482
+ */
483
+ export function verifyDecisionIntegrity(decision) {
484
+ if (!decision || !decision.content_hash) return true;
485
+ const { content_hash, ...rest } = decision;
486
+ const canonical = canonicalize(rest);
487
+ const computed =
488
+ "sha256:" + createHash("sha256").update(canonical, "utf8").digest("hex");
489
+ return computed === content_hash;
490
+ }
491
+
492
+ /**
493
+ * Verify action via local guardrail script.
494
+ * toolName must match the script's case patterns (e.g. system.command.execute, messaging.message.send); the plugin passes the policy-derived name (policy id without .v1) so "exec" is not passed (script would treat it as unknown).
495
+ * Decisions are written under config dir (decisions/) with content_hash and chain (prev_*);
496
+ * they are left for audit and are tamper-resistant (edit or reorder breaks verification).
497
+ */
498
+ async function verifyViaScript(
499
+ toolName,
500
+ params,
501
+ { guardrailScript, passportFile },
502
+ ) {
503
+ const contextJson = JSON.stringify(params);
504
+ // Unique decision file per invocation — no cache, no reuse. We only read the file we pass here.
505
+ const configDir = dirname(passportFile);
506
+ const decisionsDir = join(configDir, "decisions");
507
+ await mkdir(decisionsDir, { recursive: true });
508
+ const decisionFile = join(
509
+ decisionsDir,
510
+ `${Date.now()}-${Math.random().toString(36).slice(2, 10)}.json`,
511
+ );
512
+
513
+ return new Promise((resolve, reject) => {
514
+ const proc = spawn(guardrailScript, [toolName, contextJson], {
515
+ env: {
516
+ ...process.env,
517
+ OPENCLAW_PASSPORT_FILE: passportFile,
518
+ OPENCLAW_DECISION_FILE: decisionFile,
519
+ OPENCLAW_AUDIT_LOG: join(configDir, "audit.log"),
520
+ },
521
+ });
522
+
523
+ let stdout = "";
524
+ let stderr = "";
525
+
526
+ proc.stdout.on("data", (data) => (stdout += data));
527
+ proc.stderr.on("data", (data) => (stderr += data));
528
+
529
+ proc.on("close", async (code) => {
530
+ // Always read the decision file we passed to this invocation only (script writes to it before exit).
531
+ try {
532
+ const decisionData = await readFile(decisionFile, "utf8");
533
+ const decision = JSON.parse(decisionData);
534
+ resolve(decision);
535
+ } catch (err) {
536
+ if (code === 0) {
537
+ resolve({ allow: true });
538
+ } else {
539
+ resolve({
540
+ allow: false,
541
+ reasons: [
542
+ { message: stderr || `Tool ${toolName} denied (exit ${code})` },
543
+ ],
544
+ });
545
+ }
546
+ }
547
+ });
548
+
549
+ proc.on("error", (error) => {
550
+ reject(new Error(`Failed to run guardrail script: ${error.message}`));
551
+ });
552
+ });
553
+ }
554
+
555
+ /** Generate an idempotency key (10–64 chars, alphanumeric + hyphen/underscore) for API requests that require it. */
556
+ function ensureIdempotencyKey(context) {
557
+ if (context && context.idempotency_key) return context;
558
+ const ts = Date.now().toString(36);
559
+ const r = Math.random().toString(36).slice(2, 10);
560
+ const key = `idem_${ts}_${r}`.slice(0, 64);
561
+ return { ...context, idempotency_key: key };
562
+ }
563
+
564
+ /**
565
+ * Verify action via APort API
566
+ * When agentId is set (hosted passport), API fetches passport from registry; no passport file.
567
+ */
568
+ async function verifyViaAPI(
569
+ policyName,
570
+ params,
571
+ { apiUrl, apiKey, passportFile, agentId },
572
+ ) {
573
+ try {
574
+ const context = ensureIdempotencyKey(params);
575
+
576
+ const url = `${apiUrl}/api/verify/policy/${policyName}`;
577
+ const headers = {
578
+ "Content-Type": "application/json",
579
+ };
580
+ if (apiKey) {
581
+ headers["Authorization"] = `Bearer ${apiKey}`;
582
+ }
583
+
584
+ let body;
585
+ if (agentId) {
586
+ body = JSON.stringify({
587
+ context: { agent_id: agentId, ...context },
588
+ });
589
+ } else {
590
+ const passportData = await readFile(passportFile, "utf8");
591
+ const passport = JSON.parse(passportData);
592
+ body = JSON.stringify({
593
+ passport,
594
+ context,
595
+ });
596
+ }
597
+
598
+ const response = await fetch(url, {
599
+ method: "POST",
600
+ headers,
601
+ body,
602
+ });
603
+
604
+ if (!response.ok) {
605
+ throw new Error(
606
+ `API request failed: ${response.status} ${response.statusText}`,
607
+ );
608
+ }
609
+
610
+ const data = await response.json();
611
+ return data.decision || data;
612
+ } catch (error) {
613
+ throw new Error(`API verification failed: ${error.message}`);
614
+ }
615
+ }
616
+
617
+ /**
618
+ * Expand ~ to home directory
619
+ */
620
+ function expandPath(path) {
621
+ if (path.startsWith("~/")) {
622
+ return join(homedir(), path.slice(2));
623
+ }
624
+ return path;
625
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * OpenClaw extension entry point.
3
+ * The loader resolves plugins by extension id, so it expects this file
4
+ * (packageDir/openclaw-aport.js). Re-export the main plugin.
5
+ */
6
+ export { default } from './index.js';
7
+ export * from './index.js';
@@ -0,0 +1,46 @@
1
+ {
2
+ "id": "openclaw-aport",
3
+ "name": "APort Guardrails",
4
+ "description": "Deterministic pre-action authorization via APort policy enforcement. Registers before_tool_call to block disallowed tools.",
5
+ "version": "1.0.0",
6
+ "configSchema": {
7
+ "type": "object",
8
+ "additionalProperties": false,
9
+ "properties": {
10
+ "mode": {
11
+ "type": "string",
12
+ "enum": ["local", "api"],
13
+ "default": "local",
14
+ "description": "local = guardrail script, api = APort API"
15
+ },
16
+ "passportFile": {
17
+ "type": "string",
18
+ "default": "~/.openclaw/passport.json",
19
+ "description": "Path to passport JSON"
20
+ },
21
+ "guardrailScript": {
22
+ "type": "string",
23
+ "default": "~/.openclaw/.skills/aport-guardrail-bash.sh",
24
+ "description": "Path to guardrail script (local mode)"
25
+ },
26
+ "apiUrl": {
27
+ "type": "string",
28
+ "description": "APort API base URL (api mode)"
29
+ },
30
+ "apiKey": {
31
+ "type": "string",
32
+ "description": "Optional. Prefer APORT_API_KEY env var; do not put ${APORT_API_KEY} in config (OpenClaw requires the var to exist)."
33
+ },
34
+ "failClosed": {
35
+ "type": "boolean",
36
+ "default": true,
37
+ "description": "Block tool on guardrail error when true"
38
+ },
39
+ "allowUnmappedTools": {
40
+ "type": "boolean",
41
+ "default": true,
42
+ "description": "If true (default), allow tools with no policy mapping (custom skills, ClawHub, etc.). If false, block unmapped tools (strict mode)."
43
+ }
44
+ }
45
+ }
46
+ }