@rubytech/create-realagent 1.0.794 → 1.0.797

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 (70) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/neo4j/migrations/003-person-name-eradicate.cypher +24 -0
  3. package/payload/platform/plugins/admin/PLUGIN.md +4 -0
  4. package/payload/platform/plugins/admin/hooks/__tests__/archive-ingest-gate.test.sh +166 -0
  5. package/payload/platform/plugins/admin/hooks/archive-ingest-gate.sh +147 -0
  6. package/payload/platform/plugins/admin/hooks/pre-tool-use.sh +9 -5
  7. package/payload/platform/plugins/admin/mcp/dist/index.js +54 -18
  8. package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
  9. package/payload/platform/plugins/docs/references/internals.md +4 -0
  10. package/payload/platform/plugins/docs/references/settings.md +6 -0
  11. package/payload/platform/plugins/linkedin-import/skills/linkedin-import/SKILL.md +2 -0
  12. package/payload/platform/plugins/memory/PLUGIN.md +4 -2
  13. package/payload/platform/plugins/memory/mcp/dist/index.js +2 -2
  14. package/payload/platform/plugins/memory/mcp/dist/index.js.map +1 -1
  15. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/schema-loader.test.js +11 -0
  16. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/schema-loader.test.js.map +1 -1
  17. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/schema-validator.test.js +124 -1
  18. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/schema-validator.test.js.map +1 -1
  19. package/payload/platform/plugins/memory/mcp/dist/lib/schema-loader.d.ts +12 -0
  20. package/payload/platform/plugins/memory/mcp/dist/lib/schema-loader.d.ts.map +1 -1
  21. package/payload/platform/plugins/memory/mcp/dist/lib/schema-loader.js +41 -2
  22. package/payload/platform/plugins/memory/mcp/dist/lib/schema-loader.js.map +1 -1
  23. package/payload/platform/plugins/memory/mcp/dist/lib/schema-validator.d.ts +9 -0
  24. package/payload/platform/plugins/memory/mcp/dist/lib/schema-validator.d.ts.map +1 -1
  25. package/payload/platform/plugins/memory/mcp/dist/lib/schema-validator.js +44 -0
  26. package/payload/platform/plugins/memory/mcp/dist/lib/schema-validator.js.map +1 -1
  27. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-archive-write.test.js +20 -2
  28. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-archive-write.test.js.map +1 -1
  29. package/payload/platform/plugins/memory/mcp/dist/tools/memory-archive-write.d.ts +1 -1
  30. package/payload/platform/plugins/memory/mcp/dist/tools/memory-archive-write.d.ts.map +1 -1
  31. package/payload/platform/plugins/memory/mcp/dist/tools/memory-archive-write.js +0 -1
  32. package/payload/platform/plugins/memory/mcp/dist/tools/memory-archive-write.js.map +1 -1
  33. package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-parse.d.ts +2 -1
  34. package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-parse.d.ts.map +1 -1
  35. package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-parse.js.map +1 -1
  36. package/payload/platform/plugins/memory/references/schema-base.md +11 -1
  37. package/payload/platform/plugins/whatsapp-import/PLUGIN.md +4 -0
  38. package/payload/platform/plugins/whatsapp-import/lib/dist/parse-export.d.ts +8 -2
  39. package/payload/platform/plugins/whatsapp-import/lib/dist/parse-export.d.ts.map +1 -1
  40. package/payload/platform/plugins/whatsapp-import/lib/dist/parse-export.js +66 -15
  41. package/payload/platform/plugins/whatsapp-import/lib/dist/parse-export.js.map +1 -1
  42. package/payload/platform/plugins/whatsapp-import/lib/src/__tests__/parse-export.test.ts +175 -0
  43. package/payload/platform/plugins/whatsapp-import/lib/src/parse-export.ts +78 -17
  44. package/payload/platform/plugins/whatsapp-import/skills/whatsapp-import/SKILL.md +2 -0
  45. package/payload/platform/plugins/whatsapp-import/skills/whatsapp-import/references/export-parse.md +8 -6
  46. package/payload/platform/scripts/logs-read.sh +17 -12
  47. package/payload/platform/scripts/seed-neo4j.sh +43 -20
  48. package/payload/platform/templates/agents/admin/IDENTITY.md +6 -0
  49. package/payload/platform/templates/specialists/agents/database-operator.md +2 -0
  50. package/payload/server/chunk-BURNRCKP.js +3405 -0
  51. package/payload/server/chunk-JSBRDJBE.js +30 -0
  52. package/payload/server/chunk-KM23Y7SY.js +9896 -0
  53. package/payload/server/client-pool-PV45NUTN.js +29 -0
  54. package/payload/server/maxy-edge.js +3 -2
  55. package/payload/server/neo4j-migrations-IUSBODOP.js +51 -0
  56. package/payload/server/public/assets/{admin-jGbRjAxV.js → admin-Cz8hUAqx.js} +60 -60
  57. package/payload/server/public/assets/data-BvV94XHO.js +1 -0
  58. package/payload/server/public/assets/graph-CBu0rtrP.js +1 -0
  59. package/payload/server/public/assets/page-BNM63zsb.js +50 -0
  60. package/payload/server/public/assets/page-DM19J3ur.js +1 -0
  61. package/payload/server/public/assets/useAdminFetch-iYCQ9lT0.js +1 -0
  62. package/payload/server/public/data.html +3 -3
  63. package/payload/server/public/graph.html +3 -3
  64. package/payload/server/public/index.html +4 -4
  65. package/payload/server/server.js +116 -137
  66. package/payload/server/public/assets/data-BhrQjgR5.js +0 -1
  67. package/payload/server/public/assets/graph-Jj7seS-w.js +0 -1
  68. package/payload/server/public/assets/page-DIG7s5Jp.js +0 -1
  69. package/payload/server/public/assets/page-sZb3wcOM.js +0 -50
  70. package/payload/server/public/assets/share-2-BndjMKeG.js +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-realagent",
