@aporthq/aport-agent-guardrails 1.0.12 → 1.0.14

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/README.md CHANGED
@@ -86,12 +86,13 @@ The **kill switch suspends the agent**: once active, every tool call is denied u
86
86
 
87
87
  **Two ways to use APort:** (1) **Guardrails (CLI/setup)** — run the installer to create your passport and config; (2) **Core (library)** — use the evaluator or framework callback in your app so each tool call is verified. Each framework doc ([LangChain](docs/frameworks/langchain.md), [CrewAI](docs/frameworks/crewai.md), [Cursor](docs/frameworks/cursor.md), [OpenClaw](docs/frameworks/openclaw.md)) describes both and how to use them for that framework (Python and Node where applicable).
88
88
 
89
- **Production-ready today:** OpenClaw (plugin + full installer), Cursor (hooks), **Python** LangChain/CrewAI (`pip install aport-agent-guardrails-langchain` / `aport-agent-guardrails-crewai`), and **Node** (CLI + `@aporthq/aport-agent-guardrails-core`, `-langchain`, `-crewai`, `-cursor` published via CI). See [Deployment readiness](docs/DEPLOYMENT_READINESS.md).
89
+ **Production-ready today:** OpenClaw (plugin + full installer), Cursor (hooks), **Claude Code** (PreToolUse hook), **Python** LangChain/CrewAI (`pip install aport-agent-guardrails-langchain` / `aport-agent-guardrails-crewai`), and **Node** (CLI + `@aporthq/aport-agent-guardrails-core`, `-langchain`, `-crewai`, `-cursor`, `-claude-code` published via CI). See [Deployment readiness](docs/DEPLOYMENT_READINESS.md).
90
90
 
91
91
  | Framework | Doc | Integration | Install |
92
92
  |-----------|-----|--------------|--------|
93
93
  | **OpenClaw** | [docs/frameworks/openclaw.md](docs/frameworks/openclaw.md) | `before_tool_call` plugin | `npx @aporthq/aport-agent-guardrails openclaw` |
94
94
  | **Cursor** | [docs/frameworks/cursor.md](docs/frameworks/cursor.md) | `beforeShellExecution` / `preToolUse` hooks → writes `~/.cursor/hooks.json`. **Runtime enforcement is the bash hook;** the Node package `@aporthq/aport-agent-guardrails-cursor` is a helper only (Evaluator, `getHookPath()`). | `npx @aporthq/aport-agent-guardrails cursor` |
95
+ | **Claude Code** | [docs/frameworks/claude-code.md](docs/frameworks/claude-code.md) | PreToolUse hook → writes `~/.claude/settings.json` (Claude Code format; not Cursor). | `npx @aporthq/aport-agent-guardrails claude-code` |
95
96
  | **LangChain / LangGraph** | [docs/frameworks/langchain.md](docs/frameworks/langchain.md) | **Python:** `APortCallback` (`on_tool_start`) | `npx @aporthq/aport-agent-guardrails langchain` then `pip install aport-agent-guardrails-langchain` + `aport-langchain setup` |
96
97
  | **CrewAI** | [docs/frameworks/crewai.md](docs/frameworks/crewai.md) | **Python:** `@before_tool_call` hook, `register_aport_guardrail` | `npx @aporthq/aport-agent-guardrails crewai` then `pip install aport-agent-guardrails-crewai` + `aport-crewai setup` |
97
98
  | **n8n** | [docs/frameworks/n8n.md](docs/frameworks/n8n.md) | *Coming soon* — custom node and runtime in progress | — |
@@ -6,7 +6,7 @@
6
6
  # agent-guardrails openclaw ap_xxx # openclaw with agent_id (pass-through)
7
7
  # agent-guardrails --framework=langchain
8
8
  # agent-guardrails -f crewai
9
- # Supported: openclaw | langchain | crewai | cursor (n8n coming soon)
9
+ # Supported: openclaw | langchain | crewai | cursor | claude-code (n8n coming soon)
10
10
  #
11
11
  # Backward compatibility: All arguments after the framework are passed through to
12
12
  # the framework script. For OpenClaw, bin/openclaw receives them (e.g. agent_id).
@@ -72,14 +72,14 @@ if [[ -z "$framework" ]]; then
72
72
  noninteractive="${APORT_NONINTERACTIVE:-${CI:-}}"
73
73
  if [[ -n "$noninteractive" ]]; then
74
74
  echo "[aport] ERROR: Multiple frameworks detected: $detected_list" >&2
75
- echo " Set APORT_FRAMEWORK or use --framework=openclaw | langchain | crewai | cursor" >&2
75
+ echo " Set APORT_FRAMEWORK or use --framework=openclaw | langchain | crewai | cursor | claude-code" >&2
76
76
  exit 1
77
77
  fi
78
78
  echo ""
79
79
  echo " APort Agent Guardrails — Framework setup"
80
80
  echo " ─────────────────────────────────────────"
81
81
  echo " Multiple frameworks detected: $detected_list"
82
- echo " Choose one: openclaw | langchain | crewai | cursor"
82
+ echo " Choose one: openclaw | langchain | crewai | cursor | claude-code"
83
83
  echo " Example: npx @aporthq/agent-guardrails --framework=openclaw"
84
84
  echo ""
85
85
  read -p " Framework [${framework:-openclaw}]: " choice
