@pmoses-s1/s1-secops-mcp 1.3.0
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/CHANGELOG.md +119 -0
- package/README.md +519 -0
- package/deploy/README.md +370 -0
- package/deploy/bridge/README.md +93 -0
- package/deploy/bridge/sentinelone-mcp-bridge.mjs +122 -0
- package/deploy/caddy/Caddyfile.example +110 -0
- package/deploy/install.sh +280 -0
- package/deploy/systemd/s1-secops-mcp.service +59 -0
- package/index.js +171 -0
- package/lib/auth.js +161 -0
- package/lib/credentials.js +124 -0
- package/lib/hec.js +144 -0
- package/lib/http-transport.js +289 -0
- package/lib/s1.js +610 -0
- package/lib/sdl.js +130 -0
- package/lib/server-core.js +263 -0
- package/lib/stdio-transport.js +77 -0
- package/lib/uam-ingest.js +444 -0
- package/package.json +50 -0
- package/scripts/regen-readme-tools-table.mjs +142 -0
- package/scripts/smoke-test-http.sh +125 -0
- package/scripts/test-mac.sh +187 -0
- package/tools/hyperautomation.js +284 -0
- package/tools/mgmt-console.js +344 -0
- package/tools/powerquery.js +129 -0
- package/tools/sdl-api.js +125 -0
- package/tools/uam-ingest.js +128 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# smoke-test-http.sh -- generic HTTP smoke test for s1-secops-mcp.
|
|
4
|
+
#
|
|
5
|
+
# Exercises the public contract end-to-end:
|
|
6
|
+
# 1. healthz returns 200 (no auth)
|
|
7
|
+
# 2. initialize returns the expected protocol version and server info
|
|
8
|
+
# 3. tools/list returns 26 tools
|
|
9
|
+
# 4. tools/call s1_api_get works (uses /agents/count as a cheap probe)
|
|
10
|
+
# 5. bad bearer returns HTTP 401
|
|
11
|
+
# 6. unknown method returns JSON-RPC error -32601 inside a 200 envelope
|
|
12
|
+
#
|
|
13
|
+
# Useful for new team members verifying their setup, or for re-checking the
|
|
14
|
+
# deployment after a config change, a cert rotation, or an MCP version bump.
|
|
15
|
+
#
|
|
16
|
+
# Run as:
|
|
17
|
+
# MCP_HOST=mcp.s1.internal:8764 \
|
|
18
|
+
# MCP_BEARER=your-bearer-token \
|
|
19
|
+
# bash s1-secops-mcp/scripts/smoke-test-http.sh
|
|
20
|
+
#
|
|
21
|
+
# Or set defaults at the top of the script and run with no args.
|
|
22
|
+
|
|
23
|
+
set -uo pipefail
|
|
24
|
+
|
|
25
|
+
HOST="${MCP_HOST:-}"
|
|
26
|
+
TOKEN="${MCP_BEARER:-}"
|
|
27
|
+
|
|
28
|
+
if [[ -z "$HOST" || -z "$TOKEN" ]]; then
|
|
29
|
+
cat >&2 <<EOF
|
|
30
|
+
Usage:
|
|
31
|
+
MCP_HOST=<host:port> MCP_BEARER=<token> bash $0
|
|
32
|
+
|
|
33
|
+
Both env vars are required.
|
|
34
|
+
EOF
|
|
35
|
+
exit 2
|
|
36
|
+
fi
|
|
37
|
+
|
|
38
|
+
for tool in curl jq; do
|
|
39
|
+
if ! command -v "$tool" >/dev/null 2>&1; then
|
|
40
|
+
echo "Missing dependency: $tool" >&2
|
|
41
|
+
exit 3
|
|
42
|
+
fi
|
|
43
|
+
done
|
|
44
|
+
|
|
45
|
+
URL="https://$HOST/mcp"
|
|
46
|
+
AUTH="Authorization: Bearer $TOKEN"
|
|
47
|
+
JSON="Content-Type: application/json"
|
|
48
|
+
|
|
49
|
+
FAILED=0
|
|
50
|
+
fail() {
|
|
51
|
+
echo " FAIL: $*" >&2
|
|
52
|
+
FAILED=$((FAILED + 1))
|
|
53
|
+
}
|
|
54
|
+
pass() { echo " PASS: $*"; }
|
|
55
|
+
|
|
56
|
+
echo "=== 1. healthz (no auth) ==="
|
|
57
|
+
HEALTHZ_CODE=$(curl -s -o /dev/null -w "%{http_code}" "https://$HOST/healthz")
|
|
58
|
+
[[ "$HEALTHZ_CODE" == "200" ]] && pass "healthz returned 200" || fail "healthz returned $HEALTHZ_CODE (expected 200)"
|
|
59
|
+
|
|
60
|
+
echo
|
|
61
|
+
echo "=== 2. initialize ==="
|
|
62
|
+
INIT_BODY=$(curl -s -X POST "$URL" -H "$AUTH" -H "$JSON" -d '{
|
|
63
|
+
"jsonrpc": "2.0",
|
|
64
|
+
"id": 1,
|
|
65
|
+
"method": "initialize",
|
|
66
|
+
"params": {
|
|
67
|
+
"protocolVersion": "2024-11-05",
|
|
68
|
+
"capabilities": {},
|
|
69
|
+
"clientInfo": { "name": "smoke-test", "version": "1" }
|
|
70
|
+
}
|
|
71
|
+
}')
|
|
72
|
+
PROTO=$(echo "$INIT_BODY" | jq -r '.result.protocolVersion // "missing"')
|
|
73
|
+
NAME=$(echo "$INIT_BODY" | jq -r '.result.serverInfo.name // "missing"')
|
|
74
|
+
VER=$(echo "$INIT_BODY" | jq -r '.result.serverInfo.version // "missing"')
|
|
75
|
+
[[ "$PROTO" == "2024-11-05" ]] && pass "protocolVersion=$PROTO" || fail "protocolVersion=$PROTO"
|
|
76
|
+
[[ "$NAME" == "s1-secops-mcp-server" ]] && pass "serverInfo.name=$NAME" || fail "serverInfo.name=$NAME"
|
|
77
|
+
[[ "$VER" != "missing" ]] && pass "serverInfo.version=$VER" || fail "serverInfo.version missing"
|
|
78
|
+
|
|
79
|
+
echo
|
|
80
|
+
echo "=== 3. tools/list count ==="
|
|
81
|
+
TOOLS_COUNT=$(curl -s -X POST "$URL" -H "$AUTH" -H "$JSON" \
|
|
82
|
+
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' |
|
|
83
|
+
jq '.result.tools | length')
|
|
84
|
+
[[ "$TOOLS_COUNT" == "26" ]] && pass "tools/list returned 26 tools" || fail "tools/list returned $TOOLS_COUNT"
|
|
85
|
+
|
|
86
|
+
echo
|
|
87
|
+
echo "=== 4. tools/call s1_api_get on /agents/count ==="
|
|
88
|
+
AGENTS_TOTAL=$(curl -s -X POST "$URL" -H "$AUTH" -H "$JSON" -d '{
|
|
89
|
+
"jsonrpc": "2.0",
|
|
90
|
+
"id": 3,
|
|
91
|
+
"method": "tools/call",
|
|
92
|
+
"params": {
|
|
93
|
+
"name": "s1_api_get",
|
|
94
|
+
"arguments": { "path": "/web/api/v2.1/agents/count" }
|
|
95
|
+
}
|
|
96
|
+
}' | jq -r '.result.content[0].text' | jq -r '.data.total // "missing"')
|
|
97
|
+
if [[ "$AGENTS_TOTAL" =~ ^[0-9]+$ ]]; then
|
|
98
|
+
pass "s1_api_get returned $AGENTS_TOTAL agents"
|
|
99
|
+
else
|
|
100
|
+
fail "s1_api_get returned data.total=$AGENTS_TOTAL"
|
|
101
|
+
fi
|
|
102
|
+
|
|
103
|
+
echo
|
|
104
|
+
echo "=== 5. bad bearer (expect HTTP 401) ==="
|
|
105
|
+
BAD_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$URL" \
|
|
106
|
+
-H "Authorization: Bearer wrong-token-of-sufficient-length-1234567890" -H "$JSON" \
|
|
107
|
+
-d '{"jsonrpc":"2.0","id":99,"method":"tools/list"}')
|
|
108
|
+
[[ "$BAD_CODE" == "401" ]] && pass "bad bearer rejected with 401" || fail "bad bearer returned $BAD_CODE (expected 401)"
|
|
109
|
+
|
|
110
|
+
echo
|
|
111
|
+
echo "=== 6. method not found (expect -32601) ==="
|
|
112
|
+
ERR_CODE=$(curl -s -X POST "$URL" -H "$AUTH" -H "$JSON" \
|
|
113
|
+
-d '{"jsonrpc":"2.0","id":4,"method":"does/not/exist"}' |
|
|
114
|
+
jq -r '.error.code // "missing"')
|
|
115
|
+
[[ "$ERR_CODE" == "-32601" ]] && pass "unknown method returned -32601" || fail "unknown method returned code=$ERR_CODE"
|
|
116
|
+
|
|
117
|
+
echo
|
|
118
|
+
echo "=== Summary ==="
|
|
119
|
+
if [[ "$FAILED" -eq 0 ]]; then
|
|
120
|
+
echo "All checks passed."
|
|
121
|
+
exit 0
|
|
122
|
+
else
|
|
123
|
+
echo "$FAILED check(s) failed."
|
|
124
|
+
exit 1
|
|
125
|
+
fi
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# Mac validation script for s1-secops-mcp (version read from package.json).
|
|
4
|
+
#
|
|
5
|
+
# Runs the same test matrix as Linux:
|
|
6
|
+
# - syntax-check every .js/.mjs file
|
|
7
|
+
# - npm test (smoke + stdio + HTTP + regression suites)
|
|
8
|
+
# - regen:readme --check (no doc drift)
|
|
9
|
+
# - sanity-run the server in stdio and HTTP modes
|
|
10
|
+
#
|
|
11
|
+
# Run this from the s1-secops-mcp/ directory on your Mac:
|
|
12
|
+
#
|
|
13
|
+
# cd ~/path/to/claude-skills/s1-secops-mcp
|
|
14
|
+
# bash scripts/test-mac.sh
|
|
15
|
+
#
|
|
16
|
+
# All checks should pass with green PASS markers. Any FAIL line should be
|
|
17
|
+
# reported back so the issue can be diagnosed.
|
|
18
|
+
|
|
19
|
+
set -uo pipefail
|
|
20
|
+
|
|
21
|
+
PASS_COUNT=0
|
|
22
|
+
FAIL_COUNT=0
|
|
23
|
+
|
|
24
|
+
green() { printf '\033[32m%s\033[0m' "$*"; }
|
|
25
|
+
red() { printf '\033[31m%s\033[0m' "$*"; }
|
|
26
|
+
bold() { printf '\033[1m%s\033[0m' "$*"; }
|
|
27
|
+
|
|
28
|
+
pass() {
|
|
29
|
+
printf ' %s %s\n' "$(green PASS)" "$1"
|
|
30
|
+
PASS_COUNT=$((PASS_COUNT + 1))
|
|
31
|
+
}
|
|
32
|
+
fail() {
|
|
33
|
+
printf ' %s %s\n %s\n' "$(red FAIL)" "$1" "${2:-}"
|
|
34
|
+
FAIL_COUNT=$((FAIL_COUNT + 1))
|
|
35
|
+
}
|
|
36
|
+
step() { printf '\n%s\n' "$(bold "$1")"; }
|
|
37
|
+
|
|
38
|
+
# ─── 1. Environment ───────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
step "1. Environment"
|
|
41
|
+
if [[ "$(uname -s)" != "Darwin" ]]; then
|
|
42
|
+
echo " WARNING: this script targets macOS but you're on $(uname -s). Continuing anyway."
|
|
43
|
+
fi
|
|
44
|
+
if ! command -v node >/dev/null 2>&1; then
|
|
45
|
+
fail "node not on PATH" "Install Node 18+: brew install node@20"
|
|
46
|
+
exit 1
|
|
47
|
+
fi
|
|
48
|
+
NODE_MAJOR="$(node --version | sed 's/^v\([0-9]*\).*/\1/')"
|
|
49
|
+
if [[ "$NODE_MAJOR" -lt 18 ]]; then
|
|
50
|
+
fail "Node $(node --version) is too old" "Need Node 18+"
|
|
51
|
+
exit 1
|
|
52
|
+
fi
|
|
53
|
+
pass "Node $(node --version) on $(uname -srm)"
|
|
54
|
+
|
|
55
|
+
if [[ ! -f "index.js" ]]; then
|
|
56
|
+
fail "Not in the s1-secops-mcp directory" "cd to .../claude-skills/s1-secops-mcp first"
|
|
57
|
+
exit 1
|
|
58
|
+
fi
|
|
59
|
+
pass "running from s1-secops-mcp directory"
|
|
60
|
+
|
|
61
|
+
# ─── 2. Syntax check ──────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
step "2. Syntax check"
|
|
64
|
+
for f in index.js lib/*.js tests/*.mjs scripts/*.mjs; do
|
|
65
|
+
[[ -f "$f" ]] || continue
|
|
66
|
+
if node --check "$f" 2>/dev/null; then
|
|
67
|
+
pass "$f"
|
|
68
|
+
else
|
|
69
|
+
out="$(node --check "$f" 2>&1)"
|
|
70
|
+
fail "$f" "$out"
|
|
71
|
+
fi
|
|
72
|
+
done
|
|
73
|
+
|
|
74
|
+
# ─── 3. npm test (22 assertions across smoke + stdio + HTTP) ─────────────────
|
|
75
|
+
|
|
76
|
+
step "3. Test suite (npm test)"
|
|
77
|
+
# Node 18-22 default to the TAP reporter, Node 23+ default to spec. Both
|
|
78
|
+
# reporters print the same passing assertions but with different summary
|
|
79
|
+
# lines. We check exit code first (authoritative) and then count assertions.
|
|
80
|
+
TEST_LOG="/tmp/mcp-test-mac.npm-test.log"
|
|
81
|
+
if npm test >"$TEST_LOG" 2>&1; then
|
|
82
|
+
# Count passing assertions across both reporter formats:
|
|
83
|
+
# TAP: "ok 1 - <name>"
|
|
84
|
+
# spec: "✔ <name>" (U+2714 HEAVY CHECK MARK)
|
|
85
|
+
TAP_OKS="$(grep -cE '^ok [0-9]+' "$TEST_LOG" || true)"
|
|
86
|
+
SPEC_OKS="$(grep -cE '^[[:space:]]*✔' "$TEST_LOG" || true)"
|
|
87
|
+
TOTAL=$((TAP_OKS + SPEC_OKS))
|
|
88
|
+
if [[ "$TOTAL" -ge 22 ]]; then
|
|
89
|
+
pass "$TOTAL passing assertions (npm test exit 0)"
|
|
90
|
+
else
|
|
91
|
+
fail "npm test exited 0 but only $TOTAL passing lines found" "$(tail -30 "$TEST_LOG")"
|
|
92
|
+
fi
|
|
93
|
+
else
|
|
94
|
+
fail "npm test exited non-zero" "$(tail -30 "$TEST_LOG")"
|
|
95
|
+
fi
|
|
96
|
+
|
|
97
|
+
# ─── 4. README/code drift check ───────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
step "4. README/code drift check"
|
|
100
|
+
if npm run regen:readme -- --check >/dev/null 2>&1; then
|
|
101
|
+
pass "README tools table in sync with ALL_TOOLS"
|
|
102
|
+
else
|
|
103
|
+
fail "README tools table is stale" "Run: npm run regen:readme"
|
|
104
|
+
fi
|
|
105
|
+
|
|
106
|
+
# ─── 5. CLI flag sanity ───────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
step "5. CLI flag sanity"
|
|
109
|
+
# Read the expected version from package.json so this check never goes stale.
|
|
110
|
+
PKG_VERSION="$(node -p "require('./package.json').version")"
|
|
111
|
+
if [[ "$(node index.js --version 2>/dev/null)" == "$PKG_VERSION" ]]; then
|
|
112
|
+
pass "--version returns $PKG_VERSION"
|
|
113
|
+
else
|
|
114
|
+
fail "--version did not return $PKG_VERSION" "Got: $(node index.js --version 2>&1)"
|
|
115
|
+
fi
|
|
116
|
+
if node index.js --help 2>&1 | grep -q "s1-secops-mcp $PKG_VERSION"; then
|
|
117
|
+
pass "--help renders"
|
|
118
|
+
else
|
|
119
|
+
fail "--help broken" "$(node index.js --help 2>&1 | head -5)"
|
|
120
|
+
fi
|
|
121
|
+
|
|
122
|
+
# ─── 6. stdio round-trip ──────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
step "6. stdio round-trip"
|
|
125
|
+
STDIO_REPLY="$(printf '%s\n%s\n' \
|
|
126
|
+
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"mac-test","version":"1"}}}' \
|
|
127
|
+
'{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' |
|
|
128
|
+
node index.js 2>/dev/null)"
|
|
129
|
+
|
|
130
|
+
TOOL_COUNT="$(echo "$STDIO_REPLY" | tail -n 1 | node -e 'let s=""; process.stdin.on("data",d=>s+=d); process.stdin.on("end",()=>{try{console.log(JSON.parse(s).result.tools.length)}catch(e){console.log("ERR")}})')"
|
|
131
|
+
if [[ "$TOOL_COUNT" == "26" ]]; then
|
|
132
|
+
pass "stdio tools/list returned 26 tools"
|
|
133
|
+
else
|
|
134
|
+
fail "stdio tools/list returned $TOOL_COUNT (expected 26)" "$STDIO_REPLY"
|
|
135
|
+
fi
|
|
136
|
+
|
|
137
|
+
# ─── 7. HTTP transport round-trip ─────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
step "7. HTTP transport round-trip"
|
|
140
|
+
PORT=$((10000 + RANDOM % 1000))
|
|
141
|
+
node index.js --transport http --port "$PORT" --host 127.0.0.1 >/tmp/mcp-test-mac.log 2>&1 &
|
|
142
|
+
SERVER_PID=$!
|
|
143
|
+
trap 'kill $SERVER_PID 2>/dev/null || true' EXIT
|
|
144
|
+
|
|
145
|
+
# Wait for healthz
|
|
146
|
+
for i in $(seq 1 30); do
|
|
147
|
+
if curl -sf "http://127.0.0.1:$PORT/healthz" >/dev/null 2>&1; then
|
|
148
|
+
pass "HTTP server up on port $PORT"
|
|
149
|
+
break
|
|
150
|
+
fi
|
|
151
|
+
sleep 0.2
|
|
152
|
+
if [[ "$i" -eq 30 ]]; then
|
|
153
|
+
fail "HTTP server did not start within 6s" "$(cat /tmp/mcp-test-mac.log)"
|
|
154
|
+
exit 1
|
|
155
|
+
fi
|
|
156
|
+
done
|
|
157
|
+
|
|
158
|
+
HTTP_REPLY="$(curl -sf -X POST "http://127.0.0.1:$PORT/mcp" \
|
|
159
|
+
-H 'Content-Type: application/json' \
|
|
160
|
+
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}')"
|
|
161
|
+
HTTP_COUNT="$(echo "$HTTP_REPLY" | node -e 'let s=""; process.stdin.on("data",d=>s+=d); process.stdin.on("end",()=>{try{console.log(JSON.parse(s).result.tools.length)}catch(e){console.log("ERR")}})')"
|
|
162
|
+
if [[ "$HTTP_COUNT" == "26" ]]; then
|
|
163
|
+
pass "HTTP tools/list returned 26 tools"
|
|
164
|
+
else
|
|
165
|
+
fail "HTTP tools/list returned $HTTP_COUNT" "$HTTP_REPLY"
|
|
166
|
+
fi
|
|
167
|
+
|
|
168
|
+
# Auth: with no token loaded the server should accept unauthenticated requests
|
|
169
|
+
# (single-user local-only mode). Confirmed by the 26-tools response above.
|
|
170
|
+
pass "no-auth mode allows requests (single-user local)"
|
|
171
|
+
|
|
172
|
+
kill $SERVER_PID 2>/dev/null || true
|
|
173
|
+
trap - EXIT
|
|
174
|
+
|
|
175
|
+
# ─── Summary ──────────────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
step "Summary"
|
|
178
|
+
printf " Passed: %s\n" "$(green "$PASS_COUNT")"
|
|
179
|
+
if [[ "$FAIL_COUNT" -gt 0 ]]; then
|
|
180
|
+
printf " Failed: %s\n\n" "$(red "$FAIL_COUNT")"
|
|
181
|
+
echo "If any FAILs above, paste them so the issue can be diagnosed."
|
|
182
|
+
exit 1
|
|
183
|
+
else
|
|
184
|
+
printf " Failed: %s\n\n" "$FAIL_COUNT"
|
|
185
|
+
echo "$(green 'All checks passed on macOS.')"
|
|
186
|
+
exit 0
|
|
187
|
+
fi
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hyperautomation tools: hyperautomation skill
|
|
3
|
+
*
|
|
4
|
+
* Tools:
|
|
5
|
+
* ha_list_workflows List Hyperautomation workflows (with scope/state/sort filters)
|
|
6
|
+
* ha_get_workflow Get a single workflow by ID (+ optional revisionId)
|
|
7
|
+
* ha_delete_workflow Delete (soft, recoverable) a workflow via REST DELETE
|
|
8
|
+
* ha_import_workflow Import (create) a workflow from JSON (account- or site-scoped)
|
|
9
|
+
* ha_export_workflow Export all workflows as a ZIP archive
|
|
10
|
+
*
|
|
11
|
+
* API root (confirmed via live network capture 2026-05-03):
|
|
12
|
+
* /web/api/v2.1/hyper-automate/api/v1
|
|
13
|
+
*
|
|
14
|
+
* Single-workflow fetch requires BOTH workflowId AND revisionId:
|
|
15
|
+
* GET /workflows/single/{workflowId}/{revisionId}
|
|
16
|
+
* The revisionId is workflow.version_id in the list response.
|
|
17
|
+
*
|
|
18
|
+
* Deletion is a soft, recoverable HTTP DELETE (validated 2026-06-13):
|
|
19
|
+
* DELETE /workflows/{id}?accountIds=<acct> (or ?siteIds=<site>)
|
|
20
|
+
* The older POST /workflows/archive returns 500 on this tenant: do not use it.
|
|
21
|
+
*
|
|
22
|
+
* Import scope: the public import endpoint accepts ?accountIds=<acct> or
|
|
23
|
+
* ?siteIds=<site>; with no scope it returns a misleading 403 on a scoped tenant.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { apiGet, apiPost, apiDelete } from '../lib/s1.js';
|
|
27
|
+
|
|
28
|
+
// Confirmed base path from live network monitor (2026-05-03).
|
|
29
|
+
const HA_BASE = '/web/api/v2.1/hyper-automate/api/v1';
|
|
30
|
+
|
|
31
|
+
// Export/import confirmed on /public path during backtest; not re-captured under /v1.
|
|
32
|
+
const HA_PUBLIC = '/web/api/v2.1/hyper-automate/api/public';
|
|
33
|
+
|
|
34
|
+
export const tools = [
|
|
35
|
+
// ─── ha_list_workflows ────────────────────────────────────────────────────
|
|
36
|
+
{
|
|
37
|
+
name: 'ha_list_workflows',
|
|
38
|
+
description: `List SentinelOne Hyperautomation workflows. Returns workflow ID, version_id (revisionId for ha_get_workflow), name, state, status, trigger types, action types, scope, and timestamps. Supports filtering by siteId, state, and sorting. Use siteIds to scope to a specific site. State values: active, inactive, deactivated, draft. Requires Hyper Automate.view permission.`,
|
|
39
|
+
inputSchema: {
|
|
40
|
+
type: 'object',
|
|
41
|
+
properties: {
|
|
42
|
+
limit: {
|
|
43
|
+
type: 'number',
|
|
44
|
+
description: 'Max workflows per page (default 50, max 200).',
|
|
45
|
+
default: 50,
|
|
46
|
+
},
|
|
47
|
+
skip: {
|
|
48
|
+
type: 'number',
|
|
49
|
+
description: 'Offset for pagination (default 0).',
|
|
50
|
+
default: 0,
|
|
51
|
+
},
|
|
52
|
+
siteIds: {
|
|
53
|
+
type: 'string',
|
|
54
|
+
description: 'Comma-separated site IDs to scope results to (e.g. "<site-id-1>,<site-id-2>"). Omit for all accessible scopes.',
|
|
55
|
+
},
|
|
56
|
+
sortBy: {
|
|
57
|
+
type: 'string',
|
|
58
|
+
description: 'Sort field. Default: updated_at.',
|
|
59
|
+
enum: ['updated_at', 'created_at', 'name'],
|
|
60
|
+
default: 'updated_at',
|
|
61
|
+
},
|
|
62
|
+
sortOrder: {
|
|
63
|
+
type: 'string',
|
|
64
|
+
description: 'Sort direction.',
|
|
65
|
+
enum: ['asc', 'desc'],
|
|
66
|
+
default: 'desc',
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
required: [],
|
|
70
|
+
},
|
|
71
|
+
async handler({ limit = 50, skip = 0, siteIds, sortBy = 'updated_at', sortOrder = 'desc' } = {}) {
|
|
72
|
+
const params = {
|
|
73
|
+
limit: Math.min(limit, 200),
|
|
74
|
+
skip,
|
|
75
|
+
sortBy,
|
|
76
|
+
sortOrder,
|
|
77
|
+
};
|
|
78
|
+
if (siteIds) params.siteIds = siteIds;
|
|
79
|
+
const result = await apiGet(`${HA_BASE}/workflows`, params);
|
|
80
|
+
// Summarise for readability: include key fields the LLM needs for follow-up calls.
|
|
81
|
+
const items = (result?.data || []).map(item => ({
|
|
82
|
+
id: item.id,
|
|
83
|
+
revisionId: item.workflow?.version_id, // needed for ha_get_workflow
|
|
84
|
+
name: item.workflow?.name,
|
|
85
|
+
state: item.workflow?.state, // active | inactive | deactivated | draft
|
|
86
|
+
status: item.workflow?.status, // idle | running | etc.
|
|
87
|
+
scopeLevel: item.workflow?.scope_level, // account | site
|
|
88
|
+
scopeId: item.workflow?.scope_id,
|
|
89
|
+
siteName: item.workflow?.site_name,
|
|
90
|
+
createdAt: item.workflow?.created_at,
|
|
91
|
+
updatedAt: item.workflow?.updated_at,
|
|
92
|
+
versionCount: item.workflow?.version_count,
|
|
93
|
+
triggerTypes: [...new Set((item.actions || [])
|
|
94
|
+
.filter(a => a.type?.endsWith('_trigger'))
|
|
95
|
+
.map(a => a.type))],
|
|
96
|
+
actionTypes: [...new Set((item.actions || [])
|
|
97
|
+
.filter(a => !a.type?.endsWith('_trigger'))
|
|
98
|
+
.map(a => a.type))],
|
|
99
|
+
integrationIds: [...new Set((item.actions || [])
|
|
100
|
+
.filter(a => a.integration_id)
|
|
101
|
+
.map(a => a.integration_id))],
|
|
102
|
+
}));
|
|
103
|
+
return JSON.stringify({
|
|
104
|
+
workflows: items,
|
|
105
|
+
totalItems: result?.pagination?.totalItems ?? null,
|
|
106
|
+
skip,
|
|
107
|
+
limit: params.limit,
|
|
108
|
+
}, null, 2);
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
// ─── ha_get_workflow ──────────────────────────────────────────────────────
|
|
113
|
+
{
|
|
114
|
+
name: 'ha_get_workflow',
|
|
115
|
+
description: `Get a single Hyperautomation workflow by workflowId and revisionId. The revisionId (= workflow.version_id) is returned by ha_list_workflows; always pass both IDs for a direct fetch. If revisionId is omitted, the tool will scan the first page of workflows to find the current revision, which is slower. Returns the full workflow object including trigger configuration, action steps, integration dependencies, scope, and version metadata.`,
|
|
116
|
+
inputSchema: {
|
|
117
|
+
type: 'object',
|
|
118
|
+
properties: {
|
|
119
|
+
workflowId: {
|
|
120
|
+
type: 'string',
|
|
121
|
+
description: 'Hyperautomation workflow UUID (from ha_list_workflows).',
|
|
122
|
+
},
|
|
123
|
+
revisionId: {
|
|
124
|
+
type: 'string',
|
|
125
|
+
description: 'Workflow version/revision UUID (= workflow.version_id from ha_list_workflows). Provide this to avoid an extra list call.',
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
required: ['workflowId'],
|
|
129
|
+
},
|
|
130
|
+
async handler({ workflowId, revisionId }) {
|
|
131
|
+
let resolvedRevisionId = revisionId;
|
|
132
|
+
|
|
133
|
+
// If revisionId not supplied, resolve it from the list endpoint.
|
|
134
|
+
if (!resolvedRevisionId) {
|
|
135
|
+
// The list endpoint does not support filtering by workflowId, so we scan
|
|
136
|
+
// up to 200 workflows sorted by updated_at desc (most recently touched first).
|
|
137
|
+
// Confirmed: GET /workflows/single/{id} alone returns 404; revisionId is required.
|
|
138
|
+
const listResult = await apiGet(`${HA_BASE}/workflows`, {
|
|
139
|
+
limit: 200,
|
|
140
|
+
skip: 0,
|
|
141
|
+
sortBy: 'updated_at',
|
|
142
|
+
sortOrder: 'desc',
|
|
143
|
+
});
|
|
144
|
+
const found = (listResult?.data || []).find(item => item.id === workflowId);
|
|
145
|
+
if (!found) {
|
|
146
|
+
return JSON.stringify({
|
|
147
|
+
error: `Workflow ${workflowId} not found in the first 200 results. ` +
|
|
148
|
+
'Provide revisionId directly (from ha_list_workflows) for an exact fetch.',
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
resolvedRevisionId = found.workflow?.version_id;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Confirmed endpoint: GET /workflows/single/{workflowId}/{revisionId}
|
|
155
|
+
const result = await apiGet(`${HA_BASE}/workflows/single/${workflowId}/${resolvedRevisionId}`);
|
|
156
|
+
return JSON.stringify(result, null, 2);
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
// ─── ha_delete_workflow ───────────────────────────────────────────────────
|
|
161
|
+
{
|
|
162
|
+
name: 'ha_delete_workflow',
|
|
163
|
+
description: `Delete one or more Hyperautomation workflows. Uses the REST DELETE /hyper-automate/api/v1/workflows/{id} endpoint (validated 2026-06-13), a soft, recoverable delete (the console offers a "Restore workflow" action), equivalent to clicking Delete in the Hyperautomation UI. Scope the call to where the workflow lives with accountIds (account-scoped workflow) or siteIds (site-scoped); a 404 "Object not found" means the id is not under that scope or is already deleted. Requires Hyper Automate.write permission. NOTE: do NOT use the older POST /workflows/archive path; it returns 500 on this tenant.`,
|
|
164
|
+
inputSchema: {
|
|
165
|
+
type: 'object',
|
|
166
|
+
properties: {
|
|
167
|
+
workflowIds: {
|
|
168
|
+
type: 'array',
|
|
169
|
+
items: { type: 'string' },
|
|
170
|
+
description: 'One or more workflow UUIDs to delete (from ha_list_workflows).',
|
|
171
|
+
},
|
|
172
|
+
accountIds: {
|
|
173
|
+
type: 'string',
|
|
174
|
+
description: 'Account scope for an account-level workflow (e.g. "2046190533732727925"). Provide this OR siteIds.',
|
|
175
|
+
},
|
|
176
|
+
siteIds: {
|
|
177
|
+
type: 'string',
|
|
178
|
+
description: 'Site scope for a site-level workflow. Provide this OR accountIds.',
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
required: ['workflowIds'],
|
|
182
|
+
},
|
|
183
|
+
async handler({ workflowIds, accountIds, siteIds } = {}) {
|
|
184
|
+
if (!Array.isArray(workflowIds) || workflowIds.length === 0) {
|
|
185
|
+
return JSON.stringify({ error: 'workflowIds must be a non-empty array of UUIDs.' });
|
|
186
|
+
}
|
|
187
|
+
if (!accountIds && !siteIds) {
|
|
188
|
+
return JSON.stringify({ error: 'Provide accountIds or siteIds to scope the delete to where the workflow lives (a workflow created at a site must be deleted with siteIds; an account-scoped one with accountIds).' });
|
|
189
|
+
}
|
|
190
|
+
const scope = accountIds
|
|
191
|
+
? `accountIds=${encodeURIComponent(accountIds)}`
|
|
192
|
+
: `siteIds=${encodeURIComponent(siteIds)}`;
|
|
193
|
+
const results = [];
|
|
194
|
+
for (const id of workflowIds) {
|
|
195
|
+
try {
|
|
196
|
+
// DELETE returns 204 No Content on success.
|
|
197
|
+
await apiDelete(`${HA_BASE}/workflows/${encodeURIComponent(id)}?${scope}`);
|
|
198
|
+
results.push({ id, status: 'deleted' });
|
|
199
|
+
} catch (e) {
|
|
200
|
+
results.push({ id, status: 'error', error: e.message });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return JSON.stringify({ deleted: results }, null, 2);
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
// ─── ha_import_workflow ───────────────────────────────────────────────────
|
|
208
|
+
{
|
|
209
|
+
name: 'ha_import_workflow',
|
|
210
|
+
description: `Import a Hyperautomation workflow JSON into the SentinelOne console. Scope the import with accountIds (account-level) or siteIds (site-level); on a scoped tenant a bare import with no scope returns a misleading 403 "Insufficient permissions". The workflow JSON must follow the Hyperautomation schema (use the hyperautomation skill to generate valid JSON). Integration-backed actions (type=http_request with an integration_id) require pre-configured connections in Hyperautomation > Integrations before the workflow will run, and an imported flow lands as a private draft until published (publish endpoint) or activated. Returns the created workflow ID on success. Requires Hyper Automate.write permission.`,
|
|
211
|
+
inputSchema: {
|
|
212
|
+
type: 'object',
|
|
213
|
+
properties: {
|
|
214
|
+
workflowJson: {
|
|
215
|
+
type: 'string',
|
|
216
|
+
description: 'Full Hyperautomation workflow JSON as a string. Must be valid Hyperautomation schema. Generate this using the hyperautomation skill.',
|
|
217
|
+
},
|
|
218
|
+
accountIds: {
|
|
219
|
+
type: 'string',
|
|
220
|
+
description: 'Account scope for an account-level import (e.g. "2046190533732727925"). Provide this OR siteIds.',
|
|
221
|
+
},
|
|
222
|
+
siteIds: {
|
|
223
|
+
type: 'string',
|
|
224
|
+
description: 'Site scope for a site-level import. Provide this OR accountIds.',
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
required: ['workflowJson'],
|
|
228
|
+
},
|
|
229
|
+
async handler({ workflowJson, accountIds, siteIds }) {
|
|
230
|
+
let parsed;
|
|
231
|
+
try {
|
|
232
|
+
parsed = JSON.parse(workflowJson);
|
|
233
|
+
} catch (e) {
|
|
234
|
+
return JSON.stringify({ error: `Invalid JSON: ${e.message}` });
|
|
235
|
+
}
|
|
236
|
+
// Public import endpoint. Append the scope query param: account-level imports use
|
|
237
|
+
// ?accountIds=, site-level use ?siteIds=. A bare import (no scope) returns a misleading
|
|
238
|
+
// 403 on a scoped tenant (validated 2026-06-13).
|
|
239
|
+
let qs = '';
|
|
240
|
+
if (accountIds) qs = `?accountIds=${encodeURIComponent(accountIds)}`;
|
|
241
|
+
else if (siteIds) qs = `?siteIds=${encodeURIComponent(siteIds)}`;
|
|
242
|
+
const result = await apiPost(`${HA_PUBLIC}/workflow-import-export/import${qs}`, { data: parsed });
|
|
243
|
+
return JSON.stringify(result, null, 2);
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
// ─── ha_export_workflow ───────────────────────────────────────────────────
|
|
248
|
+
{
|
|
249
|
+
name: 'ha_export_workflow',
|
|
250
|
+
description: `Export all Hyperautomation workflows as a ZIP archive. Returns metadata about the ZIP (size, content-type) plus the first 200 bytes of the base64-encoded content. NOTE: The export API (confirmed via live backtest) returns ALL workflows; there is no per-workflow filter. Use ha_get_workflow to read a specific workflow's JSON definition instead. Export/import endpoints were not captured in the v1 network trace; this tool uses the confirmed /public path.`,
|
|
251
|
+
inputSchema: {
|
|
252
|
+
type: 'object',
|
|
253
|
+
properties: {},
|
|
254
|
+
required: [],
|
|
255
|
+
},
|
|
256
|
+
async handler() {
|
|
257
|
+
// Export path confirmed working during backtest at /public path.
|
|
258
|
+
// GET returns binary ZIP of ALL workflows; POST returns 405.
|
|
259
|
+
// Per-workflow filter is not supported by this API version.
|
|
260
|
+
const { getCreds } = await import('../lib/credentials.js');
|
|
261
|
+
const creds = getCreds();
|
|
262
|
+
const base = creds.S1_CONSOLE_URL.replace(/\/+$/, '');
|
|
263
|
+
const tok = creds.S1_CONSOLE_API_TOKEN;
|
|
264
|
+
const url = `${base}${HA_PUBLIC}/workflow-import-export/export`;
|
|
265
|
+
|
|
266
|
+
const res = await fetch(url, {
|
|
267
|
+
method: 'GET',
|
|
268
|
+
headers: { Authorization: `ApiToken ${tok}` },
|
|
269
|
+
});
|
|
270
|
+
if (!res.ok) {
|
|
271
|
+
const text = await res.text();
|
|
272
|
+
throw new Error(`ha_export_workflow → ${res.status}: ${text}`);
|
|
273
|
+
}
|
|
274
|
+
const buf = await res.arrayBuffer();
|
|
275
|
+
const base64 = Buffer.from(buf).toString('base64');
|
|
276
|
+
return JSON.stringify({
|
|
277
|
+
note: 'Export returns all workflows as a binary ZIP. Per-workflow filtering is not supported. METADATA ONLY: this tool does not return or persist the ZIP content; use the console UI or a direct API call with file output if you need the archive itself.',
|
|
278
|
+
contentType: res.headers.get('Content-Type') || 'application/zip',
|
|
279
|
+
sizeBytes: buf.byteLength,
|
|
280
|
+
base64Preview: base64.slice(0, 200) + '… [truncated]',
|
|
281
|
+
}, null, 2);
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
];
|