@rubytech/create-maxy 1.0.799 → 1.0.801

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 (23) hide show
  1. package/dist/index.js +7 -1
  2. package/package.json +1 -1
  3. package/payload/platform/plugins/admin/hooks/__tests__/archive-ingest-surface-gate.test.sh +191 -0
  4. package/payload/platform/plugins/admin/hooks/archive-ingest-surface-gate.sh +207 -0
  5. package/payload/platform/plugins/cloudflare/references/manual-setup.md +12 -0
  6. package/payload/platform/plugins/cloudflare/scripts/_cdp-authorize-matcher.mjs +74 -0
  7. package/payload/platform/plugins/cloudflare/scripts/_cdp-authorize.mjs +60 -50
  8. package/payload/platform/plugins/cloudflare/scripts/setup-tunnel.sh +118 -22
  9. package/payload/platform/plugins/cloudflare/skills/setup-tunnel/SKILL.md +4 -0
  10. package/payload/platform/plugins/docs/references/plugins-guide.md +1 -1
  11. package/payload/platform/plugins/whatsapp-import/PLUGIN.md +2 -2
  12. package/payload/platform/plugins/whatsapp-import/bin/ingest.mjs +732 -0
  13. package/payload/platform/plugins/whatsapp-import/bin/whatsapp-ingest.sh +102 -0
  14. package/payload/platform/plugins/whatsapp-import/skills/whatsapp-import/SKILL.md +49 -97
  15. package/payload/platform/plugins/whatsapp-import/skills/whatsapp-import/references/export-parse.md +1 -1
  16. package/payload/platform/scripts/seed-neo4j.sh +24 -15
  17. package/payload/platform/templates/specialists/agents/database-operator.md +13 -3
  18. package/payload/server/public/assets/{admin-C0lKk6WM.js → admin-Sa301b8q.js} +6 -6
  19. package/payload/server/public/index.html +1 -1
  20. package/payload/platform/plugins/admin/hooks/__tests__/archive-ingest-gate.test.sh +0 -166
  21. package/payload/platform/plugins/admin/hooks/archive-ingest-gate.sh +0 -147
  22. package/payload/platform/plugins/whatsapp-import/skills/whatsapp-import/references/conversation-and-messages.md +0 -99
  23. package/payload/platform/plugins/whatsapp-import/skills/whatsapp-import/references/insight-extraction.md +0 -121