@@ -95,14 +95,14 @@ if [[ -z "$framework" ]]; then
95
95
  noninteractive="${APORT_NONINTERACTIVE:-${CI:-}}"
96
96
  if [[ -n "$noninteractive" ]]; then
97
97
  echo "[aport] ERROR: No framework detected in current directory." >&2
98
- echo " Set APORT_FRAMEWORK or use --framework=openclaw | langchain | crewai | cursor" >&2
98
+ echo " Set APORT_FRAMEWORK or use --framework=openclaw | langchain | crewai | cursor | claude-code" >&2
99
99
  exit 1
100
100
  fi
101
101
  echo ""
102
102
  echo " APort Agent Guardrails — Framework setup"
103
103
  echo " ─────────────────────────────────────────"
104
104
  echo " No framework detected in current directory."
105
- echo " Choose: openclaw | langchain | crewai | cursor"
105
+ echo " Choose: openclaw | langchain | crewai | cursor | claude-code"
106
106
  echo " Example: npx @aporthq/agent-guardrails openclaw"
107
107
  echo " npx @aporthq/agent-guardrails --framework=langchain"
108
108
  echo ""
@@ -111,6 +111,13 @@ if [[ -z "$framework" ]]; then
111
111
  fi
112
112
 
113
113
  framework="$(echo "$framework" | tr '[:upper:]' '[:lower:]')"
114
+
115
+ # SECURITY: Validate framework name (alphanumeric + hyphen only, prevents path traversal)
116
+ if [[ ! "$framework" =~ ^[a-z0-9-]+$ ]]; then
117
+ echo "[aport] ERROR: Invalid framework name: $framework (alphanumeric and hyphens only)" >&2
118
+ exit 1
119
+ fi
120
+
114
121
  script="$FRAMEWORKS_DIR/${framework}.sh"
115
122
 
116
123
  # n8n: custom node not yet available; warn before running wizard-only script
@@ -129,5 +136,5 @@ if [[ "$framework" == "openclaw" ]]; then
129
136
  fi
130
137
 
131
138
  echo "[aport] ERROR: Unknown or unsupported framework: $framework" >&2
