@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.
- package/LICENSE +217 -0
- package/README.md +481 -0
- package/bin/agent-guardrails +133 -0
- package/bin/aport-create-passport.sh +444 -0
- package/bin/aport-cursor-hook.sh +90 -0
- package/bin/aport-guardrail-api.sh +108 -0
- package/bin/aport-guardrail-bash.sh +394 -0
- package/bin/aport-guardrail-v2.sh +5 -0
- package/bin/aport-guardrail.sh +5 -0
- package/bin/aport-resolve-paths.sh +71 -0
- package/bin/aport-status.sh +276 -0
- package/bin/frameworks/crewai.sh +49 -0
- package/bin/frameworks/cursor.sh +95 -0
- package/bin/frameworks/langchain.sh +48 -0
- package/bin/frameworks/n8n.sh +36 -0
- package/bin/frameworks/openclaw.sh +19 -0
- package/bin/lib/allowlist.sh +18 -0
- package/bin/lib/common.sh +28 -0
- package/bin/lib/config.sh +46 -0
- package/bin/lib/constants.sh +232 -0
- package/bin/lib/detect.sh +65 -0
- package/bin/lib/error.sh +269 -0
- package/bin/lib/passport.sh +19 -0
- package/bin/lib/templates/.gitkeep +1 -0
- package/bin/lib/templates/config.yaml +6 -0
- package/bin/lib/validation.sh +206 -0
- package/bin/openclaw +660 -0
- package/docs/ADDING_A_FRAMEWORK.md +87 -0
- package/docs/AGENTS.md.example +40 -0
- package/docs/CODE_REVIEW.md +192 -0
- package/docs/DEPLOYMENT_READINESS.md +81 -0
- package/docs/FAQ_SECURITY_SCANNERS.md +373 -0
- package/docs/FRAMEWORK_ROADMAP.md +41 -0
- package/docs/HOSTED_PASSPORT_SETUP.md +362 -0
- package/docs/IMPLEMENTING_YOUR_OWN_EVALUATOR.md +433 -0
- package/docs/OPENCLAW_COMPATIBILITY.md +73 -0
- package/docs/OPENCLAW_LOCAL_INTEGRATION.md +596 -0
- package/docs/OPENCLAW_TOOLS_AND_POLICIES.md +54 -0
- package/docs/QUICKSTART.md +470 -0
- package/docs/QUICKSTART_OPENCLAW_PLUGIN.md +470 -0
- package/docs/README.md +28 -0
- package/docs/RELEASE.md +87 -0
- package/docs/REPO_LAYOUT.md +47 -0
- package/docs/SKILLS_ECOSYSTEM_ANALYSIS_FEB17.md +1260 -0
- package/docs/TOOL_POLICY_MAPPING.md +46 -0
- package/docs/UPGRADE.md +46 -0
- package/docs/VERIFICATION_METHODS.md +97 -0
- package/docs/assets/README.md +8 -0
- package/docs/assets/porter.svg +54 -0
- package/docs/development/ERROR_CODES.md +616 -0
- package/docs/frameworks/GITHUB_ISSUE_PROPOSALS.md +1105 -0
- package/docs/frameworks/crewai.md +114 -0
- package/docs/frameworks/cursor.md +159 -0
- package/docs/frameworks/langchain.md +72 -0
- package/docs/frameworks/n8n.md +40 -0
- package/docs/frameworks/openclaw.md +40 -0
- package/docs/launch/ADD_APORT_AWESOME_LISTS_INSTRUCTIONS.md +146 -0
- package/docs/launch/ANNOUNCEMENT_GUIDE.md +266 -0
- package/docs/launch/AWESOME_REPOS.md +53 -0
- package/docs/launch/CURSOR_VSCODE_HOOKS_RESEARCH.md +77 -0
- package/docs/launch/DEMO_TERMINAL_OUTPUT.txt +48 -0
- package/docs/launch/DRY_AND_PLAN_CHECKLIST.md +47 -0
- package/docs/launch/EVIDENCE_README.md +61 -0
- package/docs/launch/EVIDENCE_TERMINAL_CAPTURE.txt +10 -0
- package/docs/launch/FRAMEWORK_SUPPORT_PLAN.md +1640 -0
- package/docs/launch/LAUNCH_READINESS_CHECKLIST.md +237 -0
- package/docs/launch/LAUNCH_STRATEGY_SUMMARY.md +464 -0
- package/docs/launch/OPENCLAW_FEEDBACK_AND_FIXES.md +85 -0
- package/docs/launch/POST_1_VALENTINE_IMPROVED.md +233 -0
- package/docs/launch/POST_2_GUARDRAIL_IMPROVED.md +369 -0
- package/docs/launch/PRE_LAUNCH_FIXES.md +766 -0
- package/docs/launch/QUICK_LAUNCH_CHECKLIST.md +400 -0
- package/docs/launch/READINESS_SUMMARY.md +262 -0
- package/docs/launch/README.md +68 -0
- package/docs/launch/USER_STORIES.md +327 -0
- package/docs/launch/scripts/add-aport-awesome-pr.sh +69 -0
- package/docs/operations/MONITORING.md +588 -0
- package/docs/reviews/2026-02-18-staff-review.md +268 -0
- package/extensions/openclaw-aport/README.md +415 -0
- package/extensions/openclaw-aport/index.js +625 -0
- package/extensions/openclaw-aport/openclaw-aport.js +7 -0
- package/extensions/openclaw-aport/openclaw.plugin.json +46 -0
- package/extensions/openclaw-aport/package.json +36 -0
- package/extensions/openclaw-aport/test.js +307 -0
- package/external/aport-policies/README.md +363 -0
- package/external/aport-policies/agent.session.create.v1/README.md +345 -0
- package/external/aport-policies/agent.session.create.v1/policy.json +162 -0
- package/external/aport-policies/agent.tool.register.v1/README.md +361 -0
- package/external/aport-policies/agent.tool.register.v1/policy.json +172 -0
- package/external/aport-policies/code.release.publish.v1/README.md +51 -0
- package/external/aport-policies/code.release.publish.v1/policy.json +121 -0
- package/external/aport-policies/code.repository.merge.v1/README.md +287 -0
- package/external/aport-policies/code.repository.merge.v1/express.example.js +332 -0
- package/external/aport-policies/code.repository.merge.v1/fastapi.example.py +370 -0
- package/external/aport-policies/code.repository.merge.v1/policy.json +162 -0
- package/external/aport-policies/data.export.create.v1/README.md +226 -0
- package/external/aport-policies/data.export.create.v1/express.example.js +172 -0
- package/external/aport-policies/data.export.create.v1/fastapi.example.py +165 -0
- package/external/aport-policies/data.export.create.v1/policy.json +133 -0
- package/external/aport-policies/data.report.ingest.v1/README.md +134 -0
- package/external/aport-policies/data.report.ingest.v1/express.example.js +105 -0
- package/external/aport-policies/data.report.ingest.v1/minimal-example.js +68 -0
- package/external/aport-policies/data.report.ingest.v1/policy.json +174 -0
- package/external/aport-policies/finance.crypto.trade.v1/README.md +146 -0
- package/external/aport-policies/finance.crypto.trade.v1/express.example.js +109 -0
- package/external/aport-policies/finance.crypto.trade.v1/minimal-example.js +65 -0
- package/external/aport-policies/finance.crypto.trade.v1/policy.json +176 -0
- package/external/aport-policies/finance.payment.charge.v1/README.md +326 -0
- package/external/aport-policies/finance.payment.charge.v1/express.example.js +250 -0
- package/external/aport-policies/finance.payment.charge.v1/fastapi.example.py +227 -0
- package/external/aport-policies/finance.payment.charge.v1/minimal-example.js +64 -0
- package/external/aport-policies/finance.payment.charge.v1/policy.json +224 -0
- package/external/aport-policies/finance.payment.charge.v1/tests/contexts.jsonl +12 -0
- package/external/aport-policies/finance.payment.charge.v1/tests/expected.jsonl +12 -0
- package/external/aport-policies/finance.payment.charge.v1/tests/passport.instance.json +42 -0
- package/external/aport-policies/finance.payment.charge.v1/tests/passport.template.json +40 -0
- package/external/aport-policies/finance.payment.charge.v1/tests/payments-charge-policy.test.js +817 -0
- package/external/aport-policies/finance.payment.charge.v1/tests/test_payments_charge_policy.py +486 -0
- package/external/aport-policies/finance.payment.payout.v1/README.md +78 -0
- package/external/aport-policies/finance.payment.payout.v1/policy.json +181 -0
- package/external/aport-policies/finance.payment.refund.v1/README.md +275 -0
- package/external/aport-policies/finance.payment.refund.v1/express.example.js +167 -0
- package/external/aport-policies/finance.payment.refund.v1/fastapi.example.py +136 -0
- package/external/aport-policies/finance.payment.refund.v1/minimal-example.js +183 -0
- package/external/aport-policies/finance.payment.refund.v1/policy.json +216 -0
- package/external/aport-policies/finance.payment.refund.v1/tests/refunds-policy.test.js +924 -0
- package/external/aport-policies/finance.payment.refund.v1/tests/test_refunds_policy.py +778 -0
- package/external/aport-policies/finance.transaction.execute.v1/README.md +309 -0
- package/external/aport-policies/finance.transaction.execute.v1/express.example.js +261 -0
- package/external/aport-policies/finance.transaction.execute.v1/fastapi.example.py +231 -0
- package/external/aport-policies/finance.transaction.execute.v1/minimal-example.js +78 -0
- package/external/aport-policies/finance.transaction.execute.v1/policy.json +189 -0
- package/external/aport-policies/finance.transaction.execute.v1/tests/contexts.jsonl +12 -0
- package/external/aport-policies/finance.transaction.execute.v1/tests/expected.jsonl +12 -0
- package/external/aport-policies/finance.transaction.execute.v1/tests/passport.instance.json +42 -0
- package/external/aport-policies/finance.transaction.execute.v1/tests/passport.template.json +42 -0
- package/external/aport-policies/finance.transaction.execute.v1/tests/test_transactions_policy.py +214 -0
- package/external/aport-policies/finance.transaction.execute.v1/tests/transactions-policy.test.js +306 -0
- package/external/aport-policies/governance.data.access.v1/README.md +292 -0
- package/external/aport-policies/governance.data.access.v1/express.example.js +321 -0
- package/external/aport-policies/governance.data.access.v1/fastapi.example.py +279 -0
- package/external/aport-policies/governance.data.access.v1/minimal-example.js +65 -0
- package/external/aport-policies/governance.data.access.v1/policy.json +208 -0
- package/external/aport-policies/governance.data.access.v1/tests/contexts.jsonl +12 -0
- package/external/aport-policies/governance.data.access.v1/tests/data-access-policy.test.js +308 -0
- package/external/aport-policies/governance.data.access.v1/tests/expected.jsonl +12 -0
- package/external/aport-policies/governance.data.access.v1/tests/passport.instance.json +56 -0
- package/external/aport-policies/governance.data.access.v1/tests/passport.template.json +56 -0
- package/external/aport-policies/governance.data.access.v1/tests/test_data_access_policy.py +214 -0
- package/external/aport-policies/legal.contract.review.v1/README.md +109 -0
- package/external/aport-policies/legal.contract.review.v1/policy.json +378 -0
- package/external/aport-policies/legal.contract.review.v1/tests/legal-contract-review-policy.test.js +609 -0
- package/external/aport-policies/legal.contract.review.v1/tests/passport.template.json +49 -0
- package/external/aport-policies/mcp.tool.execute.v1/README.md +301 -0
- package/external/aport-policies/mcp.tool.execute.v1/policy.json +141 -0
- package/external/aport-policies/messaging.message.send.v1/README.md +230 -0
- package/external/aport-policies/messaging.message.send.v1/express.example.js +183 -0
- package/external/aport-policies/messaging.message.send.v1/fastapi.example.py +193 -0
- package/external/aport-policies/messaging.message.send.v1/policy.json +144 -0
- package/external/aport-policies/policy-template.json +107 -0
- package/external/aport-policies/system.command.execute.v1/README.md +275 -0
- package/external/aport-policies/system.command.execute.v1/policy.json +146 -0
- package/external/aport-spec/CONTRIBUTING.md +273 -0
- package/external/aport-spec/LICENSE +21 -0
- package/external/aport-spec/README.md +168 -0
- package/external/aport-spec/conformance/README.md +294 -0
- package/external/aport-spec/conformance/cases/data.export.v1/contexts/allow_users.json +6 -0
- package/external/aport-spec/conformance/cases/data.export.v1/contexts/deny_pii.json +6 -0
- package/external/aport-spec/conformance/cases/data.export.v1/expected/allow_users.decision.json +19 -0
- package/external/aport-spec/conformance/cases/data.export.v1/expected/deny_pii.decision.json +19 -0
- package/external/aport-spec/conformance/cases/data.export.v1/passports/template.json +29 -0
- package/external/aport-spec/conformance/cases/payments.refunds.v1/contexts/allow_50usd.json +9 -0
- package/external/aport-spec/conformance/cases/payments.refunds.v1/contexts/deny_150usd.json +9 -0
- package/external/aport-spec/conformance/cases/payments.refunds.v1/contexts/deny_currency.json +9 -0
- package/external/aport-spec/conformance/cases/payments.refunds.v1/expected/allow_50usd.decision.json +19 -0
- package/external/aport-spec/conformance/cases/payments.refunds.v1/expected/deny_150usd.decision.json +19 -0
- package/external/aport-spec/conformance/cases/payments.refunds.v1/expected/deny_currency.decision.json +19 -0
- package/external/aport-spec/conformance/cases/payments.refunds.v1/passports/template.json +42 -0
- package/external/aport-spec/conformance/package.json +44 -0
- package/external/aport-spec/conformance/pnpm-lock.yaml +642 -0
- package/external/aport-spec/conformance/src/cases.ts +371 -0
- package/external/aport-spec/conformance/src/ed25519.ts +167 -0
- package/external/aport-spec/conformance/src/jcs.ts +85 -0
- package/external/aport-spec/conformance/src/runner.ts +533 -0
- package/external/aport-spec/conformance/src/validators.ts +185 -0
- package/external/aport-spec/conformance/test-runner.js +315 -0
- package/external/aport-spec/conformance/tsconfig.json +21 -0
- package/external/aport-spec/error-schema.json +192 -0
- package/external/aport-spec/index.json +12 -0
- package/external/aport-spec/integrations/clawmoat/README.md +12 -0
- package/external/aport-spec/integrations/shield/README.md +245 -0
- package/external/aport-spec/integrations/shield/adapters/index.js +116 -0
- package/external/aport-spec/integrations/shield/adapters/system-command-execute.js +133 -0
- package/external/aport-spec/integrations/shield/test/README.md +58 -0
- package/external/aport-spec/integrations/shield/test/shield.md +40 -0
- package/external/aport-spec/integrations/shield/test/test-shield-to-verify.js +274 -0
- package/external/aport-spec/metrics-schema.json +504 -0
- package/external/aport-spec/oap/CHANGELOG.md +54 -0
- package/external/aport-spec/oap/VERSION.md +40 -0
- package/external/aport-spec/oap/capability-registry.md +229 -0
- package/external/aport-spec/oap/conformance.md +257 -0
- package/external/aport-spec/oap/decision-schema.json +114 -0
- package/external/aport-spec/oap/examples/context.refund.usd.50.json +9 -0
- package/external/aport-spec/oap/examples/decision.allow.sample.json +20 -0
- package/external/aport-spec/oap/examples/decision.deny.sample.json +23 -0
- package/external/aport-spec/oap/examples/passport.instance.v1.json +50 -0
- package/external/aport-spec/oap/examples/passport.template.v1.json +71 -0
- package/external/aport-spec/oap/oap-spec.md +426 -0
- package/external/aport-spec/oap/passport-schema.json +396 -0
- package/external/aport-spec/oap/security.md +213 -0
- package/external/aport-spec/oap/vc/context-oap-v1.jsonld +137 -0
- package/external/aport-spec/oap/vc/examples/oap-decision-vc.json +37 -0
- package/external/aport-spec/oap/vc/examples/oap-passport-vc.json +68 -0
- package/external/aport-spec/oap/vc/tools/INTEGRATION.md +375 -0
- package/external/aport-spec/oap/vc/tools/README.md +278 -0
- package/external/aport-spec/oap/vc/tools/examples/decision-to-vc.js +66 -0
- package/external/aport-spec/oap/vc/tools/examples/passport-to-vc.js +83 -0
- package/external/aport-spec/oap/vc/tools/examples/vc-to-decision.js +77 -0
- package/external/aport-spec/oap/vc/tools/examples/vc-to-passport.js +94 -0
- package/external/aport-spec/oap/vc/tools/package.json +38 -0
- package/external/aport-spec/oap/vc/tools/pnpm-lock.yaml +472 -0
- package/external/aport-spec/oap/vc/tools/src/cli.ts +226 -0
- package/external/aport-spec/oap/vc/tools/src/crypto-utils.ts +427 -0
- package/external/aport-spec/oap/vc/tools/src/index.ts +653 -0
- package/external/aport-spec/oap/vc/tools/src/test.ts +148 -0
- package/external/aport-spec/oap/vc/tools/src/vp.ts +382 -0
- package/external/aport-spec/oap/vc/tools/test-simple.js +214 -0
- package/external/aport-spec/oap/vc/tools/tsconfig.json +19 -0
- package/external/aport-spec/oap/vc/vc-mapping.md +443 -0
- package/external/aport-spec/passport-schema.json +586 -0
- package/external/aport-spec/rate-limiting.md +136 -0
- package/external/aport-spec/transport-profile.md +325 -0
- package/external/aport-spec/webhook-spec.md +314 -0
- package/package.json +70 -0
- package/skills/aport-agent-guardrail/SKILL.md +314 -0
- package/src/evaluator.js +252 -0
- 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,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
|
+
}
|