3
- "version": "1.0.794",
3
+ "version": "1.0.797",
4
4
  "description": "Install Real Agent — Built for agents. By agents.",
5
5
  "bin": {
6
6
  "create-realagent": "./dist/index.js"
@@ -0,0 +1,24 @@
1
+ // ============================================================
2
+ // Migration 003 — Person.name eradication (Task 849)
3
+ //
4
+ // Removes the denormalised `name` property from every Person node.
5
+ // `Person.name` is forbidden by schema-base.md "Forbidden Properties":
6
+ // the canonical fields are `givenName` + `familyName`, composed at
7
+ // read time by display-helpers.ts. Persisted `name` was a divergence
8
+ // trap — LLM extraction emitted `name = givenName` ("Dan") alongside
9
+ // the structured pair, so the canvas rendered "Dan" instead of
10
+ // "Dan Brett".
11
+ //
12
+ // Idempotent: subsequent runs find no Persons with `name` set and
13
+ // return removed=0.
14
+ //
15
+ // Applied at boot by platform/ui/app/lib/neo4j-migrations.ts. Safe to
16
+ // run manually:
17
+ // cypher-shell -u neo4j -p <password> -a $NEO4J_URI \
18
+ // -f platform/neo4j/migrations/003-person-name-eradicate.cypher
19
+ // ============================================================
20
+
21
+ MATCH (p:Person)
22
+ WHERE p.name IS NOT NULL
23
+ REMOVE p.name
24
+ RETURN count(p) AS removed
@@ -51,6 +51,10 @@ Platform management tools for both the admin and public agents. The `plugin-read
51
51
 
52
52
  Tools are available via the `admin` MCP server.
53
53
 
54
+ **Three-store admin auth invariant (Task 850).** `admin-add` writes to all three identity stores (`users.json` PIN auth, `account.json` `admins[]` role, Neo4j `:AdminUser`/`:Person` graph identity) with per-leg `[admin-auth-store]` log lines and returns `is_error: true` on any leg failure naming what's already written. `admin-update-pin` writes `users.json` only and emits the same line. Direct `Edit`/`Write` on `account.json` is blocked at the `pre-tool-use` hook — mutations go through `account-update`, `plugin-toggle-enabled`, or the `admin-*` tools. See `.docs/agents.md` § "Three-store admin auth invariant" for the full contract.
55
+
56
+ `logs-read { type: "agent-stream" }` (Task 850) is the canonical name for the per-conversation tool-use/tool-result archive previously called `system`; both names work and the legacy alias is preserved.
57
+
54
58
  ## Skills
55
59
 
56
60
  | Task | When to use | Reference |
@@ -0,0 +1,166 @@
1
+ #!/usr/bin/env bash
2
+ # Regression test for archive-ingest-gate.sh (Task 846).
3
+ #
4
+ # Six cases cover the contract:
5
+ # 1. Edit on /platform/plugins/<x>/lib/* is BLOCKED (exit 2).
6
+ # 2. Edit on a benign path is ALLOWED (exit 0).
7
+ # 3. Bash with `npx vitest` is BLOCKED.
8
+ # 4. PostToolUse on whatsapp-export-parse with isError:true sets the flag.
9
+ # 5. Subsequent PreToolUse on ANY tool is BLOCKED (post-parse-error gate).
10
+ # 6. UserPromptSubmit clears the flag, restoring normal allow behavior.
11
+ #
12
+ # Tests use ARCHIVE_INGEST_GATE_STATE_DIR to point at a tmp dir so they run
13
+ # without a real account layout.
14
+
15
+ set -u
16
+
17
+ HOOK="$(cd "$(dirname "$0")/.." && pwd)/archive-ingest-gate.sh"
18
+ if [[ ! -x "$HOOK" ]]; then
19
+ echo "FAIL: $HOOK not executable" >&2
20
+ exit 1
21
+ fi
22
+
23
+ # Per-run isolated state dir
24
+ STATE_DIR=$(mktemp -d)
25
+ export ARCHIVE_INGEST_GATE_STATE_DIR="$STATE_DIR"
26
+ FLAG_FILE="$STATE_DIR/archive-ingest-parse-error.flag"
27
+
28
+ cleanup() { rm -rf "$STATE_DIR"; }
29
+ trap cleanup EXIT
30
+
31
+ PASS=0
32
+ FAIL=0
33
+
34
+ run_case() {
35
+ local name="$1" stdin="$2" expected_exit="$3"
36
+ local actual_exit
37
+ printf '%s' "$stdin" | bash "$HOOK" >/dev/null 2>/dev/null
38
+ actual_exit=$?
39
+ if [[ "$actual_exit" -eq "$expected_exit" ]]; then
40
+ echo "PASS: $name (exit=$actual_exit)"
41
+ PASS=$((PASS + 1))
42
+ else
43
+ echo "FAIL: $name (expected exit=$expected_exit, got=$actual_exit)" >&2
44
+ FAIL=$((FAIL + 1))
45
+ fi
46
+ }
47
+
48
+ # Case 1 — Edit on plugin lib path: BLOCKED
49
+ run_case "Edit on platform/plugins/whatsapp-import/lib/src/parse-export.ts → BLOCKED" \
50
+ '{"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"}}' \
51
+ 2
52
+
53
+ # Case 2 — Edit on a benign path: ALLOWED
54
+ run_case "Edit on README.md → ALLOWED" \
55
+ '{"hook_event_name":"PreToolUse","tool_name":"Edit","tool_input":{"file_path":"/Users/x/repo/README.md","old_string":"a","new_string":"b"}}' \
56
+ 0
57
+
58
+ # Case 3 — Bash with `npx vitest`: BLOCKED
59
+ run_case "Bash 'npx vitest run parse-export.test.ts' → BLOCKED" \
60
+ '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"npx vitest run parse-export.test.ts"}}' \
61
+ 2
62
+
63
+ # Case 3b — Bash with benign command: ALLOWED
64
+ run_case "Bash 'ls -la' → ALLOWED" \
65
+ '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"ls -la"}}' \
66
+ 0
67
+
68
+ # Case 3c — Bash with `bun test`: BLOCKED
69
+ run_case "Bash 'bun test' → BLOCKED" \
70
+ '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"bun test"}}' \
71
+ 2
72
+
73
+ # Case 3d — Bash with `npm test`: BLOCKED
74
+ run_case "Bash 'npm test' → BLOCKED" \
75
+ '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"npm test"}}' \
76
+ 2
77
+
78
+ # Make sure flag is absent before parse-error simulation
79
+ rm -f "$FLAG_FILE"
80
+
81
+ # Case 4 — PostToolUse on whatsapp-export-parse with isError:true sets flag
82
+ run_case "PostToolUse parse-error sets flag (exit 0, flag side-effect)" \
83
+ '{"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"}]}}' \
84
+ 0
85
+
86
+ if [[ -f "$FLAG_FILE" ]]; then
87
+ echo "PASS: parse-error flag created at $FLAG_FILE"
88
+ PASS=$((PASS + 1))
89
+ else
90
+ echo "FAIL: parse-error flag NOT created at $FLAG_FILE" >&2
91
+ FAIL=$((FAIL + 1))
92
+ fi
93
+
94
+ # Case 5 — Subsequent PreToolUse on ANY tool BLOCKED while flag is fresh
95
+ run_case "PreToolUse Read after parse-error → BLOCKED" \
96
+ '{"hook_event_name":"PreToolUse","tool_name":"Read","tool_input":{"file_path":"/tmp/foo"}}' \
97
+ 2
98
+
99
+ run_case "PreToolUse Bash after parse-error → BLOCKED" \
100
+ '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"echo hi"}}' \
101
+ 2
102
+
103
+ # Case 6 — UserPromptSubmit clears flag
104
+ run_case "UserPromptSubmit clears flag (exit 0)" \
105
+ '{"hook_event_name":"UserPromptSubmit","prompt":"retry"}' \
106
+ 0
107
+
108
+ if [[ ! -f "$FLAG_FILE" ]]; then
109
+ echo "PASS: UserPromptSubmit cleared flag"
110
+ PASS=$((PASS + 1))
111
+ else
112
+ echo "FAIL: UserPromptSubmit did NOT clear flag" >&2
113
+ FAIL=$((FAIL + 1))
114
+ fi
115
+
116
+ # Case 7 — After clearance, normal allow resumes
117
+ run_case "PreToolUse Read after clearance → ALLOWED" \
118
+ '{"hook_event_name":"PreToolUse","tool_name":"Read","tool_input":{"file_path":"/tmp/foo"}}' \
119
+ 0
120
+
121
+ # Case 8 — PostToolUse with isError:false does NOT set flag
122
+ rm -f "$FLAG_FILE"
123
+ run_case "PostToolUse parse-success (isError:false) does NOT set flag" \
124
+ '{"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\":[]}"}]}}' \
125
+ 0
126
+
127
+ if [[ ! -f "$FLAG_FILE" ]]; then
128
+ echo "PASS: parse-success leaves flag absent"
129
+ PASS=$((PASS + 1))
130
+ else
131
+ echo "FAIL: parse-success incorrectly created flag" >&2
132
+ FAIL=$((FAIL + 1))
133
+ fi
134
+
135
+ # Case 9 — Stale flag (>600s) auto-clears + allows
136
+ PAST=$(( $(date -u +%s) - 700 ))
137
+ echo "$PAST" > "$FLAG_FILE"
138
+ run_case "Stale flag auto-clears, PreToolUse Read → ALLOWED" \
139
+ '{"hook_event_name":"PreToolUse","tool_name":"Read","tool_input":{"file_path":"/tmp/foo"}}' \
140
+ 0
141
+
142
+ # Case 10 — No stdin (terminal) fails closed
143
+ echo "Probing fail-closed behaviour (no stdin)..."
144
+ bash "$HOOK" </dev/null >/dev/null 2>/dev/null
145
+ ACTUAL=$?
146
+ # /dev/null IS a stdin — the `[ -t 0 ]` check tests for terminal, not file.
147
+ # A file/pipe stdin reads as empty, which produces empty hook_event_name and
148
+ # falls through to default `exit 0` (allow). The terminal-only fail-closed
149
+ # branch can't be tested non-interactively; verify the script reads `[ -t 0 ]`.
150
+ if grep -q '\[ -t 0 \]' "$HOOK"; then
151
+ echo "PASS: fail-closed terminal check is present"
152
+ PASS=$((PASS + 1))
153
+ else
154
+ echo "FAIL: fail-closed terminal check missing" >&2
155
+ FAIL=$((FAIL + 1))
156
+ fi
157
+
158
+ echo
159
+ echo "──────── archive-ingest-gate test summary ────────"
160
+ echo "PASS: $PASS"
161
+ echo "FAIL: $FAIL"
162
+
163
+ if [[ "$FAIL" -gt 0 ]]; then
164
+ exit 1
165
+ fi
166
+ exit 0
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env bash
2
+ # Archive-ingest gate (Task 846).
3
+ #
4
+ # Three enforcements, one script — phase decided by `hook_event_name` on stdin:
5
+ #
6
+ # 1. PreToolUse Edit/Write/NotebookEdit: deny writes under
7
+ # `*platform/plugins/*/lib/*` (parser/CSV-shape source for any *-import or
8
+ # *-export plugin). The database-operator subagent has Read+Bash but not
9
+ # Edit/Write per its `tools:` frontmatter — yet it can still mutate files
10
+ # via Bash heredoc. The path block applies to every tool that takes a
11
+ # `file_path` and has been observed as the improvisation surface.
12
+ #
13
+ # 2. PreToolUse Bash: deny commands invoking JavaScript test runners
14
+ # (vitest|bun test|npm test|npx jest|node .*vitest). The reproducer
15
+ # incident (conv 47c6a590) saw the operator run all four variants in
16
+ # sequence after parse-export returned isError.
17
+ #
18
+ # 3. Parse-error gate: PostToolUse on any `mcp__*__*-export-parse` /
19
+ # `mcp__*__*-import-parse` tool whose `tool_response.isError == true`
20
+ # writes a flag file. Subsequent PreToolUse on ANY tool blocks until
21
+ # UserPromptSubmit clears the flag (semantics: "subagent's next action
22
+ # must be a user-facing message; further tool calls blocked"). A 600s
23
+ # TTL is the cross-session safety net.
24
+ #
25
+ # Exit codes follow Claude Code hook protocol: 0 = allow, 2 = block (stderr
26
+ # message shown to the agent). Fail-closed on stdin read failure to match
27
+ # pre-tool-use.sh.
28
+
29
+ set -uo pipefail
30
+
31
+ # Read stdin — fail closed if unavailable
32
+ if [ -t 0 ]; then
33
+ echo "Blocked: archive-ingest-gate received no stdin (cannot inspect tool call). Failing closed." >&2
34
+ exit 2
35
+ fi
36
+ INPUT=$(cat)
37
+
38
+ # ----- Resolve account dir for state file ----------------------------------
39
+ # Mirrors pre-tool-use.sh lines 100-107: walk from this hook's location to
40
+ # platform/, then `../data/accounts/<single-account>/`. Phase 0 has exactly
41
+ # one account directory.
42
+ HOOK_DIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd)"
43
+ PLATFORM_ROOT_RESOLVED="${HOOK_DIR}/../../.."
44
+ ACCOUNTS_DIR="${PLATFORM_ROOT_RESOLVED}/../data/accounts"
45
+ ACCOUNT_DIR=""
46
+ if [ -d "$ACCOUNTS_DIR" ]; then
47
+ ACCOUNT_DIR=$(find "$ACCOUNTS_DIR" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | head -1)
48
+ fi
49
+ # Test harness override — tests pass `ARCHIVE_INGEST_GATE_STATE_DIR` to point
50
+ # at a tmp dir without needing a real account layout.
51
+ STATE_DIR="${ARCHIVE_INGEST_GATE_STATE_DIR:-${ACCOUNT_DIR}/state}"
52
+ FLAG_FILE="${STATE_DIR}/archive-ingest-parse-error.flag"
53
+ TTL_SECONDS=600
54
+
55
+ # ----- Parse fields from stdin ---------------------------------------------
56
+ # Hook protocol: every event includes `hook_event_name`. PreToolUse +
57
+ # PostToolUse include `tool_name`. PostToolUse adds `tool_response`.
58
+ # UserPromptSubmit has neither. Use grep/sed against the JSON envelope —
59
+ # no jq dependency to match the rest of the hook fleet.
60
+ HOOK_EVENT=$(printf '%s' "$INPUT" | grep -o '"hook_event_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
61
+ TOOL_NAME=$(printf '%s' "$INPUT" | grep -o '"tool_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
62
+
63
+ # ============================================================================
64
+ # UserPromptSubmit — clear the parse-error flag.
65
+ # ============================================================================
66
+ if [ "$HOOK_EVENT" = "UserPromptSubmit" ]; then
67
+ if [ -f "$FLAG_FILE" ]; then
68
+ rm -f "$FLAG_FILE" 2>/dev/null
69
+ echo "[archive-ingest-gate] cleared reason=user-prompt-submit" >&2
70
+ fi
71
+ exit 0
72
+ fi
73
+
74
+ # ============================================================================
75
+ # PostToolUse — record parse-error from any *-export-parse / *-import-parse.
76
+ # ============================================================================
77
+ if [ "$HOOK_EVENT" = "PostToolUse" ]; then
78
+ case "$TOOL_NAME" in
79
+ mcp__*__*-export-parse|mcp__*__*-import-parse)
80
+ # Inspect tool_response for isError true. Body is a JSON object; match
81
+ # `"isError":true` with optional whitespace.
82
+ if printf '%s' "$INPUT" | grep -Eq '"isError"[[:space:]]*:[[:space:]]*true'; then
83
+ mkdir -p "$STATE_DIR" 2>/dev/null
84
+ date -u +%s > "$FLAG_FILE" 2>/dev/null
85
+ echo "[archive-ingest-gate] surfaced reason=parse-error tool=${TOOL_NAME}" >&2
86
+ fi
87
+ ;;
88
+ esac
89
+ # PostToolUse must always allow the tool result through.
90
+ exit 0
91
+ fi
92
+
93
+ # ============================================================================
94
+ # PreToolUse — three independent blocks.
95
+ # ============================================================================
96
+ if [ "$HOOK_EVENT" != "PreToolUse" ]; then
97
+ # Unknown event, or hook fired in a context without an event name. Allow.
98
+ exit 0
99
+ fi
100
+
101
+ # --- Block 1: post-parse-error gate (applies to ALL tools) -----------------
102
+ if [ -f "$FLAG_FILE" ]; then
103
+ FLAG_TS=$(cat "$FLAG_FILE" 2>/dev/null | head -1)
104
+ NOW=$(date -u +%s)
105
+ if [ -n "$FLAG_TS" ] && [ "$FLAG_TS" -gt 0 ] 2>/dev/null; then
106
+ AGE=$(( NOW - FLAG_TS ))
107
+ if [ "$AGE" -lt "$TTL_SECONDS" ]; then
108
+ echo "[archive-ingest-gate] block tool=${TOOL_NAME} reason=post-parse-error age_s=${AGE}" >&2
109
+ echo "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." >&2
110
+ exit 2
111
+ fi
112
+ # Stale flag — clean up so we don't keep blocking on a corpse.
113
+ rm -f "$FLAG_FILE" 2>/dev/null
114
+ else
115
+ # Unparseable flag — remove rather than block on garbage.
116
+ rm -f "$FLAG_FILE" 2>/dev/null
117
+ fi
118
+ fi
119
+
120
+ # --- Block 2: plugin-source path block (Edit/Write/NotebookEdit) -----------
121
+ case "$TOOL_NAME" in
122
+ Edit|Write|NotebookEdit)
123
+ FILE_PATH=$(printf '%s' "$INPUT" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
124
+ case "$FILE_PATH" in
125
+ */platform/plugins/*/lib/*|platform/plugins/*/lib/*)
126
+ echo "[archive-ingest-gate] block tool=${TOOL_NAME} reason=plugin-source-edit path=${FILE_PATH}" >&2
127
+ echo "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." >&2
128
+ exit 2
129
+ ;;
130
+ esac
131
+ ;;
132
+ esac
133
+
134
+ # --- Block 3: shell test-runner block (Bash) -------------------------------
135
+ if [ "$TOOL_NAME" = "Bash" ]; then
136
+ COMMAND=$(printf '%s' "$INPUT" | grep -o '"command"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
137
+ # Match any of: `vitest`, `bun test`, `npm test`, `npx jest`, `node ... vitest`.
138
+ # Word-boundary checks via grep -E with explicit token boundaries — POSIX
139
+ # `[[:space:]]` covers leading-token edge cases, end-of-string covers tail.
140
+ 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
141
+ echo "[archive-ingest-gate] block tool=Bash reason=test-runner command=${COMMAND}" >&2
142
+ echo "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." >&2
143
+ exit 2
144
+ fi
145
+ fi
146
+
147
+ exit 0
@@ -47,11 +47,15 @@ if [ "$AGENT_TYPE" = "admin" ]; then
47
47
  echo "[entitlement] tool-deny: tool=${TOOL_NAME} path=${FILE_PATH} field=entitlement-file" >&2
48
48
  exit 2
49
49
  ;;
50
- # account.json (Task 831) — agent must use the account-update MCP tool
51
- # (or plugin-toggle-enabled for enabledPlugins changes), which whitelists
52
- # editable fields server-side. Direct Edit/Write bypasses that whitelist;
53
- # deny unconditionally including cwd-relative paths.
54
- */data/accounts/*/account.json|*/config/accounts/*/account.json|data/accounts/*/account.json|config/accounts/*/account.json|account.json)
50
+ # account.json (Task 831 + Task 850) — agent must use the account-update
51
+ # MCP tool (or plugin-toggle-enabled for enabledPlugins changes), which
52
+ # whitelists editable fields server-side. Direct Edit/Write bypasses
53
+ # that whitelist; deny unconditionally including cwd-relative paths.
54
+ # Task 850 adds */account.json and *account.json as a backstop so any
55
+ # layout the named patterns miss is still caught — the Adam Mackay
56
+ # incident showed a tier upgrade via raw Edit on account.json reach
57
+ # the disk, exactly the failure mode this hook exists to prevent.
58
+ */data/accounts/*/account.json|*/config/accounts/*/account.json|data/accounts/*/account.json|config/accounts/*/account.json|*/account.json|*account.json|account.json)
55
59
  echo "Blocked: Admin agent cannot edit account.json directly. Use the account-update or plugin-toggle-enabled MCP tools — they whitelist editable fields server-side and exclude tier and purchasedPlugins by design." >&2