132
- echo " Supported: openclaw, langchain, crewai, cursor (n8n coming soon)" >&2
139
+ echo " Supported: openclaw, langchain, crewai, cursor, claude-code (n8n coming soon)" >&2
133
140
  exit 1
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env bash
2
+ # APort Claude Code hook: reads tool_name + tool_input from JSON stdin,
3
+ # maps to APort policy, calls guardrail, outputs hookSpecificOutput deny or exit 0.
4
+ # Exit 0 = allow, exit 2 = block. Other exits = hook error (Claude Code may fail-open).
5
+ # Output format: Claude Code official schema (hookSpecificOutput.permissionDecision), NOT Cursor format.
6
+
7
+ set -e
8
+
9
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
10
+ ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
11
+ GUARDRAIL="$ROOT_DIR/bin/aport-guardrail-bash.sh"
12
+
13
+ # Path resolver: probes ~/.claude, ~/.cursor, ~/.openclaw, etc.
14
+ # shellcheck source=bin/aport-resolve-paths.sh
15
+ . "$ROOT_DIR/bin/aport-resolve-paths.sh"
16
+
17
+ # Read stdin
18
+ INPUT=""
19
+ if [ -t 0 ]; then
20
+ INPUT='{}'
21
+ else
22
+ INPUT="$(cat)"
23
+ fi
24
+
25
+ # No input = allow (fail-open for bad input)
26
+ [ -z "$INPUT" ] && exit 0
27
+
28
+ # Parse tool_name and tool_input (requires jq)
29
+ if ! command -v jq &> /dev/null; then
30
+ echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"🛡️ APort: jq is required"}}'
31
+ exit 2
32
+ fi
33
+
34
+ # Parse with error handling: jq failure must exit 2 (deny), never undefined exit codes
35
+ set +e
36
+ TOOL_NAME="$(echo "$INPUT" | jq -r '.tool_name // "unknown"' 2> /dev/null)"
37
+ JQ_EXIT=$?
38
+ set -e
39
+ if [ "$JQ_EXIT" -ne 0 ] || [ -z "$TOOL_NAME" ]; then
40
+ TOOL_NAME="unknown"
41
+ fi
42
+ set +e
43
+ TOOL_INPUT="$(echo "$INPUT" | jq -c '.tool_input // {}' 2> /dev/null)"
44
+ JQ_EXIT=$?
45
+ set -e
46
+ if [ "$JQ_EXIT" -ne 0 ] || [ -z "$TOOL_INPUT" ]; then
47
+ TOOL_INPUT='{}'
48
+ fi
49
+
50
+ # Safe jq extraction: returns '{}' on any jq error instead of crashing under set -e
51
+ safe_jq() {
52
+ local input="$1" filter="$2"
53
+ local result
54
+ result="$(echo "$input" | jq -c "$filter" 2> /dev/null)" || result='{}'
55
+ [ -z "$result" ] && result='{}'
56
+ echo "$result"
57
+ }
58
+
59
+ # Deny helper: outputs Claude Code hookSpecificOutput JSON and exits 2
60
+ deny() {
61
+ local reason="$1"
62
+ jq -n --arg reason "$reason" \
63
+ --arg event "PreToolUse" \
64
+ '{hookSpecificOutput:{hookEventName:$event,permissionDecision:"deny",permissionDecisionReason:$reason}}'
65
+ exit 2
66
+ }
67
+
68
+ # Tool name passed to guardrail (must match aport-guardrail-bash.sh case patterns)
69
+ GUARDRAIL_TOOL=""
70
+ CONTEXT_JSON="{}"
71
+
72
+ case "$TOOL_NAME" in
73
+ Bash)
74
+ GUARDRAIL_TOOL="bash"
75
+ CONTEXT_JSON="$(safe_jq "$TOOL_INPUT" '{command: (.command // "")}')"
76
+ ;;
77
+ Read | Glob | LS | Grep | TodoRead | ToolSearch | AskUserQuestion)
78
+ # Read-family + user-interaction tools: allow without calling evaluator
79
+ exit 0
80
+ ;;
81
+ TaskGet | TaskList | TaskOutput | CronList)
82
+ # Read-only task/cron queries: allow without evaluator
83
+ exit 0
84
+ ;;
85
+ EnterPlanMode | ExitPlanMode)
86
+ # Internal state transitions: allow without evaluator
87
+ exit 0
88
+ ;;
89
+ Write | Edit | MultiEdit | NotebookEdit | TodoWrite)
90
+ GUARDRAIL_TOOL="write"
91
+ CONTEXT_JSON="$(safe_jq "$TOOL_INPUT" '{file_path: (.file_path // .path // "")}')"
92
+ ;;
93
+ WebSearch | WebFetch)
94
+ GUARDRAIL_TOOL="webfetch"
95
+ CONTEXT_JSON="$(safe_jq "$TOOL_INPUT" '{url: (.url // .query // "")}')"
96
+ ;;
97
+ Browser)
98
+ GUARDRAIL_TOOL="browser"
99
+ CONTEXT_JSON="$(safe_jq "$TOOL_INPUT" '{url: (.url // "")}')"
100
+ ;;
101
+ Task | TaskCreate | TaskUpdate | TaskStop | Agent | Skill | EnterWorktree)
102
+ GUARDRAIL_TOOL="session.create"
103
+ CONTEXT_JSON="$(safe_jq "$TOOL_INPUT" '{description: (.description // .prompt // "")}')"
104
+ ;;
105
+ CronCreate | CronDelete)
106
+ GUARDRAIL_TOOL="cron"
107
+ CONTEXT_JSON="$(safe_jq "$TOOL_INPUT" '{description: (.description // .schedule // "")}')"
108
+ ;;
109
+ mcp__*)
110
+ GUARDRAIL_TOOL="mcp.tool"
111
+ CONTEXT_JSON="$TOOL_INPUT"
112
+ ;;
113
+ unknown | *)
114
+ # Unknown tool: fail-closed (deny)
115
+ deny "🛡️ APort: unknown tool '$TOOL_NAME' — fail-closed policy"
116
+ ;;
117
+ esac
118
+
119
+ # Use a per-invocation decision file to avoid race conditions with concurrent tool calls
120
+ HOOK_DECISION_FILE="${OPENCLAW_DECISION_FILE:-}"
121
+ if [ -n "$HOOK_DECISION_FILE" ]; then
122
+ HOOK_DECISION_FILE="${HOOK_DECISION_FILE%.json}-$$.json"
123
+ export OPENCLAW_DECISION_FILE="$HOOK_DECISION_FILE"
124
+ fi
125
+
126
+ # Call core evaluator (guardrail expects tool name, not policy ID)
127
+ set +e
128
+ "$GUARDRAIL" "$GUARDRAIL_TOOL" "$CONTEXT_JSON" 2> /dev/null
129
+ GUARDRAIL_EXIT=$?
130
+ set -e
131
+
132
+ # Clean up per-invocation decision file on exit
133
+ cleanup_decision() { [ -n "$HOOK_DECISION_FILE" ] && rm -f "$HOOK_DECISION_FILE" 2> /dev/null; }
134
+
135
+ if [ "$GUARDRAIL_EXIT" -eq 0 ]; then
136
+ cleanup_decision
137
+ exit 0
138
+ fi
139
+
140
+ # Deny: read reason from decision file
141
+ REASON="Policy denied this action."
142
+ if [ -n "$HOOK_DECISION_FILE" ] && [ -f "$HOOK_DECISION_FILE" ] && command -v jq &> /dev/null; then
143
+ R="$(jq -r '.reasons[0].message // empty' "$HOOK_DECISION_FILE" 2> /dev/null)"
144
+ [ -n "$R" ] && REASON="$R"
145
+ fi
146
+ cleanup_decision
147
+ deny "🛡️ APort: $REASON"
@@ -109,6 +109,64 @@ DEFAULT_AGENT_NAME=${DEFAULT_AGENT_NAME:-"OpenClaw Agent"}
109
109
  DEFAULT_AGENT_DESC=$(get_identity_description) || true
110
110
  DEFAULT_AGENT_DESC=${DEFAULT_AGENT_DESC:-"Local OpenClaw AI agent with APort guardrails"}
111
111
 