package/dist/index.js CHANGED
@@ -1765,11 +1765,17 @@ function installTunnelScripts() {
1765
1765
  // $HOME symlink — it's a helper, not a top-level operator command. We do
1766
1766
  // chmod +x defensively so `ls -l` and any ad-hoc `~/setup-tunnel.sh` copy
1767
1767
  // flow sees it as executable (Task 588).
1768
+ //
1769
+ // _cdp-authorize-matcher.mjs is imported by _cdp-authorize.mjs (Task 855)
1770
+ // — the tri-state matcher's MATCH_EXPR + findMatch live in this side
1771
+ // module so JSDOM tests run identical logic to the live page. chmod +x
1772
+ // is defensive symmetry; the file is read by Node, not exec'd.
1768
1773
  const cdpAuthorizeSrc = join(INSTALL_DIR, "platform/plugins/cloudflare/scripts/_cdp-authorize.mjs");
1774
+ const cdpMatcherSrc = join(INSTALL_DIR, "platform/plugins/cloudflare/scripts/_cdp-authorize-matcher.mjs");
1769
1775
  const setupLink = resolve(process.env.HOME ?? "/root", "setup-tunnel.sh");
1770
1776
  const resetLink = resolve(process.env.HOME ?? "/root", "reset-tunnel.sh");
1771
1777
  const listLink = resolve(process.env.HOME ?? "/root", "list-cf-domains.sh");
1772
- for (const src of [setupSrc, resetSrc, listSrc, cdpAuthorizeSrc]) {
1778
+ for (const src of [setupSrc, resetSrc, listSrc, cdpAuthorizeSrc, cdpMatcherSrc]) {
1773
1779
  try {
1774
1780
  chmodSync(src, 0o755);
1775
1781
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy",
3
- "version": "1.0.799",
3
+ "version": "1.0.801",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy": "./dist/index.js"
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env bash
2
+ # Regression test for archive-ingest-surface-gate.sh (Task 855).
3
+ #
4
+ # Covers:
5
+ # Preserved-from-Task-846:
6
+ # 1. Edit on /platform/plugins/<x>/lib/* → BLOCKED
7
+ # 2. Edit on benign path → ALLOWED
8
+ # 3. Bash with `npx vitest`/`bun test`/`npm test` → BLOCKED
9
+ # 4. PostToolUse on whatsapp-export-parse with isError:true sets flag
10
+ # 5. Subsequent PreToolUse on ANY tool → BLOCKED
11
+ # 6. UserPromptSubmit clears flag → normal allow resumes
12
+ # 7. PostToolUse with isError:false → flag absent
13
+ # 8. Stale flag (>600s) auto-clears
14
+ # New (Task 855):
15
+ # A. PreToolUse mcp__memory__whatsapp-export-parse → BLOCKED
16
+ # B. PreToolUse mcp__memory__whatsapp-export-insight-write → BLOCKED
17
+ # C. PreToolUse mcp__memory__memory-archive-write w/ archiveType=whatsapp-export → BLOCKED
18
+ # D. PreToolUse mcp__memory__memory-archive-write w/ archiveType=linkedin-connections → ALLOWED
19
+ # E. PreToolUse Bash invoking whatsapp-ingest.sh → ALLOWED
20
+ # F. Default-allow emits a [archive-ingest-gate] decision=allow log line
21
+
22
+ set -u
23
+
24
+ HOOK="$(cd "$(dirname "$0")/.." && pwd)/archive-ingest-surface-gate.sh"
25
+ if [[ ! -x "$HOOK" ]]; then
26
+ echo "FAIL: $HOOK not executable" >&2
27
+ exit 1
28
+ fi
29
+
30
+ STATE_DIR=$(mktemp -d)
31
+ export ARCHIVE_INGEST_GATE_STATE_DIR="$STATE_DIR"
32
+ FLAG_FILE="$STATE_DIR/archive-ingest-parse-error.flag"
33
+
34
+ cleanup() { rm -rf "$STATE_DIR"; }
35
+ trap cleanup EXIT
36
+
37
+ PASS=0
38
+ FAIL=0
39
+
40
+ run_case() {
41
+ local name="$1" stdin="$2" expected_exit="$3"
42
+ local actual_exit
43
+ printf '%s' "$stdin" | bash "$HOOK" >/dev/null 2>/dev/null
44
+ actual_exit=$?
45
+ if [[ "$actual_exit" -eq "$expected_exit" ]]; then
46
+ echo "PASS: $name (exit=$actual_exit)"
47
+ PASS=$((PASS + 1))
48
+ else
49
+ echo "FAIL: $name (expected exit=$expected_exit, got=$actual_exit)" >&2
50
+ FAIL=$((FAIL + 1))
51
+ fi
52
+ }
53
+
54
+ # Preserved cases ---------------------------------------------------------
55
+
56
+ run_case "Edit on platform/plugins/whatsapp-import/lib/src/parse-export.ts → BLOCKED" \
57
+ '{"hook_event_name":"PreToolUse","tool_name":"Edit","tool_input":{"file_path":"/Users/x/repo/platform/plugins/whatsapp-import/lib/src/parse-export.ts","old_string":"a","new_string":"b"}}' \
58
+ 2
59
+
60
+ run_case "Edit on README.md → ALLOWED" \
61
+ '{"hook_event_name":"PreToolUse","tool_name":"Edit","tool_input":{"file_path":"/Users/x/repo/README.md","old_string":"a","new_string":"b"}}' \
62
+ 0
63
+
64
+ run_case "Bash 'npx vitest run' → BLOCKED" \
65
+ '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"npx vitest run parse-export.test.ts"}}' \
66
+ 2
67
+
68
+ run_case "Bash 'ls -la' → ALLOWED" \
69
+ '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"ls -la"}}' \
70
+ 0
71
+
72
+ run_case "Bash 'bun test' → BLOCKED" \
73
+ '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"bun test"}}' \
74
+ 2
75
+
76
+ run_case "Bash 'npm test' → BLOCKED" \
77
+ '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"npm test"}}' \
78
+ 2
79
+
80
+ # New (Task 855) cases ----------------------------------------------------
81
+
82
+ run_case "PreToolUse mcp__memory__whatsapp-export-parse → BLOCKED" \
83
+ '{"hook_event_name":"PreToolUse","tool_name":"mcp__memory__whatsapp-export-parse","tool_input":{"filePath":"/tmp/_chat.txt","accountId":"acct1","timezone":"Europe/London"}}' \
84
+ 2
85
+
86
+ run_case "PreToolUse mcp__memory__whatsapp-export-insight-write → BLOCKED" \
87
+ '{"hook_event_name":"PreToolUse","tool_name":"mcp__memory__whatsapp-export-insight-write","tool_input":{"kind":"MENTIONS","name":"Joel"}}' \
88
+ 2
89
+
90
+ run_case "PreToolUse memory-archive-write w/ archiveType=whatsapp-export → BLOCKED" \
91
+ '{"hook_event_name":"PreToolUse","tool_name":"mcp__memory__memory-archive-write","tool_input":{"archiveType":"whatsapp-export","ownerNodeId":"x","accountId":"a","rows":[]}}' \
92
+ 2
93
+
94
+ run_case "PreToolUse memory-archive-write w/ archiveType=linkedin-connections → ALLOWED" \
95
+ '{"hook_event_name":"PreToolUse","tool_name":"mcp__memory__memory-archive-write","tool_input":{"archiveType":"linkedin-connections","ownerNodeId":"x","accountId":"a","rows":[]}}' \
96
+ 0
97
+
98
+ # Bypass attempts (Task 855 code-review C1): nested archiveType in rows[0]
99
+ # or conversation must NOT defeat the block. The gate must read the
100
+ # top-level tool_input.archiveType, not the first textual occurrence.
101
+ run_case "BYPASS: nested rows[0].archiveType=linkedin + top-level archiveType=whatsapp-export → BLOCKED" \
102
+ '{"hook_event_name":"PreToolUse","tool_name":"mcp__memory__memory-archive-write","tool_input":{"rows":[{"archiveType":"linkedin-connections"}],"archiveType":"whatsapp-export","ownerNodeId":"x","accountId":"a"}}' \
103
+ 2
104
+
105
+ run_case "BYPASS: conversation.archiveType=linkedin + top-level archiveType=whatsapp-export → BLOCKED" \
106
+ '{"hook_event_name":"PreToolUse","tool_name":"mcp__memory__memory-archive-write","tool_input":{"conversation":{"archiveType":"linkedin-connections","conversationId":"x"},"archiveType":"whatsapp-export","ownerNodeId":"x","accountId":"a","rows":[]}}' \
107
+ 2
108
+
109
+ # Plugin-source-edit path block must read tool_input.file_path top-level,
110
+ # not a nested file_path in old_string/new_string.
111
+ run_case "BYPASS: nested file_path in old_string + top-level file_path in lib/* → BLOCKED" \
112
+ '{"hook_event_name":"PreToolUse","tool_name":"Edit","tool_input":{"file_path":"/repo/platform/plugins/whatsapp-import/lib/src/parse-export.ts","old_string":"file_path:/safe/path","new_string":"x"}}' \
113
+ 2
114
+
115
+ run_case "PreToolUse Bash invoking whatsapp-ingest.sh → ALLOWED" \
116
+ '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"bash platform/plugins/whatsapp-import/bin/whatsapp-ingest.sh /tmp/chat.zip --owner-element-id 4:abc:1 --scope admin"}}' \
117
+ 0
118
+
119
+ # Default-allow log-line check
120
+ LOG=$(printf '%s' '{"hook_event_name":"PreToolUse","tool_name":"Read","tool_input":{"file_path":"/tmp/foo"}}' | bash "$HOOK" 2>&1 1>/dev/null)
121
+ if printf '%s' "$LOG" | grep -q '\[archive-ingest-gate\] decision=allow tool=Read reason=default'; then
122
+ echo "PASS: default-allow emits decision-log line"
123
+ PASS=$((PASS + 1))
124
+ else
125
+ echo "FAIL: default-allow missing decision-log line. Got: $LOG" >&2
126
+ FAIL=$((FAIL + 1))
127
+ fi
128
+
129
+ # Block-log line includes command field for Bash
130
+ LOG=$(printf '%s' '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"npx vitest run"}}' | bash "$HOOK" 2>&1 1>/dev/null)
131
+ if printf '%s' "$LOG" | grep -q '\[archive-ingest-gate\] decision=block tool=Bash reason=test-runner'; then
132
+ echo "PASS: block emits decision-log line with reason"
133
+ PASS=$((PASS + 1))
134
+ else
135
+ echo "FAIL: block missing decision-log line. Got: $LOG" >&2
136
+ FAIL=$((FAIL + 1))
137
+ fi
138
+
139
+ # Parse-error flag lifecycle (preserved)
140
+ rm -f "$FLAG_FILE"
141
+ run_case "PostToolUse parse-error sets flag (exit 0)" \
142
+ '{"hook_event_name":"PostToolUse","tool_name":"mcp__memory__whatsapp-export-parse","tool_input":{"filePath":"_chat.txt"},"tool_response":{"isError":true,"content":[{"type":"text","text":"parse-error file=_chat.txt line=1 reason=not-a-_chat.txt"}]}}' \
143
+ 0
144
+ [[ -f "$FLAG_FILE" ]] && { echo "PASS: parse-error flag created"; PASS=$((PASS+1)); } \
145
+ || { echo "FAIL: parse-error flag NOT created" >&2; FAIL=$((FAIL+1)); }
146
+
147
+ run_case "PreToolUse Read after parse-error → BLOCKED" \
148
+ '{"hook_event_name":"PreToolUse","tool_name":"Read","tool_input":{"file_path":"/tmp/foo"}}' \
149
+ 2
150
+
151
+ run_case "UserPromptSubmit clears flag" \
152
+ '{"hook_event_name":"UserPromptSubmit","prompt":"retry"}' \
153
+ 0
154
+ [[ ! -f "$FLAG_FILE" ]] && { echo "PASS: UserPromptSubmit cleared flag"; PASS=$((PASS+1)); } \
155
+ || { echo "FAIL: UserPromptSubmit did NOT clear flag" >&2; FAIL=$((FAIL+1)); }
156
+
157
+ run_case "PreToolUse Read after clearance → ALLOWED" \
158
+ '{"hook_event_name":"PreToolUse","tool_name":"Read","tool_input":{"file_path":"/tmp/foo"}}' \
159
+ 0
160
+
161
+ # Stale flag auto-clears
162
+ PAST=$(( $(date -u +%s) - 700 ))
163
+ echo "$PAST" > "$FLAG_FILE"
164
+ run_case "Stale flag auto-clears, PreToolUse Read → ALLOWED" \
165
+ '{"hook_event_name":"PreToolUse","tool_name":"Read","tool_input":{"file_path":"/tmp/foo"}}' \
166
+ 0
167
+
168
+ # PostToolUse parse-success leaves flag absent
169
+ rm -f "$FLAG_FILE"
170
+ run_case "PostToolUse parse-success (isError:false) does NOT set flag" \
171
+ '{"hook_event_name":"PostToolUse","tool_name":"mcp__memory__whatsapp-export-parse","tool_input":{"filePath":"_chat.txt"},"tool_response":{"isError":false,"content":[{"type":"text","text":"{\"parsedLines\":[]}"}]}}' \
172
+ 0
173
+ [[ ! -f "$FLAG_FILE" ]] && { echo "PASS: parse-success leaves flag absent"; PASS=$((PASS+1)); } \
174
+ || { echo "FAIL: parse-success incorrectly created flag" >&2; FAIL=$((FAIL+1)); }
175
+
176
+ # Fail-closed terminal check
177
+ if grep -q '\[ -t 0 \]' "$HOOK"; then
178
+ echo "PASS: fail-closed terminal check is present"
179
+ PASS=$((PASS + 1))
180
+ else
181
+ echo "FAIL: fail-closed terminal check missing" >&2
182
+ FAIL=$((FAIL + 1))
183
+ fi
184
+
185
+ echo
186
+ echo "──────── archive-ingest-surface-gate test summary ────────"
187
+ echo "PASS: $PASS"
188
+ echo "FAIL: $FAIL"
189
+
190
+ [[ "$FAIL" -gt 0 ]] && exit 1
191
+ exit 0
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env bash
2
+ # Archive-ingest surface gate (Task 855; supersedes Task 846).
3
+ #
4
+ # Five enforcements, one script — phase decided by `hook_event_name` on stdin.
5
+ # Task 855 narrows the database-operator subagent's effective surface during
6
+ # WhatsApp archive ingestion to exactly one Bash entry
7
+ # (`whatsapp-import/bin/whatsapp-ingest.sh`) plus read-only neighbours, by
8
+ # blocking the legacy MCP deviation tools mechanically.
9
+ #
10
+ # 1. PreToolUse on the three legacy WhatsApp MCP tools — BLOCK unconditionally.
11
+ # The single deterministic Bash entry (Task 855) is the only supported path
12
+ # for `archiveType=whatsapp-export`. The legacy MCP tools' source remains
13
+ # until cleanup; the gate stops them being invoked.
14
+ # mcp__memory__whatsapp-export-parse
15
+ # mcp__memory__whatsapp-export-insight-write
16
+ # mcp__memory__memory-archive-write (only when `archiveType` is
17
+ # `whatsapp-export`; LinkedIn
18
+ # and other archiveTypes pass
19
+ # through unchanged.)
20
+ #
21
+ # 2. PreToolUse Edit/Write/NotebookEdit: deny writes under
22
+ # `*platform/plugins/*/lib/*` (parser/CSV-shape source for any *-import or
23
+ # *-export plugin). Preserved from Task 846 — defence in depth even though
24
+ # the database-operator template no longer lists Edit/Write tools.
25
+ #
26
+ # 3. PreToolUse Bash: deny commands invoking JavaScript test runners
27
+ # (vitest|bun test|npm test|npx jest|node .*vitest). Preserved from Task 846.
28
+ #
29
+ # 4. Parse-error gate: PostToolUse on any `mcp__*__*-export-parse` /
30
+ # `mcp__*__*-import-parse` tool whose `tool_response.isError == true`
31
+ # writes a flag file. Subsequent PreToolUse on ANY tool blocks until
32
+ # UserPromptSubmit clears the flag. A 600s TTL is the cross-session safety
33
+ # net. Preserved from Task 846 because LinkedIn and future per-source archive
34
+ # parsers still use the legacy MCP path until they migrate to their own
35
+ # deterministic Bash entries.
36
+ #
37
+ # 5. Logging: every PreToolUse decision emits one line in the format
38
+ # [archive-ingest-gate] decision=<allow|block> tool=<name> reason=<r> ...
39
+ # so the operator can grep the full decision trail for one ingest from
40
+ # server.log alongside the [whatsapp-ingest] script lines.
41
+ #
42
+ # Exit codes follow Claude Code hook protocol: 0 = allow, 2 = block (stderr
43
+ # message shown to the agent). Fail-closed on terminal stdin to match
44
+ # pre-tool-use.sh.
45
+
46
+ set -uo pipefail
47
+
48
+ # Read stdin — fail closed if attached to a terminal (no JSON envelope coming).
49
+ if [ -t 0 ]; then
50
+ echo "Blocked: archive-ingest-surface-gate received no stdin (cannot inspect tool call). Failing closed." >&2
51
+ exit 2
52
+ fi
53
+ INPUT=$(cat)
54
+
55
+ # ----- Resolve account dir for state file ----------------------------------
56
+ HOOK_DIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd)"
57
+ PLATFORM_ROOT_RESOLVED="${HOOK_DIR}/../../.."
58
+ ACCOUNTS_DIR="${PLATFORM_ROOT_RESOLVED}/../data/accounts"
59
+ ACCOUNT_DIR=""
60
+ if [ -d "$ACCOUNTS_DIR" ]; then
61
+ ACCOUNT_DIR=$(find "$ACCOUNTS_DIR" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | head -1)
62
+ fi
63
+ STATE_DIR="${ARCHIVE_INGEST_GATE_STATE_DIR:-${ACCOUNT_DIR}/state}"
64
+ FLAG_FILE="${STATE_DIR}/archive-ingest-parse-error.flag"
65
+ TTL_SECONDS=600
66
+
67
+ # ----- Parse fields from stdin ---------------------------------------------
68
+ HOOK_EVENT=$(printf '%s' "$INPUT" | grep -o '"hook_event_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
69
+ TOOL_NAME=$(printf '%s' "$INPUT" | grep -o '"tool_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
70
+
71
+ # ============================================================================
72
+ # UserPromptSubmit — clear the parse-error flag.
73
+ # ============================================================================
74
+ if [ "$HOOK_EVENT" = "UserPromptSubmit" ]; then
75
+ if [ -f "$FLAG_FILE" ]; then
76
+ rm -f "$FLAG_FILE" 2>/dev/null
77
+ echo "[archive-ingest-gate] cleared reason=user-prompt-submit" >&2
78
+ fi
79
+ exit 0
80
+ fi
81
+
82
+ # ============================================================================
83
+ # PostToolUse — record parse-error from any *-export-parse / *-import-parse.
84
+ # ============================================================================
85
+ if [ "$HOOK_EVENT" = "PostToolUse" ]; then
86
+ case "$TOOL_NAME" in
87
+ mcp__*__*-export-parse|mcp__*__*-import-parse)
88
+ if printf '%s' "$INPUT" | grep -Eq '"isError"[[:space:]]*:[[:space:]]*true'; then
89
+ mkdir -p "$STATE_DIR" 2>/dev/null
90
+ date -u +%s > "$FLAG_FILE" 2>/dev/null
91
+ echo "[archive-ingest-gate] surfaced reason=parse-error tool=${TOOL_NAME}" >&2
92
+ fi
93
+ ;;
94
+ esac
95
+ exit 0
96
+ fi
97
+
98
+ # ============================================================================
99
+ # PreToolUse — five independent blocks.
100
+ # ============================================================================
101
+ if [ "$HOOK_EVENT" != "PreToolUse" ]; then
102
+ exit 0
103
+ fi
104
+
105
+ # Helper: emit a single decision-log line + remediation message, then exit.
106
+ # Args: $1=decision (allow|block) $2=reason $3=remediation message
107
+ emit_decision() {
108
+ local decision="$1" reason="$2" remediation="$3" cmd="${4:-}"
109
+ local cmd_field=""
110
+ if [ -n "$cmd" ]; then
111
+ # Sanitise: strip control chars, take first 60 chars.
112
+ cmd_field=" command=\"$(printf '%s' "$cmd" | tr -d '\000-\037' | cut -c1-60)\""
113
+ fi
114
+ echo "[archive-ingest-gate] decision=${decision} tool=${TOOL_NAME} reason=${reason}${cmd_field}" >&2
115
+ if [ "$decision" = "block" ]; then
116
+ echo "$remediation" >&2
117
+ exit 2
118
+ fi
119
+ }
120
+
121
+ # --- Block 1: post-parse-error gate (applies to ALL tools) -----------------
122
+ if [ -f "$FLAG_FILE" ]; then
123
+ FLAG_TS=$(cat "$FLAG_FILE" 2>/dev/null | head -1)
124
+ NOW=$(date -u +%s)
125
+ if [ -n "$FLAG_TS" ] && [ "$FLAG_TS" -gt 0 ] 2>/dev/null; then
126
+ AGE=$(( NOW - FLAG_TS ))
127
+ if [ "$AGE" -lt "$TTL_SECONDS" ]; then
128
+ emit_decision "block" "post-parse-error age_s=${AGE}" \
129
+ "Blocked: an archive-parser MCP tool returned isError=true earlier in this turn. The subagent's next action must be a user-facing message naming the parse-error and yielding back to the operator. Further tool calls are blocked until the operator submits a new prompt."
130
+ fi
131
+ rm -f "$FLAG_FILE" 2>/dev/null
132
+ else
133
+ rm -f "$FLAG_FILE" 2>/dev/null
134
+ fi
135
+ fi
136
+
137
+ # --- Block 2: legacy WhatsApp MCP tools — denied unconditionally ----------
138
+ case "$TOOL_NAME" in
139
+ mcp__memory__whatsapp-export-parse|mcp__memory__whatsapp-export-insight-write)
140
+ emit_decision "block" "denied-mcp-legacy" \
141
+ "Blocked: ${TOOL_NAME} is the Task 804 legacy path. Task 855 ships the deterministic Bash entry — invoke 'bash platform/plugins/whatsapp-import/bin/whatsapp-ingest.sh <archive> --owner-element-id <id> --scope <admin|public>' once. Parse, archive-write, and insight all run in-process; do not call legacy MCP tools."
142
+ ;;
143
+ esac
144
+
145
+ # Helper: extract a top-level field from `tool_input` via python3 — never via
146
+ # grep+sed against the raw JSON, which would pick the first textual occurrence
147
+ # including nested-object matches (`rows[0].archiveType`,
148
+ # `conversation.archiveType`, etc.) and let a malicious payload bypass the
149
+ # block. python3 is the project standard for JSON-aware hook parsing
150
+ # (mirrors lane-gate.sh:31-46). On parse failure return empty string —
151
+ # downstream block conditions skip cleanly.
152
+ extract_tool_input_field() {
153
+ local field="$1"
154
+ printf '%s' "$INPUT" | python3 -c "
155
+ import sys, json
156
+ try:
157
+ d = json.load(sys.stdin)
158
+ print(d.get('tool_input', {}).get('$field', ''))
159
+ except Exception:
160
+ print('')
161
+ " 2>/dev/null || echo ""
162
+ }
163
+
164
+ # --- Block 3: memory-archive-write conditional on whatsapp-export -----------
165
+ # LinkedIn and future archiveTypes flow unchanged through memory-archive-write.
166
+ # Only `archiveType=whatsapp-export` is now restricted to the script path.
167
+ if [ "$TOOL_NAME" = "mcp__memory__memory-archive-write" ]; then
168
+ ARCHIVE_TYPE=$(extract_tool_input_field archiveType)
169
+ if [ "$ARCHIVE_TYPE" = "whatsapp-export" ]; then
170
+ emit_decision "block" "denied-mcp-legacy archiveType=whatsapp-export" \
171
+ "Blocked: memory-archive-write with archiveType='whatsapp-export' is the Task 804 legacy path. Task 855 ships the deterministic Bash entry — invoke 'bash platform/plugins/whatsapp-import/bin/whatsapp-ingest.sh <archive> --owner-element-id <id> --scope <admin|public>' once. Other archiveTypes (linkedin-connections, …) flow through memory-archive-write unchanged."
172
+ fi
173
+ fi
174
+
175
+ # --- Block 4: plugin-source path block (Edit/Write/NotebookEdit) -----------
176
+ case "$TOOL_NAME" in
177
+ Edit|Write|NotebookEdit)
178
+ FILE_PATH=$(extract_tool_input_field file_path)
179
+ case "$FILE_PATH" in
180
+ */platform/plugins/*/lib/*|platform/plugins/*/lib/*)
181
+ emit_decision "block" "plugin-source-edit path=${FILE_PATH}" \
182
+ "Blocked: ${TOOL_NAME} on ${FILE_PATH} is a platform plugin lib/ path. The database-operator subagent does not own plugin source; if a parser is broken, surface the parse-error to the operator and let them dispatch a code-edit task instead."
183
+ ;;
184
+ esac
185
+ ;;
186
+ esac
187
+
188
+ # --- Block 5: shell test-runner block (Bash) -------------------------------
189
+ COMMAND=""
190
+ if [ "$TOOL_NAME" = "Bash" ]; then
191
+ COMMAND=$(extract_tool_input_field command)
192
+ if printf '%s' "$COMMAND" | grep -Eq '(^|[[:space:]/])vitest($|[[:space:]])|(^|[[:space:]])bun[[:space:]]+test($|[[:space:]])|(^|[[:space:]])npm[[:space:]]+test($|[[:space:]])|(^|[[:space:]])npx[[:space:]]+jest($|[[:space:]])|node[[:space:]].*vitest'; then
193
+ emit_decision "block" "test-runner" \
194
+ "Blocked: Bash command invokes a JavaScript test runner (vitest/bun test/npm test/npx jest). The database-operator subagent does not run plugin tests; surface the parse-error to the operator." \
195
+ "$COMMAND"
196
+ fi
197
+ fi
198
+
199
+ # Default — allow. Emit a single-line decision log so successful invocations
200
+ # leave a grep-able trail too. (Bash gets command field; other tools omit.)
201
+ if [ "$TOOL_NAME" = "Bash" ]; then
202
+ emit_decision "allow" "default" "" "$COMMAND"
203
+ else
204
+ emit_decision "allow" "default" ""
205
+ fi
206
+
207
+ exit 0
@@ -131,6 +131,18 @@ ls /tmp/.X11-unix/
131
131
 
132
132
  You should see `X99`. If not, start VNC before retrying Step 1.
133
133
 
134
+ ### Three states the consent page can render
135
+
136
+ The dashboard at `dash.cloudflare.com/argotunnel?...` shows one of three states. The automation script's CDP helper (`_cdp-authorize.mjs`) detects each one; the same triage applies when you walk the flow manually.
137
+
138
+ 1. **Pre-authorize — the Authorize button is visible.** This is the normal first run. Click the zone you want, then click **Authorize**. Cloudflare's callback fires and `~/.cloudflared/cert.pem` lands a second or two later. Continue with the `mv` above.
139
+
140
+ 2. **Post-success — "Cloudflared has installed a certificate" modal.** The bound account already has a cert authorised for the zones the dashboard knows about. The OAuth callback is idempotent — navigating to a fresh `cloudflared tunnel login` URL still fires the callback, so `~/.cloudflared/cert.pem` will land. Wait a second or two, then run the `mv`.
141
+
142
+ If the cert *also* exists at `${CFG_DIR}/cert.pem` from a prior partial run, Step 1 is already complete — skip to Step 2.
143
+
144
+ 3. **Blank or button-less — neither the Authorize button nor the success modal is on the page.** The browser may be mid-load, signed out of Cloudflare, blocking on captcha, or showing some other state. Sign in to Cloudflare in the same browser, navigate back to the URL `cloudflared tunnel login` printed, and complete the click manually. If the URL has expired, kill `cloudflared` (`Ctrl+C`) and re-run Step 1 to get a fresh URL.
145
+
134
146
  ---
135
147
 
136
148
  ## Step 2 — List existing tunnels
@@ -0,0 +1,74 @@
1
+ // Tri-state DOM matcher for the Cloudflare argotunnel consent page (Task 855).
2
+ //
3
+ // `dash.cloudflare.com/argotunnel?...` legitimately renders three observable
4
+ // states for our flow:
5
+ //
6
+ // 1. Pre-authorize — a <button> or <input type="submit"> whose trimmed text
7
+ // matches /^(authorize|connect)$/i (and is not disabled). Click it; the
8
+ // callback fires, cloudflared writes ~/.cloudflared/cert.pem.
9
+ //
10
+ // 2. Post-success — the dashboard renders a Success modal containing the
11
+ // stable substring "Cloudflared has installed a certificate". This shape
12
+ // is reached when the user already authorised this account before (or
13
+ // did so manually in VNC between cloudflared spawn and helper poll).
14
+ // The OAuth callback is idempotent on the cert side: when the helper
15
+ // sees this state, the running cloudflared subprocess will still write
16
+ // ~/.cloudflared/cert.pem because the callback URL is exercised by the
17
+ // navigation. The wrapper drops into the existing cert-poll afterwards.
18
+ //
19
+ // 3. Neither — the page is mid-load, blank, or in some other state. The
20
+ // caller polls again until BUTTON_POLL_TIMEOUT_MS, then exits 1.
21
+ //
22
+ // The matcher returns a discriminated union from a single DOM read:
23
+ //
24
+ // { kind: 'button', descriptor: {tag,text,disabled} } | { kind: 'success' } | null
25
+ //
26
+ // PRIORITY: button > success modal. Page transitions can briefly render both
27
+ // (e.g. during the success animation). If a clickable Authorize button exists,
28
+ // click it — the click produces a fresh callback regardless of any leftover
29
+ // modal text. Only when no button is present do we trust the modal as the
30
+ // terminal state.
31
+ //
32
+ // EXPORTED SHAPES:
33
+ //
34
+ // * findMatch(doc) — JS function form. Used by JSDOM-based unit tests
35
+ // (platform/ui/scripts/__tests__/cdp-authorize-matcher.test.ts) so future
36
+ // matcher edits replay against captured DOM fixtures offline (Success
37
+ // criterion 8 of Task 855).
38
+ //
39
+ // * MATCH_EXPR — string form, evaluated by Chrome DevTools Protocol's
40
+ // Runtime.evaluate in _cdp-authorize.mjs's polling loop. Built from
41
+ // findMatch.toString() so the live page and the tests run identical
42
+ // logic — single source of truth.
43
+ //
44
+ // CONTRACT — DO NOT loosen the success-modal anchor. Tighter anchors regress
45
+ // on Cloudflare copy edits; "Cloudflared has installed a certificate" is the
46
+ // load-bearing claim of the modal. Wider anchors (e.g. matching just
47
+ // "certificate") would false-positive on the pre-authorize page.
48
+
49
+ export function findMatch(doc) {
50
+ const candidates = Array.from(doc.querySelectorAll('button, input[type="submit"]'));
51
+ const match = candidates.find((el) => {
52
+ const text = (el.textContent ?? el.value ?? '').trim();
53
+ return /^(authorize|connect)$/i.test(text) && !el.disabled;
54
+ });
55
+ if (match) {
56
+ const descriptor = {
57
+ tag: match.tagName.toLowerCase(),
58
+ text: (match.textContent ?? match.value ?? '').trim().slice(0, 40),
59
+ disabled: Boolean(match.disabled),
60
+ };
61
+ match.click();
62
+ return { kind: 'button', descriptor };
63
+ }
64
+ // textContent over innerText: jsdom's innerText is partial; the dashboard's
65
+ // success-modal text is plain DOM content (not visibility-gated by CSS for
66
+ // anyone reading the page) so textContent finds it on both runtimes.
67
+ const bodyText = doc.body ? doc.body.textContent || '' : '';
68
+ if (bodyText.includes('Cloudflared has installed a certificate')) {
69
+ return { kind: 'success' };
70
+ }
71
+ return null;
72
+ }
73
+
74
+ export const MATCH_EXPR = `(${findMatch.toString()})(document)`;