56
60
  echo "[entitlement] tool-deny: tool=${TOOL_NAME} path=${FILE_PATH} field=account-json" >&2
57
61
  exit 2
@@ -621,7 +621,7 @@ server.tool("plugin-toggle-enabled", "Enable or disable a plugin in this account
621
621
  // ===================================================================
622
622
  // Admin user management tools
623
623
  // ===================================================================
624
- server.tool("admin-add", "Add a new admin user to this account. Creates a device-level user entry (users.json) and adds them to this account's admins list (account.json). PIN must be at least 4 digits. If no PIN is provided, a unique 4-digit PIN is generated. Returns the userId and PIN to share with the new admin.", {
624
+ server.tool("admin-add", "Add a new admin user to this account. Creates a device-level user entry (users.json) and adds them to this account's admins list (account.json). PIN must be at least 4 digits. If no PIN is provided, a unique 4-digit PIN is generated. Returns the userId and PIN to share with the new admin.\n\nIMPORTANT — retry behaviour: if the user already stated a specific PIN earlier in the conversation and a first admin-add call failed (e.g. tier cap reached, PIN collision), every retry MUST re-pass that PIN as the `pin` parameter. Omitting `pin` on the retry auto-generates a different 4-digit PIN, silently substituting what the user asked for.", {
625
625
  name: z.string().describe("Display name for the new admin (stored on the AdminUser node in Neo4j)."),
626
626
  pin: z.string().optional().describe("Optional PIN (minimum 4 digits). If omitted, a unique 4-digit PIN is generated."),
627
627
  }, async ({ name, pin: rawPin }) => {
@@ -679,14 +679,25 @@ server.tool("admin-add", "Add a new admin user to this account. Creates a device
679
679
  }
680
680
  const pinHash = hashPin(plaintextPin);
681
681
  const userId = crypto.randomUUID();
682
+ // Three-store admin auth invariant (Task 850): users.json (device-level
683
+ // PIN auth), account.json admins[] (account-level role), Neo4j AdminUser
684
+ // (display + graph identity). Each leg emits a [admin-auth-store] line so
685
+ // a future incident can grep one log to know which leg failed; any leg
686
+ // failing makes the tool result is_error: true with a cause line citing
687
+ // what's already been written. No automatic rollback — the cause line is
688
+ // the manual-recovery diagnostic.
689
+ const userIdShort = userId.slice(0, 8);
682
690
  // 1. Write to users.json (device-level). Auth fields only — `name` lives
683
691
  // on the AdminUser node in Neo4j (Task 829).
684
692
  users.push({ userId, pin: pinHash });
685
693
  try {
686
694
  writeUsersJson(users);
695
+ console.error(`[admin-auth-store] action=add userId=${userIdShort} result=ok store=users`);
687
696
  }
688
697
  catch (err) {
689
- return { content: [{ type: "text", text: `${TAG} Failed to write users.json: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
698
+ const errMsg = err instanceof Error ? err.message : String(err);
699
+ console.error(`[admin-auth-store] action=add userId=${userIdShort} result=fail store=users error=${errMsg}`);
700
+ return { content: [{ type: "text", text: `${TAG} Failed to write users.json: ${errMsg}` }], isError: true };
690
701
  }
691
702
  // 2. Write to account.json (account-level)
692
703
  try {
@@ -698,10 +709,12 @@ server.tool("admin-add", "Add a new admin user to this account. Creates a device
698
709
  const configPath = join(getAccountDir(), "account.json");
699
710
  await writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
700
711
  }
712
+ console.error(`[admin-auth-store] action=add userId=${userIdShort} result=ok store=account`);
701
713
  }
702
714
  catch (err) {
703
- console.error(`${TAG} account.json write failed: ${err instanceof Error ? err.message : String(err)}`);
704
- return { content: [{ type: "text", text: `${TAG} User created in users.json but failed to add to account: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
715
+ const errMsg = err instanceof Error ? err.message : String(err);
716
+ console.error(`[admin-auth-store] action=add userId=${userIdShort} result=fail store=account error=${errMsg}`);
717
+ return { content: [{ type: "text", text: `${TAG} users.json updated; account.json write FAILED — manual reconciliation needed: ${errMsg}` }], isError: true };
705
718
  }
706
719
  // 3. Write to Neo4j (graph-level): AdminUser + Person + OWNS atomically,
707
720
  // plus ADMIN_OF edge to the LocalBusiness for this account.
@@ -710,7 +723,12 @@ server.tool("admin-add", "Add a new admin user to this account. Creates a device
710
723
  // in platform/ui/app/lib/neo4j-store.ts (case-insensitive exact match
711
724
  // on givenName + familyName; partial-name ambiguity does NOT match —
712
725
  // rationalisation is a separate agent-mediated concern).
713
- let neo4jWarning = "";
726
+ // Task 850 Neo4j-leg failure now returns is_error: true with the
727
+ // [admin-auth-store] line; previously it set a soft warning string and
728
+ // returned success, which is what hid the Adam Mackay incident from
729
+ // the recovery agent. The user is still functional via users.json +
730
+ // account.json, but the graph state is divergent and the operator
731
+ // must know.
714
732
  let personReused = false;
715
733
  try {
716
734
  const session = getSession();
@@ -759,18 +777,25 @@ server.tool("admin-add", "Add a new admin user to this account. Creates a device
759
777
  finally {
760
778
  await session.close();
761
779
  }
780
+ console.error(`[admin-auth-store] action=add userId=${userIdShort} result=ok store=neo4j personReused=${personReused}`);
762
781
  }
763
782
  catch (err) {
764
783
  const errMsg = err instanceof Error ? err.message : String(err);
765
- console.error(`${TAG} Neo4j sync failed during admin-add: userId=${userId} error=${errMsg} — files written, graph pending`);
766
- neo4jWarning = ` Note: Neo4j sync failed (${errMsg}) — the admin user is functional but the graph will need reconciliation on next seed.`;
784
+ console.error(`[admin-auth-store] action=add userId=${userIdShort} result=fail store=neo4j error=${errMsg}`);
785
+ return {
786
+ content: [{
787
+ type: "text",
788
+ text: `${TAG} users.json + account.json updated for userId ${userId} (PIN: ${plaintextPin}); Neo4j sync FAILED — manual reconciliation needed: ${errMsg}`,
789
+ }],
790
+ isError: true,
791
+ };
767
792
  }
768
- console.error(`${TAG} [admin-identity] adminuser-bound userId=${userId.slice(0, 8)} name=${name.trim()} personReused=${personReused}`);
793
+ console.error(`${TAG} [admin-identity] adminuser-bound userId=${userIdShort} name=${name.trim()} personReused=${personReused}`);
769
794
  console.error(`${TAG} admin added: userId=${userId} userName=${name.trim()} accountId=${ACCOUNT_ID} role=admin addedBy=${callerUserId ?? "unknown"}`);
770
795
  return {
771
796
  content: [{
772
797
  type: "text",
773
- text: `Admin added successfully.\n\n- **Name:** ${name.trim()}\n- **userId:** ${userId}\n- **PIN:** ${plaintextPin}\n- **Role:** admin\n\nShare the PIN with ${name.trim()} so they can log in.${neo4jWarning}`,
798
+ text: `Admin added successfully.\n\n- **Name:** ${name.trim()}\n- **userId:** ${userId}\n- **PIN:** ${plaintextPin}\n- **Role:** admin\n\nShare the PIN with ${name.trim()} so they can log in.`,
774
799
  }],
775
800
  };
776
801
  });
@@ -912,18 +937,22 @@ server.tool("admin-update-pin", "Update an existing admin user's PIN. Defaults t
912
937
  users = readUsersJson();
913
938
  }
914
939
  catch (err) {
940
+ const errMsg = err instanceof Error ? err.message : String(err);
915
941
  console.error(`${TAG} userId=${userIdLabel} result=user-not-found reason=users-json-read-failed`);
916
- return { content: [{ type: "text", text: `${TAG} ${err instanceof Error ? err.message : String(err)}` }], isError: true };
942
+ console.error(`[admin-auth-store] action=update-pin userId=${userIdLabel} result=fail store=users error=${errMsg}`);
943
+ return { content: [{ type: "text", text: `${TAG} ${errMsg}` }], isError: true };
917
944
  }
918
945
  const targetIndex = users.findIndex(u => u.userId === userId);
919
946
  if (targetIndex === -1) {
920
947
  console.error(`${TAG} userId=${userIdLabel} result=user-not-found`);
948
+ console.error(`[admin-auth-store] action=update-pin userId=${userIdLabel} result=fail store=users error=user-not-found`);
921
949
  return { content: [{ type: "text", text: `${TAG} User ${userId} not found in users.json.` }], isError: true };
922
950
  }
923
951
  const newHash = hashPin(newPin);
924
952
  const collidesWithOther = users.some((u, i) => i !== targetIndex && u.pin === newHash);
925
953
  if (collidesWithOther) {
926
954
  console.error(`${TAG} userId=${userIdLabel} result=collision`);
955
+ console.error(`[admin-auth-store] action=update-pin userId=${userIdLabel} result=fail store=users error=pin-collision`);
927
956
  return { content: [{ type: "text", text: `${TAG} That PIN is already in use by another user. Choose a different PIN.` }], isError: true };
928
957
  }
929
958
  users[targetIndex].pin = newHash;
@@ -931,9 +960,12 @@ server.tool("admin-update-pin", "Update an existing admin user's PIN. Defaults t
931
960
  writeUsersJson(users);
932
961
  }
933
962
  catch (err) {
934
- return { content: [{ type: "text", text: `${TAG} Failed to write users.json: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
963
+ const errMsg = err instanceof Error ? err.message : String(err);
964
+ console.error(`[admin-auth-store] action=update-pin userId=${userIdLabel} result=fail store=users error=${errMsg}`);
965
+ return { content: [{ type: "text", text: `${TAG} Failed to write users.json: ${errMsg}` }], isError: true };
935
966
  }
936
967
  console.error(`${TAG} userId=${userIdLabel} result=ok`);
968
+ console.error(`[admin-auth-store] action=update-pin userId=${userIdLabel} result=ok store=users`);
937
969
  const self = userId === callerUserId;
938
970
  return {
939
971
  content: [{ type: "text", text: `PIN updated${self ? "" : ` for userId ${userId}`}. The new PIN takes effect on the next login.` }],
@@ -1147,8 +1179,8 @@ server.tool("agent-list", "List all public (non-admin) agents with their full co
1147
1179
  };
1148
1180
  }
1149
1181
  });
1150
- server.tool("logs-read", "Read recent logs. Task 532: the stream logs (type=system/error/session/public) are now per-conversation — pass `conversationId` to retrieve a single conversation's log from first [spawn] to final [process-exit]. type=system: raw Claude stream-json + agent events + [tool-wait]/[tool-wait-diag]/[tool-wait-proc] telemetry + MCP server stderr via tee. type=session: SSE events sent to client. type=error: Claude subprocess stderr (raw — NODE_DEBUG HTTP/NET/UNDICI traces land in system via the stream tee, not here). type=heartbeat: platform event dispatcher (check-due-events cron). type=public: public agent diagnostic log. type=server: platform server log. type=mcp: MCP server stderr (per-plugin raw). type=vnc: VNC browser viewer lifecycle. type=review: log-review detector decisions. sessionKey: grep legacy sessionKey-tagged lines across all logs (useful for pre-Task-532 artefacts). When conversationId is provided, reads the single per-conversation file for the requested type (or dumps all type files for that conversationId if type is omitted).", {
1151
- type: z.enum(["system", "session", "error", "heartbeat", "public", "server", "mcp", "vnc", "review"]).optional(),
1182
+ server.tool("logs-read", "Read recent logs. Task 532: the stream logs (type=agent-stream/error/session/public) are now per-conversation — pass `conversationId` to retrieve a single conversation's log from first [spawn] to final [process-exit]. type=agent-stream: per-conversation tool-use/tool-result archive (every `[tool-use]` and `[tool-result]` pair with full input + output JSON, plus raw Claude stream-json, agent events, and MCP server stderr via tee). USE THIS when investigating what an agent ACTUALLY did with its tools — server.log only carries `[persist] tool-call persisted` markers, not bodies. (`type=system` is a backwards-compatible alias for the same archive.) type=session: SSE events sent to client. type=error: Claude subprocess stderr (raw — NODE_DEBUG HTTP/NET/UNDICI traces land in agent-stream via the stream tee, not here). type=heartbeat: platform event dispatcher (check-due-events cron). type=public: public agent diagnostic log. type=server: platform server log. type=mcp: MCP server stderr (per-plugin raw). type=vnc: VNC browser viewer lifecycle. type=review: log-review detector decisions. sessionKey: grep legacy sessionKey-tagged lines across all logs (useful for pre-Task-532 artefacts). When conversationId is provided, reads the single per-conversation file for the requested type (or dumps all type files for that conversationId if type is omitted).", {
1183
+ type: z.enum(["agent-stream", "system", "session", "error", "heartbeat", "public", "server", "mcp", "vnc", "review"]).optional(),
1152
1184
  lines: z.number().optional(),
1153
1185
  sessionKey: z.string().optional(),
1154
1186
  conversationId: z.string().optional(),
@@ -1181,16 +1213,17 @@ server.tool("logs-read", "Read recent logs. Task 532: the stream logs (type=syst
1181
1213
  return { content: [{ type: "text", text: `Log directory does not exist: ${LOG_DIR}` }] };
1182
1214
  }
1183
1215
  const prefixMap = {
1184
- system: "claude-agent-stream",
1216
+ "agent-stream": "claude-agent-stream",
1217
+ system: "claude-agent-stream", // Task 850 — backwards-compatible alias for agent-stream
1185
1218
  error: "claude-agent-stderr",
1186
1219
  session: "sse-events",
1187
1220
  public: "public-agent-stream",
1188
1221
  };
1189
- const resolvedType = type ?? "system";
1222
+ const resolvedType = type ?? "agent-stream";
1190
1223
  const prefix = prefixMap[resolvedType];
1191
1224
  if (!prefix) {
1192
1225
  return {
1193
- content: [{ type: "text", text: `type=${resolvedType} is not per-conversation. Valid per-conversation types: system, error, session, public. For platform-scoped types (server, vnc, review, heartbeat, mcp) omit conversationId.` }],
1226
+ content: [{ type: "text", text: `type=${resolvedType} is not per-conversation. Valid per-conversation types: agent-stream, error, session, public. For platform-scoped types (server, vnc, review, heartbeat, mcp) omit conversationId.` }],
1194
1227
  isError: true,
1195
1228
  };
1196
1229
  }
@@ -1234,7 +1267,8 @@ server.tool("logs-read", "Read recent logs. Task 532: the stream logs (type=syst
1234
1267
  // --- Session-key filtered mode: grep across log files ---
1235
1268
  if (sessionKey) {
1236
1269
  const prefixes = {
1237
- system: "claude-agent-stream-",
1270
+ "agent-stream": "claude-agent-stream-",
1271
+ system: "claude-agent-stream-", // Task 850 — backwards-compatible alias
1238
1272
  error: "claude-agent-stderr-",
1239
1273
  session: "sse-events-",
1240
1274
  public: "public-agent-stream-",
@@ -1332,7 +1366,7 @@ server.tool("logs-read", "Read recent logs. Task 532: the stream logs (type=syst
1332
1366
  return { content: [{ type: "text", text: `# Session timeline: ${sessionKey}\n\n${sections.join("\n\n")}` }] };
1333
1367
  }
1334
1368
  // --- Standard mode: tail most recent file of the requested type ---
1335
- const resolvedType = type ?? "system";
1369
+ const resolvedType = type ?? "agent-stream";
1336
1370
  // Heartbeat log is a single fixed file, not prefix-based
1337
1371
  if (resolvedType === "heartbeat") {
1338
1372
  const logFile = resolve(LOG_DIR, "check-due-events.log");
@@ -1381,6 +1415,8 @@ server.tool("logs-read", "Read recent logs. Task 532: the stream logs (type=syst
1381
1415
  }).toString();
1382
1416
  return { content: [{ type: "text", text: `# review.log\n\n${result}` }] };
1383
1417
  }
1418
+ // Task 850 — agent-stream and the legacy system alias both map to
1419
+ // claude-agent-stream-. The fall-through default also covers them.
1384
1420
  const prefix = resolvedType === "error" ? "claude-agent-stderr-"
1385
1421
  : resolvedType === "session" ? "sse-events-"
1386
1422
  : resolvedType === "public" ? "public-agent-stream-"