112
+ # Framework-aware defaults: each framework needs different capabilities to function
113
+ case "${APORT_FRAMEWORK:-}" in
114
+ claude-code)
115
+ # Claude Code: file ops, web, sub-agents, MCP tools are core functionality
116
+ DEFAULT_FILE_READ=y
117
+ DEFAULT_FILE_WRITE=y
118
+ DEFAULT_WEB_FETCH=y
119
+ DEFAULT_WEB_BROWSER=y
120
+ DEFAULT_AGENT_SESSION=y
121
+ DEFAULT_MCP_TOOL=y
122
+ ;;
123
+ cursor)
124
+ # Cursor: IDE with shell, file ops, web search, agent mode
125
+ DEFAULT_FILE_READ=y
126
+ DEFAULT_FILE_WRITE=y
127
+ DEFAULT_WEB_FETCH=y
128
+ DEFAULT_WEB_BROWSER=n
129
+ DEFAULT_AGENT_SESSION=y
130
+ DEFAULT_MCP_TOOL=n
131
+ ;;
132
+ crewai)
133
+ # CrewAI: multi-agent framework — agents spawn sub-agents, use tools
134
+ DEFAULT_FILE_READ=y
135
+ DEFAULT_FILE_WRITE=y
136
+ DEFAULT_WEB_FETCH=y
137
+ DEFAULT_WEB_BROWSER=n
138
+ DEFAULT_AGENT_SESSION=y
139
+ DEFAULT_MCP_TOOL=y
140
+ ;;
141
+ langchain)
142
+ # LangChain/LangGraph: tool calling, web, files
143
+ DEFAULT_FILE_READ=y
144
+ DEFAULT_FILE_WRITE=y
145
+ DEFAULT_WEB_FETCH=y
146
+ DEFAULT_WEB_BROWSER=n
147
+ DEFAULT_AGENT_SESSION=n
148
+ DEFAULT_MCP_TOOL=n
149
+ ;;
150
+ n8n)
151
+ # n8n: workflow automation — HTTP, file, database, messaging
152
+ DEFAULT_FILE_READ=y
153
+ DEFAULT_FILE_WRITE=y
154
+ DEFAULT_WEB_FETCH=y
155
+ DEFAULT_WEB_BROWSER=n
156
+ DEFAULT_AGENT_SESSION=n
157
+ DEFAULT_MCP_TOOL=n
158
+ ;;
159
+ *)
160
+ # Generic / unknown framework: conservative defaults
161
+ DEFAULT_FILE_READ=n
162
+ DEFAULT_FILE_WRITE=n
163
+ DEFAULT_WEB_FETCH=n
164
+ DEFAULT_WEB_BROWSER=n
165
+ DEFAULT_AGENT_SESSION=n
166
+ DEFAULT_MCP_TOOL=n
167
+ ;;
168
+ esac
169
+
112
170
  if [ -n "$NON_INTERACTIVE" ]; then
113
171
  # CI/tests: use defaults, no prompts. Use --output or APORT_FRAMEWORK for default path. Match interactive defaults (README: messaging out of the box).
114
172
  owner_id="$DEFAULT_EMAIL"
@@ -119,10 +177,12 @@ if [ -n "$NON_INTERACTIVE" ]; then
119
177
  exec_cap=y
120
178
  msg_cap=y
121
179
  data_cap=n
122
- file_read_cap=n
123
- file_write_cap=n
124
- web_fetch_cap=n
125
- web_browser_cap=n
180
+ file_read_cap=$DEFAULT_FILE_READ
181
+ file_write_cap=$DEFAULT_FILE_WRITE
182
+ web_fetch_cap=$DEFAULT_WEB_FETCH
183
+ web_browser_cap=$DEFAULT_WEB_BROWSER
184
+ agent_session_cap=$DEFAULT_AGENT_SESSION
185
+ mcp_tool_cap=$DEFAULT_MCP_TOOL
126
186
  max_pr_size=500
127
187
  max_prs_per_day=10
128
188
  max_msgs_per_day=100
@@ -195,7 +255,11 @@ else
195
255
  # Choose capabilities
196
256
  echo " 🔐 Capabilities"
197
257
  echo " ───────────────"
198
- echo " Choose what your agent can do (y/n). Defaults: PRs, exec, and messaging = yes (matches README/docs); others = no."
258
+ if [ "${APORT_FRAMEWORK:-}" = "claude-code" ]; then
259
+ echo " Choose what your agent can do (y/n). Claude Code defaults: most capabilities = yes."
260
+ else
261
+ echo " Choose what your agent can do (y/n). Defaults: PRs, exec, and messaging = yes (matches README/docs); others = no."
262
+ fi
199
263
  echo ""
200
264
  read -p " • Create and merge pull requests? [Y/n]: " pr_cap
201
265
  pr_cap=${pr_cap:-y}
@@ -206,21 +270,51 @@ else
206
270
  read -p " • Send messages (email, SMS, etc.)? [Y/n]: " msg_cap
207
271
  msg_cap=${msg_cap:-y}
208
272
 
209
- read -p " Read files from disk? [y/N]: " file_read_cap
210
- file_read_cap=${file_read_cap:-n}
273
+ if [ "$DEFAULT_FILE_READ" = "y" ]; then
274
+ read -p " • Read files from disk? [Y/n]: " file_read_cap
275
+ else
276
+ read -p " • Read files from disk? [y/N]: " file_read_cap
277
+ fi
278
+ file_read_cap=${file_read_cap:-$DEFAULT_FILE_READ}
211
279
 
