@rubytech/create-maxy 1.0.711 → 1.0.713
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/dist/index.js +38 -3
- package/package.json +2 -2
- package/payload/platform/lib/graph-search/dist/index.d.ts +22 -1
- package/payload/platform/lib/graph-search/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/graph-search/dist/index.js +69 -39
- package/payload/platform/lib/graph-search/dist/index.js.map +1 -1
- package/payload/platform/lib/graph-search/src/__tests__/bm25-label-gate.test.ts +88 -0
- package/payload/platform/lib/graph-search/src/__tests__/expand-batch.test.ts +206 -0
- package/payload/platform/lib/graph-search/src/index.ts +100 -43
- package/payload/platform/plugins/docs/references/platform.md +3 -1
- package/payload/platform/plugins/linkedin-import/PLUGIN.md +1 -0
- package/payload/platform/plugins/linkedin-import/skills/linkedin-import/SKILL.md +26 -5
- package/payload/platform/plugins/linkedin-import/skills/linkedin-import/references/connections.md +53 -82
- package/payload/platform/plugins/linkedin-import/skills/linkedin-import/references/profile.md +42 -49
- package/payload/platform/plugins/memory/PLUGIN.md +1 -0
- package/payload/platform/plugins/memory/mcp/dist/index.js +48 -0
- package/payload/platform/plugins/memory/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-archive-write.d.ts +33 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-archive-write.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-archive-write.js +229 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-archive-write.js.map +1 -0
- package/payload/platform/scripts/check-sdk-oauth.mjs +178 -0
- package/payload/platform/scripts/redact-install-logs.sh +85 -0
- package/payload/platform/scripts/setup.sh +20 -3
- package/payload/platform/scripts/verify-skill-tool-surface.sh +255 -0
- package/payload/platform/templates/specialists/agents/database-operator.md +6 -2
- package/payload/server/chunk-U5JPRUYZ.js +12298 -0
- package/payload/server/maxy-edge.js +1 -1
- package/payload/server/public/assets/{Checkbox-CjbS9JcG.js → Checkbox-Dr9MqNdk.js} +1 -1
- package/payload/server/public/assets/{admin-Ce9DbUuu.js → admin-CZ1QdDIj.js} +1 -1
- package/payload/server/public/assets/{data-C-SxjLC9.js → data-KcxxS-x3.js} +1 -1
- package/payload/server/public/assets/{file-D4cbAAuo.js → file-KlvYstdJ.js} +1 -1
- package/payload/server/public/assets/{graph-BNx6E7BH.js → graph-BjGlgDDX.js} +8 -8
- package/payload/server/public/assets/{house-CYsVygEQ.js → house-CyE0Xd3r.js} +1 -1
- package/payload/server/public/assets/{jsx-runtime-DPXE45W9.css → jsx-runtime-CPtXdEwZ.css} +1 -1
- package/payload/server/public/assets/{public-BTOF98iO.js → public-C1gnzTxk.js} +1 -1
- package/payload/server/public/assets/{share-2-B-sbkB36.js → share-2-Q9lo8ZrW.js} +1 -1
- package/payload/server/public/assets/{useVoiceRecorder-DLVFx3ms.js → useVoiceRecorder-BH8HP7l_.js} +1 -1
- package/payload/server/public/assets/{x-BNidzSAn.js → x-BwY4lg-U.js} +1 -1
- package/payload/server/public/data.html +6 -6
- package/payload/server/public/graph.html +7 -7
- package/payload/server/public/index.html +8 -8
- package/payload/server/public/public.html +5 -5
- package/payload/server/server.js +85 -56
- /package/payload/server/public/assets/{jsx-runtime-BUs3sHtV.js → jsx-runtime-BKpb2FvO.js} +0 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Task 746 — OAuth + wire-identity check for @anthropic-ai/claude-agent-sdk.
|
|
3
|
+
//
|
|
4
|
+
// One-shot spike. Pi-runnable. Intentionally orphan/standalone — not imported
|
|
5
|
+
// by any production module, not wired into any cron, hook, or route. Task 606
|
|
6
|
+
// owns SDK adoption in production; this script gates that adoption.
|
|
7
|
+
//
|
|
8
|
+
// Precondition (exact):
|
|
9
|
+
// ssh <pi> # see memory/reference_device_ssh.md for hosts
|
|
10
|
+
// cd /tmp && mkdir -p sdk-spike && cd sdk-spike
|
|
11
|
+
// npm install @anthropic-ai/claude-agent-sdk@0.2.119 # pin matches static evidence
|
|
12
|
+
// cp <repo>/platform/scripts/check-sdk-oauth.mjs .
|
|
13
|
+
// unset ANTHROPIC_API_KEY
|
|
14
|
+
// node check-sdk-oauth.mjs 2>&1 | tee spike.log
|
|
15
|
+
//
|
|
16
|
+
// PASS condition: apiKeySource ∈ {'oauth', 'none'}. Both indicate OAuth-only mode:
|
|
17
|
+
// 'oauth' = SDK supplied an OAuth-issued API key; 'none' = SDK supplied no key
|
|
18
|
+
// at all and the claude binary's own ~/.claude/.credentials.json OAuth state is
|
|
19
|
+
// in use. Cross-check with `claude --print --output-format json "Reply…"` to
|
|
20
|
+
// confirm the same value: parity = SDK is faithfully proxying claude's auth.
|
|
21
|
+
//
|
|
22
|
+
// Verdict reasons (exit 1 unless PASS):
|
|
23
|
+
// env-set ANTHROPIC_API_KEY present (operator must `unset`)
|
|
24
|
+
// no-oauth-credentials ~/.claude/.credentials.json unreadable, empty,
|
|
25
|
+
// or contains no OAuth-shaped fields (run `claude login`)
|
|
26
|
+
// exception:<msg> SDK import or runtime error
|
|
27
|
+
// no-system-init SDK never emitted system.init message
|
|
28
|
+
// wrong-api-key-source:<value> apiKeySource ∉ {oauth, none} — value verbatim;
|
|
29
|
+
// MISSING-FIELD if SDK message shape drifted
|
|
30
|
+
// (raw msg dumped on next line)
|
|
31
|
+
// assistant-error:<value> SDK reported assistant error: authentication_failed |
|
|
32
|
+
// billing_error | rate_limit | invalid_request |
|
|
33
|
+
// server_error | unknown | max_output_tokens
|
|
34
|
+
// no-response no assistant message arrived
|
|
35
|
+
// empty-response assistant arrived, no text content
|
|
36
|
+
// timeout 60s elapsed waiting for response
|
|
37
|
+
//
|
|
38
|
+
// Wire-identity (mitmproxy header diff vs `claude -p`) and subscription-billing
|
|
39
|
+
// (VNC dashboard) are operator-manual per Task 746 brief — not script-side.
|
|
40
|
+
// .docs/platform.md captures the consolidated verdict (script + mitm + VNC).
|
|
41
|
+
|
|
42
|
+
import { accessSync, readFileSync, constants as FS } from 'node:fs'
|
|
43
|
+
import { homedir } from 'node:os'
|
|
44
|
+
import { join } from 'node:path'
|
|
45
|
+
|
|
46
|
+
const TIMEOUT_MS = 60_000
|
|
47
|
+
const PROMPT = 'Reply with the literal string OK and nothing else.'
|
|
48
|
+
// Claude Code emits apiKeySource='none' when the SDK supplies no API key and
|
|
49
|
+
// the claude binary uses its own OAuth credentials at ~/.claude/.credentials.json.
|
|
50
|
+
// Brief assumed 'oauth' based on stale type-defs; runtime-correct OAuth-only
|
|
51
|
+
// indicator on Claude Code 2.1.x is 'none'. Both accepted; cross-check with
|
|
52
|
+
// `claude --print --output-format json` to confirm parity.
|
|
53
|
+
const OAUTH_API_KEY_SOURCES = new Set(['oauth', 'none'])
|
|
54
|
+
|
|
55
|
+
const log = (line) => process.stdout.write(line + '\n')
|
|
56
|
+
const safeStringify = (v) => { try { return JSON.stringify(v) } catch { return '<unstringifiable>' } }
|
|
57
|
+
|
|
58
|
+
let verdictEmitted = false
|
|
59
|
+
const fail = (reason) => {
|
|
60
|
+
if (verdictEmitted) return
|
|
61
|
+
verdictEmitted = true
|
|
62
|
+
log(`[sdk-spike] verdict: FAIL reason=${reason}`)
|
|
63
|
+
process.exit(1)
|
|
64
|
+
}
|
|
65
|
+
const pass = (note) => {
|
|
66
|
+
if (verdictEmitted) return
|
|
67
|
+
verdictEmitted = true
|
|
68
|
+
log(`[sdk-spike] verdict: PASS${note ? ` ${note}` : ''}`)
|
|
69
|
+
process.exit(0)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function main() {
|
|
73
|
+
const envState = process.env.ANTHROPIC_API_KEY ? 'set' : 'unset'
|
|
74
|
+
log(`[sdk-spike] env: ANTHROPIC_API_KEY=${envState}`)
|
|
75
|
+
if (envState === 'set') fail('env-set')
|
|
76
|
+
|
|
77
|
+
const credsPath = join(homedir(), '.claude', '.credentials.json')
|
|
78
|
+
try { accessSync(credsPath, FS.R_OK) }
|
|
79
|
+
catch { fail('no-oauth-credentials') }
|
|
80
|
+
|
|
81
|
+
let credsSize = 0
|
|
82
|
+
let credsHasOauthFields = false
|
|
83
|
+
let credsTopKeys = []
|
|
84
|
+
try {
|
|
85
|
+
const credsRaw = readFileSync(credsPath, 'utf8')
|
|
86
|
+
credsSize = credsRaw.length
|
|
87
|
+
const credsObj = JSON.parse(credsRaw)
|
|
88
|
+
credsTopKeys = Object.keys(credsObj).sort()
|
|
89
|
+
credsHasOauthFields = credsTopKeys.some((k) => /oauth|access|refresh|bearer/i.test(k)) ||
|
|
90
|
+
Object.values(credsObj).some((v) => v && typeof v === 'object' &&
|
|
91
|
+
Object.keys(v).some((k) => /oauth|access|refresh|bearer/i.test(k)))
|
|
92
|
+
} catch { /* malformed creds file — surface in log, fail with no-oauth-credentials below */ }
|
|
93
|
+
log(`[sdk-spike] creds: size=${credsSize} topKeys=${credsTopKeys.join(',')} hasOauthFields=${credsHasOauthFields}`)
|
|
94
|
+
if (credsSize === 0 || !credsHasOauthFields) fail('no-oauth-credentials')
|
|
95
|
+
|
|
96
|
+
let query
|
|
97
|
+
try {
|
|
98
|
+
({ query } = await import('@anthropic-ai/claude-agent-sdk'))
|
|
99
|
+
} catch (err) {
|
|
100
|
+
fail(`exception:${String(err?.message ?? err).slice(0, 200)}`)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let sdkVersion = '<unknown>'
|
|
104
|
+
try {
|
|
105
|
+
const pkgPath = join(process.cwd(), 'node_modules', '@anthropic-ai', 'claude-agent-sdk', 'package.json')
|
|
106
|
+
sdkVersion = JSON.parse(readFileSync(pkgPath, 'utf8')).version
|
|
107
|
+
} catch { /* leave as <unknown> — diagnostic, not load-bearing */ }
|
|
108
|
+
log(`[sdk-spike] sdk-version: ${sdkVersion}`)
|
|
109
|
+
|
|
110
|
+
let gotInit = false
|
|
111
|
+
let apiKeySource = ''
|
|
112
|
+
let apiProvider = ''
|
|
113
|
+
let model = ''
|
|
114
|
+
let sessionId = ''
|
|
115
|
+
let claudeCodeVersion = ''
|
|
116
|
+
let gotAssistant = false
|
|
117
|
+
let assistantError = ''
|
|
118
|
+
let responseText = ''
|
|
119
|
+
|
|
120
|
+
const result = query({ prompt: PROMPT })
|
|
121
|
+
|
|
122
|
+
let timeoutId
|
|
123
|
+
const timer = new Promise((_, reject) => {
|
|
124
|
+
timeoutId = setTimeout(() => reject(new Error(`timeout after ${TIMEOUT_MS}ms`)), TIMEOUT_MS)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
const consume = (async () => {
|
|
128
|
+
for await (const msg of result) {
|
|
129
|
+
if (msg?.type === 'system' && msg?.subtype === 'init') {
|
|
130
|
+
gotInit = true
|
|
131
|
+
// apiKeySource is required (sdk.d.ts:3286 — non-optional). MISSING-FIELD here = SDK shape drift, dump raw.
|
|
132
|
+
apiKeySource = msg.apiKeySource ?? msg.api_key_source ?? 'MISSING-FIELD'
|
|
133
|
+
// apiProvider is optional (sdk.d.ts:32 declares `?:`); absent on Claude Code 2.1.119. <absent> ≠ defect.
|
|
134
|
+
apiProvider = msg.apiProvider ?? msg.api_provider ?? '<absent>'
|
|
135
|
+
model = msg.model ?? '<unknown>'
|
|
136
|
+
sessionId = msg.session_id ?? msg.sessionId ?? '<unknown>'
|
|
137
|
+
claudeCodeVersion = msg.claude_code_version ?? '<unknown>'
|
|
138
|
+
log(`[sdk-spike] system-init: apiKeySource=${apiKeySource} apiProvider=${apiProvider} model=${model} sessionId=${sessionId} claudeCodeVersion=${claudeCodeVersion}`)
|
|
139
|
+
if (apiKeySource === 'MISSING-FIELD') {
|
|
140
|
+
log(`[sdk-spike] system-init-raw: ${safeStringify(msg)}`)
|
|
141
|
+
}
|
|
142
|
+
} else if (msg?.type === 'assistant') {
|
|
143
|
+
gotAssistant = true
|
|
144
|
+
if (msg.error && !assistantError) assistantError = msg.error
|
|
145
|
+
const blocks = msg.message?.content ?? []
|
|
146
|
+
for (const block of blocks) {
|
|
147
|
+
if (block?.type === 'text' && typeof block.text === 'string') {
|
|
148
|
+
responseText += block.text
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
})()
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
await Promise.race([consume, timer])
|
|
157
|
+
} catch (err) {
|
|
158
|
+
try { Promise.resolve(result.return?.()).catch(() => {}) } catch { /* best-effort cleanup */ }
|
|
159
|
+
if (String(err?.message ?? '').startsWith('timeout after')) fail('timeout')
|
|
160
|
+
fail(`exception:${String(err?.message ?? err).slice(0, 200)}`)
|
|
161
|
+
} finally {
|
|
162
|
+
clearTimeout(timeoutId)
|
|
163
|
+
try { Promise.resolve(result.return?.()).catch(() => {}) } catch { /* best-effort cleanup */ }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
log(`[sdk-spike] response: ${responseText}`)
|
|
167
|
+
|
|
168
|
+
if (!gotInit) fail('no-system-init')
|
|
169
|
+
if (!OAUTH_API_KEY_SOURCES.has(apiKeySource)) fail(`wrong-api-key-source:${apiKeySource}`)
|
|
170
|
+
if (assistantError) fail(`assistant-error:${assistantError}`)
|
|
171
|
+
if (!gotAssistant) fail('no-response')
|
|
172
|
+
if (responseText.trim() === '') fail('empty-response')
|
|
173
|
+
pass(`apiKeySource=${apiKeySource} apiProvider=${apiProvider} sdk=${sdkVersion} cli=${claudeCodeVersion}`)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
main().catch((err) => {
|
|
177
|
+
fail(`exception:${String(err?.message ?? err).slice(0, 200)}`)
|
|
178
|
+
})
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Existing-pi install-log redaction (Task 744).
|
|
3
|
+
#
|
|
4
|
+
# Idempotent one-shot remediation for Pis that completed installation BEFORE
|
|
5
|
+
# the install-log redaction landed at index.ts:152 / setup.sh:94. Scans every
|
|
6
|
+
# `install-*.log` in the configured logs directory and replaces every literal
|
|
7
|
+
# `set-initial-password ...<secret>` payload with `set-initial-password
|
|
8
|
+
# [REDACTED]`. Re-running the script is safe — already-redacted lines do not
|
|
9
|
+
# match the source pattern, so no further edits occur.
|
|
10
|
+
#
|
|
11
|
+
# Source patterns covered:
|
|
12
|
+
# 1. TS installer (packages/create-maxy/src/index.ts:152) — "[ISO] > sudo
|
|
13
|
+
# neo4j-admin dbms set-initial-password -- <secret>" or any args after
|
|
14
|
+
# "set-initial-password" (positional or "--" delimited).
|
|
15
|
+
# 2. Shell installer (platform/scripts/setup.sh, pre-fix variant) — "+ sudo
|
|
16
|
+
# neo4j-admin dbms set-initial-password <secret>" if bash -x had been on.
|
|
17
|
+
#
|
|
18
|
+
# A trailing marker line `[redact-install-logs] redacted=<n> file=<path>` is
|
|
19
|
+
# appended to each modified log so subsequent reads can identify which logs
|
|
20
|
+
# went through the remediation. Files with zero matches are left untouched.
|
|
21
|
+
#
|
|
22
|
+
# Default scan location: $HOME/.maxy/logs (the installer's LOG_DIR). Override
|
|
23
|
+
# with --dir <path> for non-default deployments.
|
|
24
|
+
|
|
25
|
+
set -euo pipefail
|
|
26
|
+
|
|
27
|
+
LOG_DIR="${HOME}/.maxy/logs"
|
|
28
|
+
|
|
29
|
+
while [ $# -gt 0 ]; do
|
|
30
|
+
case "$1" in
|
|
31
|
+
--dir) LOG_DIR="$2"; shift 2 ;;
|
|
32
|
+
--help|-h)
|
|
33
|
+
cat <<USAGE
|
|
34
|
+
Usage: redact-install-logs.sh [--dir <log-dir>]
|
|
35
|
+
|
|
36
|
+
Redacts neo4j set-initial-password secrets from install-*.log files.
|
|
37
|
+
Default --dir: \$HOME/.maxy/logs
|
|
38
|
+
|
|
39
|
+
Idempotent — safe to re-run.
|
|
40
|
+
USAGE
|
|
41
|
+
exit 0 ;;
|
|
42
|
+
*) echo "Unknown arg: $1" >&2; exit 2 ;;
|
|
43
|
+
esac
|
|
44
|
+
done
|
|
45
|
+
|
|
46
|
+
if [ ! -d "$LOG_DIR" ]; then
|
|
47
|
+
echo "[redact-install-logs] log dir not found: $LOG_DIR (nothing to do)"
|
|
48
|
+
exit 0
|
|
49
|
+
fi
|
|
50
|
+
|
|
51
|
+
shopt -s nullglob
|
|
52
|
+
TOTAL_FILES=0
|
|
53
|
+
TOTAL_REDACTIONS=0
|
|
54
|
+
|
|
55
|
+
for f in "$LOG_DIR"/install-*.log; do
|
|
56
|
+
[ -f "$f" ] || continue
|
|
57
|
+
TOTAL_FILES=$((TOTAL_FILES + 1))
|
|
58
|
+
|
|
59
|
+
# Pattern: any "set-initial-password" line followed by one or more args.
|
|
60
|
+
# The replacement keeps the leading prefix (timestamp + cmd up through
|
|
61
|
+
# set-initial-password and an optional "--") and substitutes everything
|
|
62
|
+
# after with [REDACTED]. We anchor the replacement only when the remaining
|
|
63
|
+
# tail is non-empty AND not already "[REDACTED]" — making the script idempotent.
|
|
64
|
+
REDACTED_THIS_FILE=$(
|
|
65
|
+
perl -ne '
|
|
66
|
+
if (/set-initial-password(\s+--)?\s+(\S.*)$/ && $2 ne "[REDACTED]") {
|
|
67
|
+
print STDOUT "1\n";
|
|
68
|
+
}
|
|
69
|
+
' "$f" | wc -l | tr -d ' '
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
if [ "$REDACTED_THIS_FILE" -gt 0 ]; then
|
|
73
|
+
perl -i -pe '
|
|
74
|
+
if (/set-initial-password(\s+--)?\s+(\S.*)$/ && $2 ne "[REDACTED]") {
|
|
75
|
+
s/set-initial-password(\s+--)?\s+\S.*$/set-initial-password${1} [REDACTED]/;
|
|
76
|
+
}
|
|
77
|
+
' "$f"
|
|
78
|
+
printf "[redact-install-logs] redacted=%d file=%s\n" "$REDACTED_THIS_FILE" "$f" >> "$f"
|
|
79
|
+
echo "[redact-install-logs] redacted=$REDACTED_THIS_FILE file=$f"
|
|
80
|
+
TOTAL_REDACTIONS=$((TOTAL_REDACTIONS + REDACTED_THIS_FILE))
|
|
81
|
+
fi
|
|
82
|
+
done
|
|
83
|
+
|
|
84
|
+
echo "[redact-install-logs] summary files_scanned=$TOTAL_FILES total_redactions=$TOTAL_REDACTIONS"
|
|
85
|
+
exit 0
|
|
@@ -86,12 +86,20 @@ else
|
|
|
86
86
|
# Configure Neo4j for local use
|
|
87
87
|
sudo sed -i 's/#server.default_listen_address=0.0.0.0/server.default_listen_address=127.0.0.1/' /etc/neo4j/neo4j.conf
|
|
88
88
|
|
|
89
|
-
# Generate a strong random password and store it
|
|
89
|
+
# Generate a strong random password and store it.
|
|
90
|
+
# Password handling block is set +x bracketed so even bash -x setup.sh
|
|
91
|
+
# cannot print the substituted secret. The password is written to
|
|
92
|
+
# platform/config/.neo4j-password (chmod 600) — the only readable source.
|
|
93
|
+
# set-initial-password reads the secret via $(cat ...) so the literal
|
|
94
|
+
# never appears on the parent shell's command line, and stdout is
|
|
95
|
+
# discarded so neo4j-admin's own echo cannot leak it either (Task 744).
|
|
96
|
+
{ set +x; } 2>/dev/null
|
|
90
97
|
NEO4J_GENERATED_PASSWORD=$(openssl rand -base64 32 | tr -d '/+=' | head -c 32)
|
|
91
98
|
mkdir -p "$INSTALL_DIR/platform/config"
|
|
92
|
-
|
|
99
|
+
printf '%s' "$NEO4J_GENERATED_PASSWORD" > "$INSTALL_DIR/platform/config/.neo4j-password"
|
|
93
100
|
chmod 600 "$INSTALL_DIR/platform/config/.neo4j-password"
|
|
94
|
-
|
|
101
|
+
unset NEO4J_GENERATED_PASSWORD
|
|
102
|
+
sudo neo4j-admin dbms set-initial-password "$(cat "$INSTALL_DIR/platform/config/.neo4j-password")" >/dev/null 2>&1
|
|
95
103
|
|
|
96
104
|
# Start and enable
|
|
97
105
|
sudo systemctl enable neo4j
|
|
@@ -139,6 +147,15 @@ else
|
|
|
139
147
|
cd "$INSTALL_DIR"
|
|
140
148
|
fi
|
|
141
149
|
|
|
150
|
+
# ------------------------------------------------------------------
|
|
151
|
+
# 6.5. Redact install-log credential leaks (Task 744 — idempotent).
|
|
152
|
+
# Pre-fix logs may contain plaintext neo4j passwords; this script scrubs
|
|
153
|
+
# every install-*.log to "[REDACTED]". Safe on already-clean logs.
|
|
154
|
+
# ------------------------------------------------------------------
|
|
155
|
+
if [ -x "$INSTALL_DIR/platform/scripts/redact-install-logs.sh" ]; then
|
|
156
|
+
bash "$INSTALL_DIR/platform/scripts/redact-install-logs.sh" || true
|
|
157
|
+
fi
|
|
158
|
+
|
|
142
159
|
# ------------------------------------------------------------------
|
|
143
160
|
# 7. Install dependencies and build
|
|
144
161
|
# ------------------------------------------------------------------
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Pre-publish acceptance gate (Task 744).
|
|
3
|
+
#
|
|
4
|
+
# Statically intersects what each shipped skill *prescribes* (every
|
|
5
|
+
# backtick-quoted `mcp__<server>__<tool>` token in SKILL.md and references/*.md)
|
|
6
|
+
# against the dispatched specialist's frontmatter `tools:` list. Catches the
|
|
7
|
+
# class of bug where a skill prescribes a tool the specialist does not have,
|
|
8
|
+
# and where a skill prescribes a forbidden direct-execution path
|
|
9
|
+
# (`cypher-shell`, `neo4j-admin` invocations, raw-Cypher DML in prose).
|
|
10
|
+
#
|
|
11
|
+
# Wired into the root `packages/create-maxy/package.json` `prepublishOnly`
|
|
12
|
+
# script so a regression cannot reach npm publish without firing.
|
|
13
|
+
#
|
|
14
|
+
# One stdout line per (skill, specialist) pair:
|
|
15
|
+
# [verify] skill=<plugin>/<skill> specialist=<n> resolved=<n>/<m> forbidden=<n>
|
|
16
|
+
#
|
|
17
|
+
# Exit 0: every prescribed token resolves AND no forbidden tokens.
|
|
18
|
+
# Exit 1: any unresolved or any forbidden — stderr names the offending token.
|
|
19
|
+
#
|
|
20
|
+
# Skill→specialist mapping comes from PLUGIN.md frontmatter `specialist:` field.
|
|
21
|
+
# Plugins without that field are admin-owned (loaded by the admin agent
|
|
22
|
+
# directly via plugin-read); for those the gate only enforces the forbidden-
|
|
23
|
+
# token rule, since admin's tool surface is the union of all enabled plugins.
|
|
24
|
+
|
|
25
|
+
set -euo pipefail
|
|
26
|
+
|
|
27
|
+
REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
|
|
28
|
+
cd "$REPO_ROOT"
|
|
29
|
+
|
|
30
|
+
python3 - <<'PYEOF'
|
|
31
|
+
import os, re, sys
|
|
32
|
+
|
|
33
|
+
REPO_ROOT = os.getcwd()
|
|
34
|
+
PLUGINS_DIR = os.path.join(REPO_ROOT, "platform", "plugins")
|
|
35
|
+
SPECIALISTS_DIR = os.path.join(REPO_ROOT, "platform", "templates", "specialists", "agents")
|
|
36
|
+
|
|
37
|
+
# Per-skill specialist ownership for plugins where the plugin itself is
|
|
38
|
+
# multi-purpose. The PLUGIN.md `specialist:` field handles single-owner
|
|
39
|
+
# plugins (linkedin-import → database-operator). Mixed-use plugins like
|
|
40
|
+
# `memory` declare per-skill ownership here.
|
|
41
|
+
EXPLICIT_OWNERSHIP = {
|
|
42
|
+
# plugin/skill -> specialist
|
|
43
|
+
"memory/document-ingest": "database-operator",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# Skills that are explicitly admin-owned (loaded via plugin-read by the admin
|
|
47
|
+
# agent itself, not delegated to a specialist). These get only the forbidden-
|
|
48
|
+
# token check since admin's effective tool set is the union of all enabled
|
|
49
|
+
# plugins.
|
|
50
|
+
ADMIN_OWNED_SKILLS = {
|
|
51
|
+
"memory/conversational-memory",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
TOKEN_RE = re.compile(r"`(mcp__[a-z][a-z0-9_-]*__[a-z][a-z0-9_-]*)`")
|
|
55
|
+
FENCED_BLOCK_RE = re.compile(r"```(?P<lang>[a-zA-Z]*)\n(?P<body>.*?)\n```", re.S)
|
|
56
|
+
PROSE_CYPHER_RE = re.compile(
|
|
57
|
+
r"`(?:MERGE|CREATE|DETACH\s+DELETE)\s+\(",
|
|
58
|
+
re.IGNORECASE,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def parse_frontmatter(path: str) -> dict | None:
|
|
63
|
+
"""Parse YAML-ish frontmatter without PyYAML — handles `key: value` and
|
|
64
|
+
`key:\n - item\n - item` shapes used in PLUGIN.md and specialist files."""
|
|
65
|
+
try:
|
|
66
|
+
text = open(path, encoding="utf-8").read()
|
|
67
|
+
except FileNotFoundError:
|
|
68
|
+
return None
|
|
69
|
+
m = re.match(r"^---\n(.*?)\n---", text, re.S)
|
|
70
|
+
if not m:
|
|
71
|
+
return None
|
|
72
|
+
block = m.group(1)
|
|
73
|
+
out: dict = {}
|
|
74
|
+
cur_key: str | None = None
|
|
75
|
+
cur_list: list[str] | None = None
|
|
76
|
+
for line in block.split("\n"):
|
|
77
|
+
if not line.strip():
|
|
78
|
+
continue
|
|
79
|
+
# List item under cur_key
|
|
80
|
+
if line.startswith(" - ") or line.startswith("- "):
|
|
81
|
+
if cur_list is None:
|
|
82
|
+
continue
|
|
83
|
+
cur_list.append(line.split("- ", 1)[1].strip())
|
|
84
|
+
continue
|
|
85
|
+
# Top-level key
|
|
86
|
+
m2 = re.match(r"^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$", line)
|
|
87
|
+
if not m2:
|
|
88
|
+
continue
|
|
89
|
+
cur_key = m2.group(1)
|
|
90
|
+
rhs = m2.group(2).strip()
|
|
91
|
+
if rhs:
|
|
92
|
+
# inline value — strip surrounding quotes
|
|
93
|
+
if (rhs.startswith('"') and rhs.endswith('"')) or (
|
|
94
|
+
rhs.startswith("'") and rhs.endswith("'")
|
|
95
|
+
):
|
|
96
|
+
rhs = rhs[1:-1]
|
|
97
|
+
out[cur_key] = rhs
|
|
98
|
+
cur_list = None
|
|
99
|
+
else:
|
|
100
|
+
cur_list = []
|
|
101
|
+
out[cur_key] = cur_list
|
|
102
|
+
return out
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def specialist_tools(specialist: str) -> set[str] | None:
|
|
106
|
+
fm = parse_frontmatter(os.path.join(SPECIALISTS_DIR, f"{specialist}.md"))
|
|
107
|
+
if fm is None:
|
|
108
|
+
return None
|
|
109
|
+
raw = fm.get("tools", "")
|
|
110
|
+
if isinstance(raw, list):
|
|
111
|
+
items = raw
|
|
112
|
+
else:
|
|
113
|
+
items = [t.strip() for t in str(raw).split(",")]
|
|
114
|
+
return {t for t in items if t}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def extract_prescribed_tokens(text: str) -> set[str]:
|
|
118
|
+
return set(TOKEN_RE.findall(text))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def extract_forbidden(text: str) -> list[tuple[str, str]]:
|
|
122
|
+
forbidden: list[tuple[str, str]] = []
|
|
123
|
+
|
|
124
|
+
# Forbidden invocations inside fenced shell blocks
|
|
125
|
+
for m in FENCED_BLOCK_RE.finditer(text):
|
|
126
|
+
lang = m.group("lang").lower()
|
|
127
|
+
body = m.group("body")
|
|
128
|
+
if lang in {"bash", "sh", "shell", "zsh"}:
|
|
129
|
+
if re.search(r"\bcypher-shell\b", body):
|
|
130
|
+
forbidden.append(("cypher-shell", "in fenced shell block"))
|
|
131
|
+
if re.search(r"\bneo4j-admin\s+dbms\b", body):
|
|
132
|
+
forbidden.append(("neo4j-admin", "in fenced shell block"))
|
|
133
|
+
|
|
134
|
+
# Strip fenced blocks for the prose-Cypher heuristic
|
|
135
|
+
prose = FENCED_BLOCK_RE.sub("", text)
|
|
136
|
+
if PROSE_CYPHER_RE.search(prose):
|
|
137
|
+
forbidden.append(("raw-cypher-dml", "in backtick-quoted prose"))
|
|
138
|
+
|
|
139
|
+
return forbidden
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def aggregate_skill_text(skill_dir: str) -> str:
|
|
143
|
+
out: list[str] = []
|
|
144
|
+
skill_md = os.path.join(skill_dir, "SKILL.md")
|
|
145
|
+
if not os.path.exists(skill_md):
|
|
146
|
+
return ""
|
|
147
|
+
out.append(open(skill_md, encoding="utf-8").read())
|
|
148
|
+
refs = os.path.join(skill_dir, "references")
|
|
149
|
+
if os.path.isdir(refs):
|
|
150
|
+
for name in sorted(os.listdir(refs)):
|
|
151
|
+
if name.endswith(".md"):
|
|
152
|
+
out.append(open(os.path.join(refs, name), encoding="utf-8").read())
|
|
153
|
+
return "\n".join(out)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def main() -> int:
|
|
157
|
+
if not os.path.isdir(PLUGINS_DIR):
|
|
158
|
+
print(f"[verify] PLUGINS_DIR not found: {PLUGINS_DIR}", file=sys.stderr)
|
|
159
|
+
return 1
|
|
160
|
+
if not os.path.isdir(SPECIALISTS_DIR):
|
|
161
|
+
print(f"[verify] SPECIALISTS_DIR not found: {SPECIALISTS_DIR}", file=sys.stderr)
|
|
162
|
+
return 1
|
|
163
|
+
|
|
164
|
+
summary: list[str] = []
|
|
165
|
+
errors: list[str] = []
|
|
166
|
+
pairs_checked = 0
|
|
167
|
+
|
|
168
|
+
for plugin in sorted(os.listdir(PLUGINS_DIR)):
|
|
169
|
+
pdir = os.path.join(PLUGINS_DIR, plugin)
|
|
170
|
+
if not os.path.isdir(pdir):
|
|
171
|
+
continue
|
|
172
|
+
plugin_fm = parse_frontmatter(os.path.join(pdir, "PLUGIN.md")) or {}
|
|
173
|
+
plugin_specialist = plugin_fm.get("specialist")
|
|
174
|
+
|
|
175
|
+
skills_dir = os.path.join(pdir, "skills")
|
|
176
|
+
if not os.path.isdir(skills_dir):
|
|
177
|
+
continue
|
|
178
|
+
for skill_name in sorted(os.listdir(skills_dir)):
|
|
179
|
+
sdir = os.path.join(skills_dir, skill_name)
|
|
180
|
+
if not os.path.isdir(sdir):
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
text = aggregate_skill_text(sdir)
|
|
184
|
+
if not text:
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
prescribed = extract_prescribed_tokens(text)
|
|
188
|
+
forbidden = extract_forbidden(text)
|
|
189
|
+
|
|
190
|
+
ownership_key = f"{plugin}/{skill_name}"
|
|
191
|
+
if ownership_key in ADMIN_OWNED_SKILLS:
|
|
192
|
+
specialist = None
|
|
193
|
+
else:
|
|
194
|
+
specialist = (
|
|
195
|
+
EXPLICIT_OWNERSHIP.get(ownership_key)
|
|
196
|
+
or plugin_specialist
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
if specialist is None:
|
|
200
|
+
# Admin-owned: only enforce forbidden-token rule.
|
|
201
|
+
for tok, ctx in forbidden:
|
|
202
|
+
errors.append(
|
|
203
|
+
f"[verify] skill={ownership_key} specialist=admin "
|
|
204
|
+
f"FORBIDDEN token={tok} context=\"{ctx}\""
|
|
205
|
+
)
|
|
206
|
+
summary.append(
|
|
207
|
+
f"[verify] skill={ownership_key} specialist=admin (admin-owned) "
|
|
208
|
+
f"tokens={len(prescribed)} forbidden={len(forbidden)}"
|
|
209
|
+
)
|
|
210
|
+
continue
|
|
211
|
+
|
|
212
|
+
tools = specialist_tools(specialist)
|
|
213
|
+
if tools is None:
|
|
214
|
+
errors.append(
|
|
215
|
+
f"[verify] skill={ownership_key} specialist={specialist} "
|
|
216
|
+
f"ERROR specialist frontmatter not parseable"
|
|
217
|
+
)
|
|
218
|
+
continue
|
|
219
|
+
|
|
220
|
+
unresolved = sorted(prescribed - tools)
|
|
221
|
+
for tok in unresolved:
|
|
222
|
+
errors.append(
|
|
223
|
+
f"[verify] skill={ownership_key} specialist={specialist} "
|
|
224
|
+
f"unresolved={tok}"
|
|
225
|
+
)
|
|
226
|
+
for tok, ctx in forbidden:
|
|
227
|
+
errors.append(
|
|
228
|
+
f"[verify] skill={ownership_key} specialist={specialist} "
|
|
229
|
+
f"FORBIDDEN token={tok} context=\"{ctx}\""
|
|
230
|
+
)
|
|
231
|
+
summary.append(
|
|
232
|
+
f"[verify] skill={ownership_key} specialist={specialist} "
|
|
233
|
+
f"resolved={len(prescribed) - len(unresolved)}/{len(prescribed)} "
|
|
234
|
+
f"forbidden={len(forbidden)}"
|
|
235
|
+
)
|
|
236
|
+
pairs_checked += 1
|
|
237
|
+
|
|
238
|
+
for line in summary:
|
|
239
|
+
print(line)
|
|
240
|
+
|
|
241
|
+
if errors:
|
|
242
|
+
for line in errors:
|
|
243
|
+
print(line, file=sys.stderr)
|
|
244
|
+
print(
|
|
245
|
+
f"[verify] FAIL pairs_checked={pairs_checked} errors={len(errors)}",
|
|
246
|
+
file=sys.stderr,
|
|
247
|
+
)
|
|
248
|
+
return 1
|
|
249
|
+
|
|
250
|
+
print(f"[verify] OK pairs_checked={pairs_checked}")
|
|
251
|
+
return 0
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
sys.exit(main())
|
|
255
|
+
PYEOF
|
|
@@ -3,7 +3,7 @@ name: database-operator
|
|
|
3
3
|
description: "Document and archive ingestion and ad-hoc graph operations — running the universal `document-ingest` skill for any unstructured document (PDF, text, transcript, web page, audio, video) and per-source archive-import skills (LinkedIn Basic Data Export today; CRM-type seed archives as each plugin ships), plus operator-driven graph hygiene (prune orphans, deduplicate entities, add edges, normalise labels). Delegate when the operator uploads any document, drops an archive directory into chat, or asks for any graph operation that is not a routine per-turn write."
|
|
4
4
|
summary: "Ingests every unstructured document and external archive into your graph (LinkedIn today; other CRM sources in future) and handles ad-hoc graph tidy-ups on request. For example, when you upload a CV, a pricing guide, or a contract; when you drop a LinkedIn export folder into chat; or when you ask to prune orphan nodes, merge duplicate people, or add edges between entities."
|
|
5
5
|
model: claude-sonnet-4-6
|
|
6
|
-
tools: Read, Bash, Glob, Grep, mcp__graph__maxy-graph-read_neo4j_cypher, mcp__graph__maxy-graph-get_neo4j_schema, mcp__memory__memory-write, mcp__memory__memory-update, mcp__memory__memory-delete, mcp__memory__memory-search, mcp__memory__memory-rank, mcp__memory__memory-reindex, mcp__memory__memory-find-candidates, mcp__memory__memory-ingest, mcp__memory__memory-ingest-extract, mcp__memory__memory-ingest-web, mcp__memory__memory-classify, mcp__memory__graph-prune-denylist-list, mcp__memory__graph-prune-denylist-add, mcp__memory__graph-prune-denylist-remove, mcp__contacts__contact-create, mcp__contacts__contact-update, mcp__contacts__contact-lookup, mcp__contacts__contact-list, mcp__admin__file-attach, mcp__admin__plugin-read
|
|
6
|
+
tools: Read, Bash, Glob, Grep, mcp__graph__maxy-graph-read_neo4j_cypher, mcp__graph__maxy-graph-get_neo4j_schema, mcp__memory__memory-write, mcp__memory__memory-update, mcp__memory__memory-delete, mcp__memory__memory-search, mcp__memory__memory-rank, mcp__memory__memory-reindex, mcp__memory__memory-find-candidates, mcp__memory__memory-ingest, mcp__memory__memory-ingest-extract, mcp__memory__memory-ingest-web, mcp__memory__memory-classify, mcp__memory__memory-archive-write, mcp__memory__graph-prune-denylist-list, mcp__memory__graph-prune-denylist-add, mcp__memory__graph-prune-denylist-remove, mcp__contacts__contact-create, mcp__contacts__contact-update, mcp__contacts__contact-lookup, mcp__contacts__contact-list, mcp__admin__file-attach, mcp__admin__plugin-read
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# Database Operator
|
|
@@ -12,7 +12,7 @@ You own document and archive ingestion and ad-hoc graph operations. You receive
|
|
|
12
12
|
|
|
13
13
|
## Prerogatives
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
Four rules govern every turn. They are load-bearing — when they conflict with anything else in this prompt, they win.
|
|
16
16
|
|
|
17
17
|
**PRECISE.** Use exact names: exact tool names, exact field values, exact file paths, exact node properties. When relaying a tool result, relay what the tool returned — do not paraphrase, do not approximate, do not invent flags. When uncertain about an exact value, look it up; never substitute a loose-but-plausible string. *Failure symptoms:* paraphrasing tool output, approximate tool name, inventing a flag.
|
|
18
18
|
|
|
@@ -26,6 +26,10 @@ Three rules govern every turn. They are load-bearing — when they conflict with
|
|
|
26
26
|
|
|
27
27
|
A landfill graph defeats EVIDENCE-BASED: search returns noise, the agent re-writes the noise, the noise compounds. Compress on write; filter on read.
|
|
28
28
|
|
|
29
|
+
**LOUD-FAIL.** If a dispatched skill prescribes a tool not present in your live tool surface, or a credential not provided in your tool input, terminate with a structured blocker — never improvise via Bash, never search the filesystem for credentials, never construct a parallel write path. Return: `Skill <name> prescribes <tool/credential>; not available. Cannot proceed. Operator must <remediation>.` Identical doctrine to Task 740 classifier failure and Task 560 graph-MCP loud-fail. *Failure symptoms:* `cypher-shell` invocation, `find … neo4j` / `grep … NEO4J_PASSWORD` filesystem probes, `curl` against Neo4j HTTP endpoints, any Bash improvisation that recreates the missing tool's effect.
|
|
30
|
+
|
|
31
|
+
The pre-publish gate (`platform/scripts/verify-skill-tool-surface.sh`) statically asserts every shipped skill's prescribed `mcp__*` tokens resolve against your frontmatter `tools:` list, so a missing tool is a build error, not a production discovery. LOUD-FAIL is the runtime backstop when that gate is bypassed (e.g. operator-edited skill).
|
|
32
|
+
|
|
29
33
|
---
|
|
30
34
|
|
|
31
35
|
## Output contract
|