neuralos 2.8.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/LICENSE.md +201 -0
- package/README.md +346 -0
- package/bin/gybackend.js +385192 -0
- package/package.json +75 -0
- package/plugins/fraudops/index.d.mts +9 -0
- package/plugins/fraudops/index.mjs +185 -0
- package/plugins/fraudops/plugin.json +10 -0
- package/plugins/iam-connector/index.d.mts +10 -0
- package/plugins/iam-connector/index.mjs +229 -0
- package/plugins/iam-connector/plugin.json +10 -0
- package/plugins/netdata-rterm/index.mjs +229 -0
- package/plugins/netdata-rterm/netdata-rterm.extreme.spec.ts +252 -0
- package/plugins/netdata-rterm/plugin.json +22 -0
- package/plugins/patch-manager/index.d.mts +10 -0
- package/plugins/patch-manager/index.mjs +288 -0
- package/plugins/patch-manager/plugin.json +10 -0
- package/plugins/request-router/index.d.mts +9 -0
- package/plugins/request-router/index.mjs +239 -0
- package/plugins/request-router/plugin.json +10 -0
- package/plugins/sop-assistant/index.d.mts +9 -0
- package/plugins/sop-assistant/index.mjs +325 -0
- package/plugins/sop-assistant/plugin.json +10 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* patch-manager plugin — autonomous patch management for RTerm.
|
|
3
|
+
*
|
|
4
|
+
* Discovers patch status across hosts (yum/apt/Windows Update), plans patch
|
|
5
|
+
* deployments, executes via playbooks with MOP approval gates, and alerts on
|
|
6
|
+
* completion/failure. Combines RTerm's fleet orchestration, MOP change
|
|
7
|
+
* management, triggers, and audit trail into an autonomous patch pipeline.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// --- Pure: build the command to query patch status on a host ---
|
|
11
|
+
export function buildPatchStatusCommand(os) {
|
|
12
|
+
const o = String(os).toLowerCase()
|
|
13
|
+
if (o === 'windows' || o === 'win32') {
|
|
14
|
+
return 'powershell -Command "Get-WindowsUpdate -Verbose 2>&1 | Select-Object -First 50"'
|
|
15
|
+
}
|
|
16
|
+
if (o === 'linux' || o === 'darwin') {
|
|
17
|
+
// Try yum first, then apt
|
|
18
|
+
return '(yum check-update --quiet 2>/dev/null || apt list --upgradable 2>/dev/null) | head -50'
|
|
19
|
+
}
|
|
20
|
+
return 'echo "unsupported os: ' + o + '"'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// --- Pure: build the command to apply patches on a host ---
|
|
24
|
+
export function buildPatchApplyCommand(os, opts = {}) {
|
|
25
|
+
const o = String(os).toLowerCase()
|
|
26
|
+
const severity = opts.severity ?? 'all' // all, critical, security, recommended
|
|
27
|
+
const dryRun = opts.dryRun ?? false
|
|
28
|
+
|
|
29
|
+
if (o === 'windows' || o === 'win32') {
|
|
30
|
+
const kbFilter = severity === 'critical' ? '-KBArticleID "KB*"' : ''
|
|
31
|
+
const dryRunFlag = dryRun ? '-WhatIf' : ''
|
|
32
|
+
return `powershell -Command "Install-WindowsUpdate ${kbFilter} ${dryRunFlag} -AcceptAll -AutoReboot:$false 2>&1"`
|
|
33
|
+
}
|
|
34
|
+
if (o === 'linux') {
|
|
35
|
+
if (severity === 'security') {
|
|
36
|
+
return dryRun
|
|
37
|
+
? '(yum update --security --assumeno 2>/dev/null || apt-get -s upgrade --with-new-pkgs 2>/dev/null | grep -i security | head -20)'
|
|
38
|
+
: '(yum update --security -y 2>/dev/null || apt-get upgrade -y --with-new-pkgs 2>/dev/null) | tail -20'
|
|
39
|
+
}
|
|
40
|
+
return dryRun
|
|
41
|
+
? '(yum update --assumeno 2>/dev/null || apt-get -s upgrade 2>/dev/null) | tail -20'
|
|
42
|
+
: '(yum update -y 2>/dev/null || apt-get upgrade -y 2>/dev/null) | tail -20'
|
|
43
|
+
}
|
|
44
|
+
return 'echo "unsupported os: ' + o + '"'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// --- Pure: build the pre-patch verification command ---
|
|
48
|
+
export function buildPrePatchCheckCommand(os) {
|
|
49
|
+
const o = String(os).toLowerCase()
|
|
50
|
+
if (o === 'windows' || o === 'win32') {
|
|
51
|
+
return 'powershell -Command "Get-PSDrive -Name C | Select-Object Used,Free; Get-Service -Name wuauserv | Select-Object Status"'
|
|
52
|
+
}
|
|
53
|
+
if (o === 'linux') {
|
|
54
|
+
return 'df -h / | tail -1; systemctl is-system-running 2>/dev/null || true; uptime'
|
|
55
|
+
}
|
|
56
|
+
return 'echo "unsupported os: ' + o + '"'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// --- Pure: build the post-patch verification command ---
|
|
60
|
+
export function buildPostPatchCheckCommand(os) {
|
|
61
|
+
const o = String(os).toLowerCase()
|
|
62
|
+
if (o === 'windows' || o === 'win32') {
|
|
63
|
+
return 'powershell -Command "Get-WindowsUpdate -Verbose 2>&1 | Select-Object -First 10; Get-Service -Name wuauserv | Select-Object Status"'
|
|
64
|
+
}
|
|
65
|
+
if (o === 'linux') {
|
|
66
|
+
return 'echo "post-patch check: system running"; uptime; df -h / | tail -1'
|
|
67
|
+
}
|
|
68
|
+
return 'echo "unsupported os: ' + o + '"'
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// --- Pure: parse patch status output into structured results ---
|
|
72
|
+
export function parsePatchStatus(output, os) {
|
|
73
|
+
const o = String(os ?? '').toLowerCase()
|
|
74
|
+
const lines = String(output ?? '').split(/\r?\n/).filter((l) => l.trim().length > 0)
|
|
75
|
+
const patches = []
|
|
76
|
+
let summary = { total: 0, critical: 0, security: 0, recommended: 0 }
|
|
77
|
+
|
|
78
|
+
for (const line of lines) {
|
|
79
|
+
const l = line.trim()
|
|
80
|
+
// Windows: KB article lines
|
|
81
|
+
const kbMatch = l.match(/(KB\d{7})/i)
|
|
82
|
+
if (kbMatch) {
|
|
83
|
+
patches.push({
|
|
84
|
+
id: kbMatch[1],
|
|
85
|
+
title: l.replace(/KB\d{7}/i, '').trim().slice(0, 80),
|
|
86
|
+
severity: l.match(/critical/i) ? 'critical' : l.match(/security/i) ? 'security' : 'recommended',
|
|
87
|
+
os: 'windows',
|
|
88
|
+
})
|
|
89
|
+
summary.total++
|
|
90
|
+
if (l.match(/critical/i)) summary.critical++
|
|
91
|
+
if (l.match(/security/i)) summary.security++
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
// Linux yum: package.arch version repo
|
|
95
|
+
const yumMatch = l.match(/^(\S+)\.(\S+)\s+(\S+)\s+(\S+)/)
|
|
96
|
+
if (yumMatch && !l.startsWith('Loaded') && !l.startsWith('Last') && !l.startsWith('No')) {
|
|
97
|
+
patches.push({
|
|
98
|
+
id: yumMatch[1],
|
|
99
|
+
title: `${yumMatch[1]}.${yumMatch[2]} ${yumMatch[3]} (${yumMatch[4]})`,
|
|
100
|
+
severity: l.match(/security/i) ? 'security' : l.match(/critical/i) ? 'critical' : 'recommended',
|
|
101
|
+
os: 'linux',
|
|
102
|
+
})
|
|
103
|
+
summary.total++
|
|
104
|
+
if (l.match(/security/i)) summary.security++
|
|
105
|
+
if (l.match(/critical/i)) summary.critical++
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
// Linux apt: package/version
|
|
109
|
+
const aptMatch = l.match(/^(\S+)\/(\S+)\s+(\S+)\s+(\S+)/)
|
|
110
|
+
if (aptMatch && !l.startsWith('Listing')) {
|
|
111
|
+
patches.push({
|
|
112
|
+
id: aptMatch[1],
|
|
113
|
+
title: `${aptMatch[1]}/${aptMatch[2]} ${aptMatch[3]} (${aptMatch[4]})`,
|
|
114
|
+
severity: l.match(/security/i) ? 'security' : l.match(/critical/i) ? 'critical' : 'recommended',
|
|
115
|
+
os: 'linux',
|
|
116
|
+
})
|
|
117
|
+
summary.total++
|
|
118
|
+
if (l.match(/security/i)) summary.security++
|
|
119
|
+
if (l.match(/critical/i)) summary.critical++
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
summary.recommended = summary.total - summary.critical - summary.security
|
|
124
|
+
return { patches, summary }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// --- Pure: build a patch plan from patch status ---
|
|
128
|
+
export function buildPatchPlan(host, os, patchStatus, opts = {}) {
|
|
129
|
+
const severity = opts.severity ?? 'all'
|
|
130
|
+
const toApply = severity === 'all'
|
|
131
|
+
? patchStatus.patches
|
|
132
|
+
: patchStatus.patches.filter((p) => p.severity === severity)
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
host,
|
|
136
|
+
os,
|
|
137
|
+
patchesToApply: toApply.length,
|
|
138
|
+
patchIds: toApply.map((p) => p.id),
|
|
139
|
+
severity,
|
|
140
|
+
estimatedDowntimeMin: toApply.length * 2, // rough estimate
|
|
141
|
+
requiresReboot: os === 'windows' || toApply.some((p) => p.severity === 'critical'),
|
|
142
|
+
plan: [
|
|
143
|
+
{ step: 1, name: 'pre-check', command: buildPrePatchCheckCommand(os), description: 'Verify system health before patching' },
|
|
144
|
+
{ step: 2, name: 'backup', command: 'echo "snapshot/checkpoint before patch"', description: 'Create restore point' },
|
|
145
|
+
{ step: 3, name: 'apply', command: buildPatchApplyCommand(os, { severity }), description: `Apply ${toApply.length} patches (${severity})` },
|
|
146
|
+
{ step: 4, name: 'post-check', command: buildPostPatchCheckCommand(os), description: 'Verify patches applied successfully' },
|
|
147
|
+
{ step: 5, name: 'rollback', command: 'echo "rollback if post-check fails"', description: 'Rollback on failure', onError: 'continue' },
|
|
148
|
+
],
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// --- Pure: build a compliance report for a fleet ---
|
|
153
|
+
export function buildComplianceReport(hostStatuses) {
|
|
154
|
+
const hosts = Object.entries(hostStatuses ?? {}).map(([host, status]) => ({
|
|
155
|
+
host,
|
|
156
|
+
os: status.os ?? 'unknown',
|
|
157
|
+
totalPatches: status.summary?.total ?? 0,
|
|
158
|
+
criticalPatches: status.summary?.critical ?? 0,
|
|
159
|
+
securityPatches: status.summary?.security ?? 0,
|
|
160
|
+
compliant: (status.summary?.critical ?? 0) === 0 && (status.summary?.security ?? 0) === 0,
|
|
161
|
+
}))
|
|
162
|
+
|
|
163
|
+
const totalHosts = hosts.length
|
|
164
|
+
const compliantHosts = hosts.filter((h) => h.compliant).length
|
|
165
|
+
const nonCompliantHosts = totalHosts - compliantHosts
|
|
166
|
+
const totalCritical = hosts.reduce((s, h) => s + h.criticalPatches, 0)
|
|
167
|
+
const totalSecurity = hosts.reduce((s, h) => s + h.securityPatches, 0)
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
hosts,
|
|
171
|
+
summary: {
|
|
172
|
+
totalHosts,
|
|
173
|
+
compliantHosts,
|
|
174
|
+
nonCompliantHosts,
|
|
175
|
+
complianceRate: totalHosts > 0 ? Math.round((compliantHosts / totalHosts) * 100) : 100,
|
|
176
|
+
totalCriticalPatches: totalCritical,
|
|
177
|
+
totalSecurityPatches: totalSecurity,
|
|
178
|
+
},
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// --- Plugin entry ---
|
|
183
|
+
export function register(ctx) {
|
|
184
|
+
const { registerTool, registerTrigger, registerPanel, exec, readLedger, log } = ctx
|
|
185
|
+
|
|
186
|
+
// Tool: patch_status — query patch status of a host
|
|
187
|
+
registerTool({
|
|
188
|
+
name: 'patch_status',
|
|
189
|
+
description: 'Query the current patch status of a host (available updates). Returns patch list with severity classification.',
|
|
190
|
+
params: {
|
|
191
|
+
host: { type: 'string', description: 'Host to query' },
|
|
192
|
+
os: { type: 'string', description: 'OS type: linux, windows, darwin' },
|
|
193
|
+
},
|
|
194
|
+
handler: async (params) => {
|
|
195
|
+
const { host, os } = params ?? {}
|
|
196
|
+
if (!host || !os) return { error: 'host and os are required' }
|
|
197
|
+
const cmd = buildPatchStatusCommand(os)
|
|
198
|
+
log(`[patch-manager] querying patch status on ${host} (${os})`)
|
|
199
|
+
const output = await exec(cmd, { host })
|
|
200
|
+
const result = parsePatchStatus(output, os)
|
|
201
|
+
return { host, os, ...result }
|
|
202
|
+
},
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
// Tool: patch_plan — build a patch deployment plan for a host
|
|
206
|
+
registerTool({
|
|
207
|
+
name: 'patch_plan',
|
|
208
|
+
description: 'Build a patch deployment plan for a host (pre-check → backup → apply → post-check → rollback). Returns the plan for MOP approval.',
|
|
209
|
+
params: {
|
|
210
|
+
host: { type: 'string', description: 'Host to plan for' },
|
|
211
|
+
os: { type: 'string', description: 'OS type' },
|
|
212
|
+
severity: { type: 'string', description: 'Patch severity filter: all, critical, security, recommended' },
|
|
213
|
+
},
|
|
214
|
+
handler: async (params) => {
|
|
215
|
+
const { host, os, severity = 'all' } = params ?? {}
|
|
216
|
+
if (!host || !os) return { error: 'host and os are required' }
|
|
217
|
+
// First get current status
|
|
218
|
+
const statusCmd = buildPatchStatusCommand(os)
|
|
219
|
+
const statusOutput = await exec(statusCmd, { host })
|
|
220
|
+
const patchStatus = parsePatchStatus(statusOutput, os)
|
|
221
|
+
const plan = buildPatchPlan(host, os, patchStatus, { severity })
|
|
222
|
+
log(`[patch-manager] built patch plan for ${host}: ${plan.patchesToApply} patches`)
|
|
223
|
+
return plan
|
|
224
|
+
},
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
// Tool: patch_apply — execute a patch plan on a host (runs as MOP change)
|
|
228
|
+
registerTool({
|
|
229
|
+
name: 'patch_apply',
|
|
230
|
+
description: 'Execute a patch deployment on a host. This is a destructive operation — requires MOP approval. Returns the execution result.',
|
|
231
|
+
params: {
|
|
232
|
+
host: { type: 'string', description: 'Host to patch' },
|
|
233
|
+
os: { type: 'string', description: 'OS type' },
|
|
234
|
+
severity: { type: 'string', description: 'Patch severity filter' },
|
|
235
|
+
dryRun: { type: 'boolean', description: 'If true, simulate only (no changes)' },
|
|
236
|
+
},
|
|
237
|
+
handler: async (params) => {
|
|
238
|
+
const { host, os, severity = 'all', dryRun = false } = params ?? {}
|
|
239
|
+
if (!host || !os) return { error: 'host and os are required' }
|
|
240
|
+
const applyCmd = buildPatchApplyCommand(os, { severity, dryRun })
|
|
241
|
+
log(`[patch-manager] ${dryRun ? 'simulating' : 'applying'} patches on ${host} (${severity})`)
|
|
242
|
+
const output = await exec(applyCmd, { host })
|
|
243
|
+
const postCheck = await exec(buildPostPatchCheckCommand(os), { host })
|
|
244
|
+
return { host, os, severity, dryRun, output: output.slice(0, 2000), postCheck: postCheck.slice(0, 500) }
|
|
245
|
+
},
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
// Trigger: patch_failure — fires when a patch execution fails
|
|
249
|
+
registerTrigger({
|
|
250
|
+
name: 'patch_failure',
|
|
251
|
+
description: 'Fires when a patch execution output contains error/failure indicators. Use for auto-remediation or investigation.',
|
|
252
|
+
match: (event) => {
|
|
253
|
+
if (event?.source !== 'playbook') return false
|
|
254
|
+
const detail = String(event?.detail ?? '').toLowerCase()
|
|
255
|
+
return detail.includes('error') || detail.includes('failed') || detail.includes('rollback')
|
|
256
|
+
},
|
|
257
|
+
action: 'propose-change',
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
// Trigger: patch_completion — fires when a patch execution completes successfully
|
|
261
|
+
registerTrigger({
|
|
262
|
+
name: 'patch_completion',
|
|
263
|
+
description: 'Fires when a patch execution completes without errors. Use for compliance reporting.',
|
|
264
|
+
match: (event) => {
|
|
265
|
+
if (event?.source !== 'playbook') return false
|
|
266
|
+
const detail = String(event?.detail ?? '').toLowerCase()
|
|
267
|
+
return detail.includes('success') || detail.includes('completed') || detail.includes('no packages marked for update')
|
|
268
|
+
},
|
|
269
|
+
action: 'run-playbook',
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
// Panel: patch-compliance — patch compliance dashboard
|
|
273
|
+
registerPanel({
|
|
274
|
+
name: 'patch-compliance',
|
|
275
|
+
title: 'Patch Compliance',
|
|
276
|
+
render: (data) => {
|
|
277
|
+
const report = buildComplianceReport(data ?? {})
|
|
278
|
+
const rows = report.hosts.map((h) =>
|
|
279
|
+
`<tr><td>${h.host}</td><td>${h.os}</td><td>${h.totalPatches}</td><td>${h.criticalPatches}</td><td>${h.securityPatches}</td><td>${h.compliant ? '✅' : '❌'}</td></tr>`
|
|
280
|
+
).join('')
|
|
281
|
+
return `<div class="patch-compliance"><h3>Patch Compliance</h3><p>Compliance rate: ${report.summary.complianceRate}% (${report.summary.compliantHosts}/${report.summary.totalHosts} hosts)</p><p>Critical: ${report.summary.totalCriticalPatches} | Security: ${report.summary.totalSecurityPatches}</p><table><thead><tr><th>Host</th><th>OS</th><th>Total</th><th>Critical</th><th>Security</th><th>Compliant</th></tr></thead><tbody>${rows}</tbody></table></div>`
|
|
282
|
+
},
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
log('[patch-manager] registered: 3 tools, 2 triggers, 1 panel')
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export default { register, buildPatchStatusCommand, buildPatchApplyCommand, buildPrePatchCheckCommand, buildPostPatchCheckCommand, parsePatchStatus, buildPatchPlan, buildComplianceReport }
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "patch-manager",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Autonomous patch management for RTerm — discover patch status across hosts, plan patch deployments, execute via playbooks with MOP approval gates, and alert on completion/failure. Supports Linux (yum/apt) and Windows (Get-WindowsUpdate).",
|
|
5
|
+
"entry": "index.mjs",
|
|
6
|
+
"tools": ["patch_status", "patch_plan", "patch_apply"],
|
|
7
|
+
"triggers": ["patch_failure", "patch_completion"],
|
|
8
|
+
"panels": ["patch-compliance"],
|
|
9
|
+
"permissions": ["exec", "readLedger:metrics", "readLedger:incidents"]
|
|
10
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// request-router plugin type declarations
|
|
2
|
+
export function register(ctx: any): void
|
|
3
|
+
export function classifyRequest(request: any): string
|
|
4
|
+
export function routeRequest(request: any): { route: string; risk: string; reason: string }
|
|
5
|
+
export function buildRequestId(): string
|
|
6
|
+
export function buildApprovalRecord(requestId: string, approvedBy: string, rationale: string, decision: string): any
|
|
7
|
+
export function buildQueueEntry(request: any, requestId: string): any
|
|
8
|
+
export function filterQueue(queue: any[] | null | undefined, filter?: any): any[]
|
|
9
|
+
export default any
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* request-router plugin — automated request handling and approval workflow.
|
|
3
|
+
*
|
|
4
|
+
* Receives operational requests (access, restart, deploy, patch, custom),
|
|
5
|
+
* classifies risk, routes for approval (auto-approve/queue/MOP), executes
|
|
6
|
+
* end-to-end, and audits every step. Formalizes RTerm's existing command
|
|
7
|
+
* policy + MOP approval into a structured request pipeline.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// --- Pure: classify a request by risk ---
|
|
11
|
+
export function classifyRequest(request) {
|
|
12
|
+
const req = request ?? {}
|
|
13
|
+
const type = String(req.type ?? '').toLowerCase()
|
|
14
|
+
const urgency = String(req.urgency ?? 'low').toLowerCase()
|
|
15
|
+
const target = String(req.target ?? '').toLowerCase()
|
|
16
|
+
|
|
17
|
+
// Destructive operations = high risk
|
|
18
|
+
if (['delete', 'drop', 'purge', 'format', 'destroy'].some((w) => type.includes(w))) return 'high'
|
|
19
|
+
// Production targets = elevated risk
|
|
20
|
+
if (target.includes('prod') || target.includes('production')) {
|
|
21
|
+
if (['restart', 'stop', 'kill', 'deploy', 'patch', 'update'].some((w) => type.includes(w))) return 'high'
|
|
22
|
+
return 'medium'
|
|
23
|
+
}
|
|
24
|
+
// Restart/stop operations = medium risk
|
|
25
|
+
if (['restart', 'stop', 'kill', 'deploy', 'patch', 'update', 'reboot', 'shutdown'].some((w) => type.includes(w))) return 'medium'
|
|
26
|
+
// Access requests = medium risk
|
|
27
|
+
if (['access', 'grant', 'permission', 'role', 'sudo', 'admin'].some((w) => type.includes(w))) return 'medium'
|
|
28
|
+
// Read-only = low risk
|
|
29
|
+
if (['status', 'check', 'list', 'show', 'get', 'describe', 'read'].some((w) => type.includes(w))) return 'low'
|
|
30
|
+
// Default: medium
|
|
31
|
+
return 'medium'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// --- Pure: route a request based on risk ---
|
|
35
|
+
export function routeRequest(request) {
|
|
36
|
+
const req = request ?? {}
|
|
37
|
+
const risk = classifyRequest(req)
|
|
38
|
+
const urgency = String(req.urgency ?? 'low').toLowerCase()
|
|
39
|
+
|
|
40
|
+
if (risk === 'low') return { route: 'auto_approve', risk, reason: 'low-risk read-only operation' }
|
|
41
|
+
if (risk === 'medium') {
|
|
42
|
+
if (urgency === 'critical' || urgency === 'high') {
|
|
43
|
+
return { route: 'queue', risk, reason: 'medium-risk with high urgency — expedited approval' }
|
|
44
|
+
}
|
|
45
|
+
return { route: 'queue', risk, reason: 'medium-risk operation — requires operator approval' }
|
|
46
|
+
}
|
|
47
|
+
return { route: 'mop', risk, reason: 'high-risk operation — requires MOP change (plan → approve → run)' }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// --- Pure: build a request ID ---
|
|
51
|
+
export function buildRequestId() {
|
|
52
|
+
return `req-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// --- Pure: build an approval record ---
|
|
56
|
+
export function buildApprovalRecord(requestId, approvedBy, rationale, decision) {
|
|
57
|
+
return {
|
|
58
|
+
requestId,
|
|
59
|
+
approvedBy,
|
|
60
|
+
rationale,
|
|
61
|
+
decision, // approved | denied
|
|
62
|
+
at: Date.now(),
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// --- Pure: build a request queue entry ---
|
|
67
|
+
export function buildQueueEntry(request, requestId) {
|
|
68
|
+
const req = request ?? {}
|
|
69
|
+
const { route, risk, reason } = routeRequest(req)
|
|
70
|
+
return {
|
|
71
|
+
id: requestId,
|
|
72
|
+
type: req.type,
|
|
73
|
+
target: req.target,
|
|
74
|
+
justification: req.justification ?? '',
|
|
75
|
+
urgency: req.urgency ?? 'low',
|
|
76
|
+
risk,
|
|
77
|
+
route,
|
|
78
|
+
routeReason: reason,
|
|
79
|
+
status: route === 'auto_approve' ? 'auto_approved' : 'pending',
|
|
80
|
+
submittedBy: req.submittedBy ?? 'unknown',
|
|
81
|
+
submittedAt: Date.now(),
|
|
82
|
+
...(route === 'auto_approve' ? { approvedAt: Date.now(), approvedBy: 'system' } : {}),
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// --- Pure: filter the request queue ---
|
|
87
|
+
export function filterQueue(queue, filter = {}) {
|
|
88
|
+
let out = Array.isArray(queue) ? [...queue] : []
|
|
89
|
+
const f = filter ?? {}
|
|
90
|
+
if (f.status) out = out.filter((r) => r.status === f.status)
|
|
91
|
+
if (f.risk) out = out.filter((r) => r.risk === f.risk)
|
|
92
|
+
if (f.urgency) out = out.filter((r) => r.urgency === f.urgency)
|
|
93
|
+
if (f.target) out = out.filter((r) => String(r.target ?? '').includes(f.target))
|
|
94
|
+
return out
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// --- Plugin entry ---
|
|
98
|
+
export function register(ctx) {
|
|
99
|
+
const { registerTool, registerTrigger, registerPanel, exec, readLedger, log } = ctx
|
|
100
|
+
const requestQueue = []
|
|
101
|
+
|
|
102
|
+
// Tool: submit_request — submit an operational request
|
|
103
|
+
registerTool({
|
|
104
|
+
name: 'submit_request',
|
|
105
|
+
description: 'Submit an operational request for approval and execution. The request is classified by risk and routed: low-risk → auto-approve, medium-risk → queue for operator approval, high-risk → MOP change.',
|
|
106
|
+
params: {
|
|
107
|
+
type: { type: 'string', description: 'Request type: access, restart, deploy, patch, status, custom' },
|
|
108
|
+
target: { type: 'string', description: 'Target host or service' },
|
|
109
|
+
justification: { type: 'string', description: 'Business justification' },
|
|
110
|
+
urgency: { type: 'string', description: 'low, medium, high, critical' },
|
|
111
|
+
submittedBy: { type: 'string', description: 'Who is submitting the request' },
|
|
112
|
+
},
|
|
113
|
+
handler: async (params) => {
|
|
114
|
+
const requestId = buildRequestId()
|
|
115
|
+
const entry = buildQueueEntry(params, requestId)
|
|
116
|
+
requestQueue.push(entry)
|
|
117
|
+
log(`[request-router] ${entry.id}: ${entry.type} on ${entry.target} → ${entry.route} (${entry.risk} risk)`)
|
|
118
|
+
return { requestId, ...entry }
|
|
119
|
+
},
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
// Tool: approve_request — approve or deny a pending request
|
|
123
|
+
registerTool({
|
|
124
|
+
name: 'approve_request',
|
|
125
|
+
description: 'Approve or deny a pending request. Executes the request if approved. Records the approval in the audit trail.',
|
|
126
|
+
params: {
|
|
127
|
+
requestId: { type: 'string', description: 'Request ID to approve/deny' },
|
|
128
|
+
approvedBy: { type: 'string', description: 'Who is approving' },
|
|
129
|
+
rationale: { type: 'string', description: 'Reason for the decision' },
|
|
130
|
+
decision: { type: 'string', description: 'approved or denied' },
|
|
131
|
+
},
|
|
132
|
+
handler: async (params) => {
|
|
133
|
+
const { requestId, approvedBy, rationale, decision } = params ?? {}
|
|
134
|
+
if (!requestId || !approvedBy) return { error: 'requestId and approvedBy are required' }
|
|
135
|
+
const entry = requestQueue.find((r) => r.id === requestId)
|
|
136
|
+
if (!entry) return { error: `request ${requestId} not found` }
|
|
137
|
+
if (entry.status !== 'pending') return { error: `request ${requestId} is already ${entry.status}` }
|
|
138
|
+
|
|
139
|
+
const approval = buildApprovalRecord(requestId, approvedBy, rationale ?? '', decision ?? 'approved')
|
|
140
|
+
entry.status = approval.decision === 'approved' ? 'approved' : 'denied'
|
|
141
|
+
entry.approvedBy = approvedBy
|
|
142
|
+
entry.approvedAt = approval.at
|
|
143
|
+
entry.rationale = rationale
|
|
144
|
+
|
|
145
|
+
log(`[request-router] ${requestId} ${approval.decision} by ${approvedBy}`)
|
|
146
|
+
|
|
147
|
+
// If approved, execute the request
|
|
148
|
+
if (approval.decision === 'approved') {
|
|
149
|
+
const cmd = buildRequestCommand(entry)
|
|
150
|
+
if (cmd) {
|
|
151
|
+
const output = await exec(cmd, { host: entry.target })
|
|
152
|
+
entry.executionOutput = output.slice(0, 1000)
|
|
153
|
+
entry.executedAt = Date.now()
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return { requestId, ...entry, approval }
|
|
158
|
+
},
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
// Tool: list_requests — list requests in the queue
|
|
162
|
+
registerTool({
|
|
163
|
+
name: 'list_requests',
|
|
164
|
+
description: 'List requests in the queue with optional filters (status, risk, urgency, target).',
|
|
165
|
+
params: {
|
|
166
|
+
status: { type: 'string', description: 'Filter by status: pending, approved, denied, auto_approved' },
|
|
167
|
+
risk: { type: 'string', description: 'Filter by risk: low, medium, high' },
|
|
168
|
+
urgency: { type: 'string', description: 'Filter by urgency' },
|
|
169
|
+
target: { type: 'string', description: 'Filter by target (substring match)' },
|
|
170
|
+
},
|
|
171
|
+
handler: async (params) => {
|
|
172
|
+
const filtered = filterQueue(requestQueue, params ?? {})
|
|
173
|
+
return { total: filtered.length, requests: filtered }
|
|
174
|
+
},
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
// Tool: request_status — get the status of a specific request
|
|
178
|
+
registerTool({
|
|
179
|
+
name: 'request_status',
|
|
180
|
+
description: 'Get the status of a specific request by ID.',
|
|
181
|
+
params: { requestId: { type: 'string', description: 'Request ID' } },
|
|
182
|
+
handler: async (params) => {
|
|
183
|
+
const entry = requestQueue.find((r) => r.id === params?.requestId)
|
|
184
|
+
if (!entry) return { error: `request ${params?.requestId} not found` }
|
|
185
|
+
return entry
|
|
186
|
+
},
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
// Trigger: request_urgent — fires when a high-urgency request is submitted
|
|
190
|
+
registerTrigger({
|
|
191
|
+
name: 'request_urgent',
|
|
192
|
+
description: 'Fires when a request with critical/high urgency is submitted. Use for immediate notification.',
|
|
193
|
+
match: (event) => {
|
|
194
|
+
if (event?.source !== 'request-router') return false
|
|
195
|
+
return event.labels?.urgency === 'critical' || event.labels?.urgency === 'high'
|
|
196
|
+
},
|
|
197
|
+
action: 'run-playbook',
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
// Trigger: request_approved — fires when a request is approved
|
|
201
|
+
registerTrigger({
|
|
202
|
+
name: 'request_approved',
|
|
203
|
+
description: 'Fires when a request is approved. Use for post-approval automation.',
|
|
204
|
+
match: (event) => {
|
|
205
|
+
if (event?.source !== 'request-router') return false
|
|
206
|
+
return event.labels?.status === 'approved'
|
|
207
|
+
},
|
|
208
|
+
action: 'run-playbook',
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
// Panel: request-queue — request queue dashboard
|
|
212
|
+
registerPanel({
|
|
213
|
+
name: 'request-queue',
|
|
214
|
+
title: 'Request Queue',
|
|
215
|
+
render: (data) => {
|
|
216
|
+
const queue = Array.isArray(data) ? data : requestQueue
|
|
217
|
+
const rows = queue.map((r) =>
|
|
218
|
+
`<tr><td>${r.id}</td><td>${r.type}</td><td>${r.target}</td><td>${r.risk}</td><td>${r.urgency}</td><td>${r.status}</td><td>${r.submittedBy ?? ''}</td></tr>`
|
|
219
|
+
).join('')
|
|
220
|
+
return `<div class="request-queue"><h3>Request Queue</h3><p>Total: ${queue.length} | Pending: ${queue.filter((r) => r.status === 'pending').length} | Approved: ${queue.filter((r) => r.status === 'approved').length}</p><table><thead><tr><th>ID</th><th>Type</th><th>Target</th><th>Risk</th><th>Urgency</th><th>Status</th><th>By</th></tr></thead><tbody>${rows}</tbody></table></div>`
|
|
221
|
+
},
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
log('[request-router] registered: 4 tools, 2 triggers, 1 panel')
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// --- Pure: build the command to execute for an approved request ---
|
|
228
|
+
function buildRequestCommand(entry) {
|
|
229
|
+
const type = String(entry.type ?? '').toLowerCase()
|
|
230
|
+
const target = entry.target
|
|
231
|
+
if (type.includes('restart')) return `systemctl restart ${target} 2>/dev/null || Restart-Service ${target} -Force 2>/dev/null`
|
|
232
|
+
if (type.includes('stop')) return `systemctl stop ${target} 2>/dev/null || Stop-Service ${target} -Force 2>/dev/null`
|
|
233
|
+
if (type.includes('status')) return `systemctl status ${target} 2>/dev/null || Get-Service ${target} 2>/dev/null`
|
|
234
|
+
if (type.includes('deploy')) return `echo "deploying to ${target}"`
|
|
235
|
+
if (type.includes('patch')) return `echo "patching ${target}"`
|
|
236
|
+
return null // custom requests don't have a built-in command
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export default { register, classifyRequest, routeRequest, buildRequestId, buildApprovalRecord, buildQueueEntry, filterQueue }
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "request-router",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Automated request handling and approval workflow for RTerm — receive operational requests, classify risk, route for approval (auto-approve/queue/MOP), execute end-to-end, and audit every step.",
|
|
5
|
+
"entry": "index.mjs",
|
|
6
|
+
"tools": ["submit_request", "approve_request", "list_requests", "request_status"],
|
|
7
|
+
"triggers": ["request_urgent", "request_approved"],
|
|
8
|
+
"panels": ["request-queue"],
|
|
9
|
+
"permissions": ["exec", "readLedger:incidents"]
|
|
10
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// sop-assistant plugin type declarations
|
|
2
|
+
export function register(ctx: any): void
|
|
3
|
+
export function searchSops(query: string, sops?: any[]): Array<{ id: string; title: string; category: string; relevance: number; steps: number }>
|
|
4
|
+
export function getSop(id: string, sops?: any[]): any
|
|
5
|
+
export function searchIamPolicies(query: string, policies?: any[]): Array<{ id: string; title: string; category: string; relevance: number; rules: string[] }>
|
|
6
|
+
export function buildStepCommand(step: any, vars?: Record<string, any>): string
|
|
7
|
+
export const BUILTIN_SOPS: any[]
|
|
8
|
+
export const IAM_POLICIES: any[]
|
|
9
|
+
export default any
|