212
- read -p " Write/edit files on disk? [y/N]: " file_write_cap
213
- file_write_cap=${file_write_cap:-n}
280
+ if [ "$DEFAULT_FILE_WRITE" = "y" ]; then
281
+ read -p " • Write/edit files on disk? [Y/n]: " file_write_cap
282
+ else
283
+ read -p " • Write/edit files on disk? [y/N]: " file_write_cap
284
+ fi
285
+ file_write_cap=${file_write_cap:-$DEFAULT_FILE_WRITE}
214
286
 
215
- read -p " Fetch data from web (HTTP requests)? [y/N]: " web_fetch_cap
216
- web_fetch_cap=${web_fetch_cap:-n}
287
+ if [ "$DEFAULT_WEB_FETCH" = "y" ]; then
288
+ read -p " • Fetch data from web (HTTP requests)? [Y/n]: " web_fetch_cap
289
+ else
290
+ read -p " • Fetch data from web (HTTP requests)? [y/N]: " web_fetch_cap
291
+ fi
292
+ web_fetch_cap=${web_fetch_cap:-$DEFAULT_WEB_FETCH}
217
293
 
218
- read -p " Automate web browser? [y/N]: " web_browser_cap
219
- web_browser_cap=${web_browser_cap:-n}
294
+ if [ "$DEFAULT_WEB_BROWSER" = "y" ]; then
295
+ read -p " • Automate web browser? [Y/n]: " web_browser_cap
296
+ else
297
+ read -p " • Automate web browser? [y/N]: " web_browser_cap
298
+ fi
299
+ web_browser_cap=${web_browser_cap:-$DEFAULT_WEB_BROWSER}
220
300
 
221
301
  read -p " • Export data (database, files, etc.)? [y/N]: " data_cap
222
302
  data_cap=${data_cap:-n}
223
303
 
304
+ if [ "$DEFAULT_AGENT_SESSION" = "y" ]; then
305
+ read -p " • Spawn sub-agents and tasks? [Y/n]: " agent_session_cap
306
+ else
307
+ read -p " • Spawn sub-agents and tasks? [y/N]: " agent_session_cap
308
+ fi
309
+ agent_session_cap=${agent_session_cap:-$DEFAULT_AGENT_SESSION}
310
+
311
+ if [ "$DEFAULT_MCP_TOOL" = "y" ]; then
312
+ read -p " • Use MCP tools (external integrations)? [Y/n]: " mcp_tool_cap
313
+ else
314
+ read -p " • Use MCP tools (external integrations)? [y/N]: " mcp_tool_cap
315
+ fi
316
+ mcp_tool_cap=${mcp_tool_cap:-$DEFAULT_MCP_TOOL}
317
+
224
318
  echo ""
225
319
 
226
320
  # Configure limits
@@ -351,6 +445,12 @@ fi
351
445
  if [ "$data_cap" = "y" ] || [ "$data_cap" = "Y" ]; then
352
446
  capabilities_json="$capabilities_json{\"id\": \"data.export\"},"
353
447
  fi
448
+ if [ "${agent_session_cap:-n}" = "y" ] || [ "${agent_session_cap:-n}" = "Y" ]; then
449
+ capabilities_json="$capabilities_json{\"id\": \"agent.session.create\"},"
450
+ fi
451
+ if [ "${mcp_tool_cap:-n}" = "y" ] || [ "${mcp_tool_cap:-n}" = "Y" ]; then
452
+ capabilities_json="$capabilities_json{\"id\": \"mcp.tool.execute\"},"
453
+ fi
354
454
  # Remove trailing comma
355
455
  capabilities_json="${capabilities_json%,}]"
356
456
 
@@ -438,6 +538,14 @@ if [ "$data_cap" = "y" ] || [ "$data_cap" = "Y" ]; then
438
538
  limits_json="$limits_json\"data.export\": {\"max_rows\": $max_export_rows, \"allow_pii\": $allow_pii_bool, \"allowed_collections\": [\"*\"]},"
439
539
  fi
440
540
 
541
+ if [ "${agent_session_cap:-n}" = "y" ] || [ "${agent_session_cap:-n}" = "Y" ]; then
542
+ limits_json="$limits_json\"agent.session.create\": {\"max_concurrent\": 10},"
543
+ fi
544
+
545
+ if [ "${mcp_tool_cap:-n}" = "y" ] || [ "${mcp_tool_cap:-n}" = "Y" ]; then
546
+ limits_json="$limits_json\"mcp.tool.execute\": {\"allowed_servers\": [\"*\"]},"
547
+ fi
548
+
441
549
  # Remove trailing comma
442
550
  limits_json="${limits_json%,}}"
443
551
 
@@ -503,6 +611,11 @@ else
503
611
  EOF
504
612
  fi
505
613
 
614
+ # Archive existing passport before overwrite
615
+ if [ -f "$PASSPORT_FILE" ]; then
616
+ cp "$PASSPORT_FILE" "${PASSPORT_FILE}.bak"
617
+ fi
618
+
506
619
  # Format JSON with jq if available
507
620
  if command -v jq &> /dev/null; then
508
621
  jq . "$PASSPORT_FILE.tmp" > "$PASSPORT_FILE"
@@ -535,7 +648,13 @@ echo " 🔐 Capabilities:"
535
648
  [ "$pr_cap" = "y" ] || [ "$pr_cap" = "Y" ] && echo " • Create and merge pull requests"
536
649
  [ "$exec_cap" = "y" ] || [ "$exec_cap" = "Y" ] && echo " • Execute system commands"
537
650
  [ "$msg_cap" = "y" ] || [ "$msg_cap" = "Y" ] && echo " • Send messages"
651
+ [ "$file_read_cap" = "y" ] || [ "$file_read_cap" = "Y" ] && echo " • Read files"
652
+ [ "$file_write_cap" = "y" ] || [ "$file_write_cap" = "Y" ] && echo " • Write/edit files"
653
+ [ "$web_fetch_cap" = "y" ] || [ "$web_fetch_cap" = "Y" ] && echo " • Fetch from web"
654
+ [ "$web_browser_cap" = "y" ] || [ "$web_browser_cap" = "Y" ] && echo " • Automate browser"
538
655
  [ "$data_cap" = "y" ] || [ "$data_cap" = "Y" ] && echo " • Export data"
656
+ [ "${agent_session_cap:-n}" = "y" ] || [ "${agent_session_cap:-n}" = "Y" ] && echo " • Spawn sub-agents and tasks"
657
+ [ "${mcp_tool_cap:-n}" = "y" ] || [ "${mcp_tool_cap:-n}" = "Y" ] && echo " • Use MCP tools"
539
658
  echo ""
540
659
  echo " 📝 Next steps:"
541
660
  echo " • Review limits: vim $PASSPORT_FILE"
@@ -1,9 +1,12 @@
1
1
  #!/usr/bin/env bash
2
- # APort Cursor/Copilot/Claude Code hook: read JSON from stdin, call guardrail, return allow/deny; exit 2 = block.
3
- # Compatible with Cursor (beforeShellExecution, preToolUse), VS Code Copilot, and Claude Code.
4
- # Input: JSON with "command" and/or "tool"/"name"/"input" (host-dependent). We map to system.command.execute.
5
- # Output: JSON with "permission": "allow"|"deny" (Cursor) and "allowed": true|false; optional "agentMessage"/"reason".
6
- # Exit: 0 = allow, 2 = block (deny). Other exits = hook error (host may proceed or fail-open).
2
+ # APort Cursor hook: reads JSON from stdin, maps tool to APort policy, calls guardrail.
3
+ # Handles all Cursor hook events: beforeShellExecution, preToolUse, beforeMCPExecution,
4
+ # beforeReadFile, subagentStart.
5
+ # Output: JSON with "permission": "allow"|"deny"; optional "agentMessage"/"user_message".
6
+ # Exit: 0 = allow, 2 = block (deny). Other exits = hook error (Cursor may fail-open).
7
+ #
8
+ # Cursor preToolUse tool_name values: Shell, Read, Write, Grep, Delete, Task, MCP:<name>
9
+ # See: https://cursor.com/docs/hooks
7
10
 
8
11
  set -e
9
12
 
@@ -11,14 +14,13 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
11
14
  ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
12
15
  GUARDRAIL="$ROOT_DIR/bin/aport-guardrail-bash.sh"
13
16
 
14
- # Passport/config: resolver probes ~/.cursor, ~/.openclaw, ~/.aport/langchain, etc. when OPENCLAW_CONFIG_DIR not set
17
+ # Passport/config: resolver probes ~/.cursor, ~/.openclaw, ~/.aport/*, etc.
15
18
  # shellcheck source=bin/aport-resolve-paths.sh
16
19
  . "$ROOT_DIR/bin/aport-resolve-paths.sh"
17
20
 
18
21
  # Read stdin (single JSON object; Cursor sends one payload per invocation)
19
22
  INPUT=""
20
23
  if [ -t 0 ]; then
21
- # No stdin (e.g. manual test): treat as allow to avoid blocking
22
24
  INPUT='{}'
23
25
  else
24
26
  INPUT="$(cat)"
@@ -30,61 +32,145 @@ if [ -z "$INPUT" ]; then
30
32
  exit 0
31
33
  fi
32
34
 
33
- # Parse and normalize to tool + context for guardrail
34
- # Cursor beforeShellExecution: { "command": "..." }
35
- # preToolUse / Copilot: { "tool": "runTerminalCommand", "input": { "command": "..." } } or similar
36
- TOOL_NAME="exec.run"
35
+ # Require jq for JSON parsing
36
+ if ! command -v jq &> /dev/null; then
37
+ echo '{"permission":"deny","allowed":false,"agentMessage":"APort: jq is required"}'
38
+ exit 2
39
+ fi
40
+
41
+ # Deny helper: outputs Cursor-format JSON and exits 2
42
+ deny() {
43
+ local reason="$1"
44
+ jq -n -c --arg reason "$reason" \
45
+ '{permission:"deny",allowed:false,agentMessage:$reason,reason:$reason}'
46
+ exit 2
47
+ }
48
+
49
+ # Safe jq extraction: returns '{}' on any jq error
50
+ safe_jq() {
51
+ local input="$1" filter="$2"
52
+ local result
53
+ result="$(echo "$input" | jq -c "$filter" 2> /dev/null)" || result='{}'
54
+ [ -z "$result" ] && result='{}'
55
+ echo "$result"
56
+ }
57
+
58
+ # Detect hook event type from input fields and route accordingly.
59
+ # Cursor sends different JSON shapes per hook event:
60
+ # beforeShellExecution: { "command": "...", "cwd": "..." }
61
+ # preToolUse: { "tool_name": "Shell|Read|Write|...", "tool_input": {...} }
62
+ # beforeMCPExecution: { "tool_name": "...", "tool_input": {...}, "server": "..." }
63
+ # beforeReadFile: { "file_path": "...", "content": "..." }
64
+ # subagentStart: { "subagent_id": "...", "subagent_type": "...", "task": "..." }
65
+ # We detect by checking for distinguishing fields.
66
+
67
+ GUARDRAIL_TOOL=""
37
68
  CONTEXT_JSON="{}"
38
- if command -v jq &> /dev/null; then
39
- CMD=$(echo "$INPUT" | jq -r '.command // .input.command // .input.cmd // .args[0] // ""')
40
- if [ -n "$CMD" ] && [ "$CMD" != "null" ]; then
41
- CONTEXT_JSON=$(echo "$INPUT" | jq -c '{command: (.command // .input.command // .input.cmd), args: (.args // .input.args // [])}' 2> /dev/null || echo "{}")
42
- if [ -z "$CONTEXT_JSON" ] || [ "$CONTEXT_JSON" = "null" ]; then
43
- CONTEXT_JSON=$(jq -n -c --arg cmd "$CMD" '{command: $cmd}')
44
- fi
45
- fi
46
- # If no command found, still pass through; guardrail may deny unknown
47
- if [ -z "$CONTEXT_JSON" ] || [ "$CONTEXT_JSON" = "null" ]; then
48
- CONTEXT_JSON="$INPUT"
49
- fi
69
+
70
+ # Check for hook_event_name first (newer Cursor versions include it)
71
+ HOOK_EVENT="$(echo "$INPUT" | jq -r '.hook_event_name // ""' 2> /dev/null)"
72
+ TOOL_NAME="$(echo "$INPUT" | jq -r '.tool_name // ""' 2> /dev/null)"
73
+
74
+ if [ "$HOOK_EVENT" = "beforeReadFile" ] || { [ -z "$HOOK_EVENT" ] && [ -z "$TOOL_NAME" ] && echo "$INPUT" | jq -e '.file_path and .content' &> /dev/null; }; then
75
+ # beforeReadFile: file reads — allow without evaluator (same as Claude Code)
76
+ exit 0
77
+
78
+ elif [ "$HOOK_EVENT" = "subagentStart" ] || { [ -z "$HOOK_EVENT" ] && echo "$INPUT" | jq -e '.subagent_id' &> /dev/null; }; then
79
+ # subagentStart: sub-agent spawning
80
+ GUARDRAIL_TOOL="session.create"
81
+ CONTEXT_JSON="$(safe_jq "$INPUT" '{description: (.task // ""), subagent_type: (.subagent_type // "")}')"
82
+
83
+ elif [ "$HOOK_EVENT" = "beforeMCPExecution" ] || { [ -n "$TOOL_NAME" ] && echo "$INPUT" | jq -e '.server // .url' &> /dev/null; }; then
84
+ # beforeMCPExecution: MCP tool calls (has server/url field)
85
+ GUARDRAIL_TOOL="mcp.tool"
86
+ CONTEXT_JSON="$(safe_jq "$INPUT" '{tool_name: (.tool_name // ""), tool_input: (.tool_input // {})}')"
87
+
88
+ elif [ -n "$TOOL_NAME" ]; then
89
+ # preToolUse: Cursor tool names — Shell, Read, Write, Grep, Delete, Task, MCP:<name>
90
+ case "$TOOL_NAME" in
91
+ Shell)
92
+ GUARDRAIL_TOOL="bash"
93
+ CONTEXT_JSON="$(safe_jq "$INPUT" '{command: (.tool_input.command // "")}')"
94
+ ;;
95
+ Read | Grep)
96
+ # Read-family: allow without calling evaluator
97
+ exit 0
98
+ ;;
99
+ Write)
100
+ GUARDRAIL_TOOL="write"
101
+ CONTEXT_JSON="$(safe_jq "$INPUT" '{file_path: (.tool_input.file_path // .tool_input.path // "")}')"
102
+ ;;
103
+ Delete)
104
+ GUARDRAIL_TOOL="write"
105
+ CONTEXT_JSON="$(safe_jq "$INPUT" '{file_path: (.tool_input.file_path // .tool_input.path // "")}')"
106
+ ;;
107
+ Task)
108
+ GUARDRAIL_TOOL="session.create"
109
+ CONTEXT_JSON="$(safe_jq "$INPUT" '{description: (.tool_input.description // .tool_input.prompt // "")}')"
110
+ ;;
111
+ MCP:*)
112
+ GUARDRAIL_TOOL="mcp.tool"
113
+ CONTEXT_JSON="$(safe_jq "$INPUT" '{tool_name: (.tool_name // ""), tool_input: (.tool_input // {})}')"
114
+ ;;
115
+ *)
116
+ # Unknown preToolUse tool: fail-closed
117
+ deny "🛡️ APort: unknown tool '$TOOL_NAME' — fail-closed policy"
118
+ ;;
119
+ esac
120
+
121
+ elif echo "$INPUT" | jq -e '.command' &> /dev/null; then
122
+ # beforeShellExecution: { "command": "..." }
123
+ GUARDRAIL_TOOL="bash"
124
+ CMD="$(echo "$INPUT" | jq -r '.command // ""' 2> /dev/null)"
125
+ CONTEXT_JSON="$(jq -n -c --arg cmd "$CMD" '{command: $cmd}')"
126
+
127
+ elif echo "$INPUT" | jq -e '.tool // .input.command' &> /dev/null; then
128
+ # Legacy Copilot-style: { "tool": "runTerminalCommand", "input": { "command": "..." } }
129
+ GUARDRAIL_TOOL="bash"
130
+ CMD="$(echo "$INPUT" | jq -r '.input.command // .input.cmd // .args[0] // ""' 2> /dev/null)"
131
+ CONTEXT_JSON="$(jq -n -c --arg cmd "$CMD" '{command: $cmd}')"
132
+
50
133
  else
51
- CONTEXT_JSON="$INPUT"
134
+ # Unrecognized input shape: fail-closed
135
+ deny "🛡️ APort: unrecognized hook input — fail-closed policy"
136
+ fi
137
+
138
+ # Use a per-invocation decision file to avoid race conditions with concurrent tool calls
139
+ HOOK_DECISION_FILE="${OPENCLAW_DECISION_FILE:-}"
140
+ if [ -n "$HOOK_DECISION_FILE" ]; then
141
+ HOOK_DECISION_FILE="${HOOK_DECISION_FILE%.json}-$$.json"
142
+ export OPENCLAW_DECISION_FILE="$HOOK_DECISION_FILE"
52
143
  fi
53
144
 
54
- # Call existing bash guardrail: exit 0 = allow, exit 1 = deny (forward config for subprocess)
145
+ # Call core evaluator
55
146
  set +e
56
- OPENCLAW_CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-$HOME/.openclaw}" OPENCLAW_PASSPORT_FILE="${OPENCLAW_PASSPORT_FILE:-}" OPENCLAW_DECISION_FILE="${OPENCLAW_DECISION_FILE:-}" "$GUARDRAIL" "$TOOL_NAME" "$CONTEXT_JSON" 2> /dev/null
147
+ "$GUARDRAIL" "$GUARDRAIL_TOOL" "$CONTEXT_JSON" 2> /dev/null
57
148
  GUARDRAIL_EXIT=$?
58
149
  set -e
59
150
 
151
+ # Clean up per-invocation decision file
152
+ cleanup_decision() { [ -n "$HOOK_DECISION_FILE" ] && rm -f "$HOOK_DECISION_FILE" 2> /dev/null; }
153
+
60
154
  if [ "$GUARDRAIL_EXIT" -eq 0 ]; then
155
+ cleanup_decision
61
156
  echo '{"permission":"allow","allowed":true}'
62
157
  exit 0
63
158
  fi
64
159
 
65
- # Deny: output reason from decision file if available (guardrail writes decision before exit 1)
160
+ # Deny: read reason from decision file
66
161
  REASON="Policy denied this action."
67
- if [ -n "${OPENCLAW_DECISION_FILE:-}" ] && [ -f "$OPENCLAW_DECISION_FILE" ] && command -v jq &> /dev/null; then
68
- R=$(jq -r '.reasons[0].message // empty' "$OPENCLAW_DECISION_FILE" 2> /dev/null)
69
- if [ -n "$R" ]; then
70
- REASON="$R"
71
- fi
162
+ if [ -n "$HOOK_DECISION_FILE" ] && [ -f "$HOOK_DECISION_FILE" ]; then
163
+ R="$(jq -r '.reasons[0].message // empty' "$HOOK_DECISION_FILE" 2> /dev/null)"
164
+ [ -n "$R" ] && REASON="$R"
72
165
  fi
73
- # If no decision file was set, try common config dirs so we can show actual deny reason
74
- if [ "$REASON" = "Policy denied this action." ] && command -v jq &> /dev/null; then
166
+ # Fallback: try common config dirs
167
+ if [ "$REASON" = "Policy denied this action." ]; then
75
168
  for DEC in "${OPENCLAW_CONFIG_DIR:-$HOME/.cursor}/aport/decision.json" "$HOME/.cursor/aport/decision.json" "$HOME/.openclaw/aport/decision.json"; do
76
169
  if [ -f "$DEC" ]; then
77
- R=$(jq -r '.reasons[0].message // empty' "$DEC" 2> /dev/null)
78
- if [ -n "$R" ]; then
79
- REASON="$R"
80
- break
81
- fi
170
+ R="$(jq -r '.reasons[0].message // empty' "$DEC" 2> /dev/null)"
171
+ [ -n "$R" ] && REASON="$R" && break
82
172
  fi
83
173
  done
84
174
  fi
85
- # Fallback: help user debug guardrail/script errors
86
- if [ "$REASON" = "Policy denied this action." ]; then
87
- REASON="Policy denied or guardrail error. Check passport and guardrail script (see docs/frameworks/cursor.md)."
88
- fi
89
- echo "{\"permission\":\"deny\",\"allowed\":false,\"agentMessage\":$(echo "$REASON" | jq -Rs .),\"reason\":$(echo "$REASON" | jq -Rs .)}"
90
- exit 2
175
+ cleanup_decision
176
+ deny "🛡️ APort: $REASON"