ghost-dragon 4.2.1
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/.github/workflows/ci.yml +23 -0
- package/CHANGELOG.md +96 -0
- package/README.md +193 -0
- package/bootstrap.ps1 +83 -0
- package/bootstrap.sh +71 -0
- package/dist/agent/loop.d.ts +68 -0
- package/dist/agent/loop.d.ts.map +1 -0
- package/dist/agent/loop.js +135 -0
- package/dist/agent/mcp.d.ts +33 -0
- package/dist/agent/mcp.d.ts.map +1 -0
- package/dist/agent/mcp.js +107 -0
- package/dist/agent/session.d.ts +16 -0
- package/dist/agent/session.d.ts.map +1 -0
- package/dist/agent/session.js +55 -0
- package/dist/agent/skills.d.ts +36 -0
- package/dist/agent/skills.d.ts.map +1 -0
- package/dist/agent/skills.js +153 -0
- package/dist/agent/stack.d.ts +21 -0
- package/dist/agent/stack.d.ts.map +1 -0
- package/dist/agent/stack.js +158 -0
- package/dist/agent/task.d.ts +21 -0
- package/dist/agent/task.d.ts.map +1 -0
- package/dist/agent/task.js +45 -0
- package/dist/agent/tools.d.ts +44 -0
- package/dist/agent/tools.d.ts.map +1 -0
- package/dist/agent/tools.js +262 -0
- package/dist/agent/trace.d.ts +34 -0
- package/dist/agent/trace.d.ts.map +1 -0
- package/dist/agent/trace.js +72 -0
- package/dist/agent.d.ts +46 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/agent.js +103 -0
- package/dist/auth.d.ts +74 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +116 -0
- package/dist/brain/anthropic.d.ts +19 -0
- package/dist/brain/anthropic.d.ts.map +1 -0
- package/dist/brain/anthropic.js +74 -0
- package/dist/brain/claude-cli.d.ts +20 -0
- package/dist/brain/claude-cli.d.ts.map +1 -0
- package/dist/brain/claude-cli.js +79 -0
- package/dist/brain/ghost-ember.d.ts +28 -0
- package/dist/brain/ghost-ember.d.ts.map +1 -0
- package/dist/brain/ghost-ember.js +97 -0
- package/dist/brain/index.d.ts +22 -0
- package/dist/brain/index.d.ts.map +1 -0
- package/dist/brain/index.js +95 -0
- package/dist/brain/openai-compat.d.ts +21 -0
- package/dist/brain/openai-compat.d.ts.map +1 -0
- package/dist/brain/openai-compat.js +119 -0
- package/dist/brain/router/classify.d.ts +23 -0
- package/dist/brain/router/classify.d.ts.map +1 -0
- package/dist/brain/router/classify.js +160 -0
- package/dist/brain/router/execute.d.ts +23 -0
- package/dist/brain/router/execute.d.ts.map +1 -0
- package/dist/brain/router/execute.js +84 -0
- package/dist/brain/router/index.d.ts +26 -0
- package/dist/brain/router/index.d.ts.map +1 -0
- package/dist/brain/router/index.js +118 -0
- package/dist/brain/router/routing-memory.d.ts +27 -0
- package/dist/brain/router/routing-memory.d.ts.map +1 -0
- package/dist/brain/router/routing-memory.js +77 -0
- package/dist/brain/router/select.d.ts +32 -0
- package/dist/brain/router/select.d.ts.map +1 -0
- package/dist/brain/router/select.js +146 -0
- package/dist/brain/router/two-hop.d.ts +23 -0
- package/dist/brain/router/two-hop.d.ts.map +1 -0
- package/dist/brain/router/two-hop.js +39 -0
- package/dist/brain/router/verify.d.ts +37 -0
- package/dist/brain/router/verify.d.ts.map +1 -0
- package/dist/brain/router/verify.js +111 -0
- package/dist/brain/types.d.ts +55 -0
- package/dist/brain/types.d.ts.map +1 -0
- package/dist/brain/types.js +16 -0
- package/dist/brain/worker.d.ts +27 -0
- package/dist/brain/worker.d.ts.map +1 -0
- package/dist/brain/worker.js +71 -0
- package/dist/commands/ai.d.ts +24 -0
- package/dist/commands/ai.d.ts.map +1 -0
- package/dist/commands/ai.js +137 -0
- package/dist/commands/alerts.d.ts +19 -0
- package/dist/commands/alerts.d.ts.map +1 -0
- package/dist/commands/alerts.js +114 -0
- package/dist/commands/billing.d.ts +13 -0
- package/dist/commands/billing.d.ts.map +1 -0
- package/dist/commands/billing.js +55 -0
- package/dist/commands/chat.d.ts +22 -0
- package/dist/commands/chat.d.ts.map +1 -0
- package/dist/commands/chat.js +422 -0
- package/dist/commands/config.d.ts +18 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/config.js +136 -0
- package/dist/commands/doctor.d.ts +11 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +73 -0
- package/dist/commands/global.d.ts +11 -0
- package/dist/commands/global.d.ts.map +1 -0
- package/dist/commands/global.js +253 -0
- package/dist/commands/keep.d.ts +12 -0
- package/dist/commands/keep.d.ts.map +1 -0
- package/dist/commands/keep.js +58 -0
- package/dist/commands/lifecycle.d.ts +17 -0
- package/dist/commands/lifecycle.d.ts.map +1 -0
- package/dist/commands/lifecycle.js +267 -0
- package/dist/commands/login.d.ts +16 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/login.js +234 -0
- package/dist/commands/maintenance.d.ts +12 -0
- package/dist/commands/maintenance.d.ts.map +1 -0
- package/dist/commands/maintenance.js +76 -0
- package/dist/commands/mcp.d.ts +16 -0
- package/dist/commands/mcp.d.ts.map +1 -0
- package/dist/commands/mcp.js +56 -0
- package/dist/commands/memory.d.ts +13 -0
- package/dist/commands/memory.d.ts.map +1 -0
- package/dist/commands/memory.js +218 -0
- package/dist/commands/osint.d.ts +14 -0
- package/dist/commands/osint.d.ts.map +1 -0
- package/dist/commands/osint.js +161 -0
- package/dist/commands/pentest.d.ts +13 -0
- package/dist/commands/pentest.d.ts.map +1 -0
- package/dist/commands/pentest.js +131 -0
- package/dist/commands/scale.d.ts +14 -0
- package/dist/commands/scale.d.ts.map +1 -0
- package/dist/commands/scale.js +191 -0
- package/dist/commands/serve.d.ts +16 -0
- package/dist/commands/serve.d.ts.map +1 -0
- package/dist/commands/serve.js +167 -0
- package/dist/commands/tui.d.ts +17 -0
- package/dist/commands/tui.d.ts.map +1 -0
- package/dist/commands/tui.js +138 -0
- package/dist/commands/wyrm.d.ts +20 -0
- package/dist/commands/wyrm.d.ts.map +1 -0
- package/dist/commands/wyrm.js +274 -0
- package/dist/config.d.ts +67 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +54 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +85 -0
- package/dist/manifest.d.ts +31 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +83 -0
- package/dist/ui.d.ts +57 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +174 -0
- package/dist/utils.d.ts +33 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +155 -0
- package/dist/wyrm/mcp.d.ts +37 -0
- package/dist/wyrm/mcp.d.ts.map +1 -0
- package/dist/wyrm/mcp.js +137 -0
- package/docs/SYSTEM-PREMORTEM.md +397 -0
- package/dragon-manifest.toml +241 -0
- package/dragon.py +177 -0
- package/install/launchd/lk.ghosts.dragonkeep.plist +57 -0
- package/install/systemd/dragonkeep.service +40 -0
- package/media/dragon-silver-lockup.svg +931 -0
- package/media/dragon-silver-mark.svg +931 -0
- package/media/dragon-silver.png +0 -0
- package/package.json +45 -0
- package/specs/001-godmode/constitution.md +54 -0
- package/specs/001-godmode/plan.md +30 -0
- package/specs/001-godmode/spec.md +64 -0
- package/specs/001-godmode/tasks.md +35 -0
- package/specs/002-premortem-positioning/premortem.md +211 -0
- package/src/agent/loop.ts +165 -0
- package/src/agent/mcp.ts +92 -0
- package/src/agent/session.ts +48 -0
- package/src/agent/skills.ts +138 -0
- package/src/agent/stack.ts +154 -0
- package/src/agent/task.ts +55 -0
- package/src/agent/tools.ts +255 -0
- package/src/agent/trace.ts +76 -0
- package/src/agent.ts +114 -0
- package/src/auth.ts +133 -0
- package/src/brain/anthropic.ts +83 -0
- package/src/brain/claude-cli.ts +78 -0
- package/src/brain/ghost-ember.ts +94 -0
- package/src/brain/index.ts +99 -0
- package/src/brain/openai-compat.ts +115 -0
- package/src/brain/router/classify.ts +167 -0
- package/src/brain/router/execute.ts +80 -0
- package/src/brain/router/index.ts +125 -0
- package/src/brain/router/routing-memory.ts +71 -0
- package/src/brain/router/select.ts +156 -0
- package/src/brain/router/two-hop.ts +62 -0
- package/src/brain/router/verify.ts +123 -0
- package/src/brain/types.ts +61 -0
- package/src/brain/worker.ts +72 -0
- package/src/commands/ai.ts +144 -0
- package/src/commands/alerts.ts +131 -0
- package/src/commands/billing.ts +59 -0
- package/src/commands/chat.ts +318 -0
- package/src/commands/config.ts +137 -0
- package/src/commands/doctor.ts +71 -0
- package/src/commands/global.ts +256 -0
- package/src/commands/keep.ts +67 -0
- package/src/commands/lifecycle.ts +273 -0
- package/src/commands/login.ts +184 -0
- package/src/commands/maintenance.ts +54 -0
- package/src/commands/mcp.ts +57 -0
- package/src/commands/memory.ts +229 -0
- package/src/commands/osint.ts +171 -0
- package/src/commands/pentest.ts +140 -0
- package/src/commands/scale.ts +185 -0
- package/src/commands/serve.ts +171 -0
- package/src/commands/tui.ts +126 -0
- package/src/commands/wyrm.ts +269 -0
- package/src/config.ts +93 -0
- package/src/index.ts +92 -0
- package/src/manifest.ts +104 -0
- package/src/ui.ts +188 -0
- package/src/utils.ts +153 -0
- package/src/wyrm/mcp.ts +130 -0
- package/test/auth.test.ts +70 -0
- package/test/brain.test.ts +39 -0
- package/test/security.test.ts +104 -0
- package/test/skills.test.ts +38 -0
- package/test/ui.test.ts +46 -0
- package/tsconfig.json +19 -0
- package/worker/package-lock.json +1527 -0
- package/worker/package.json +17 -0
- package/worker/src/index.ts +76 -0
- package/worker/tsconfig.json +15 -0
- package/worker/wrangler.toml +26 -0
package/src/agent/mcp.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP hub — connect ANY Model Context Protocol server (configured via `dragon mcp
|
|
3
|
+
* add`) and expose its tools to the agent. Dragon inherits the entire MCP ecosystem
|
|
4
|
+
* without forking: filesystem, GitHub, Postgres, Playwright, your own servers…
|
|
5
|
+
*
|
|
6
|
+
* Tools are namespaced `<server>__<tool>` to avoid collisions with built-in tools and
|
|
7
|
+
* across servers. Everything is bounded (connect + call timeouts) and best-effort — an
|
|
8
|
+
* unreachable server never blocks the agent.
|
|
9
|
+
*
|
|
10
|
+
* Copyright 2026 Ghost Protocol (Pvt) Ltd. All Rights Reserved.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
14
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
|
15
|
+
import type { ToolSpec } from '../brain/types.js'
|
|
16
|
+
|
|
17
|
+
export interface McpServerConfig { command: string; args?: string[]; env?: Record<string, string> }
|
|
18
|
+
|
|
19
|
+
const CONNECT_TIMEOUT = 8_000
|
|
20
|
+
const CALL_TIMEOUT = 30_000
|
|
21
|
+
|
|
22
|
+
function withTimeout<T>(p: Promise<T>, ms: number, onTimeout?: () => void): Promise<T | null> {
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
let done = false
|
|
25
|
+
const timer = setTimeout(() => { if (!done) { done = true; onTimeout?.(); resolve(null) } }, ms)
|
|
26
|
+
p.then((v) => { if (!done) { done = true; clearTimeout(timer); resolve(v) } }, () => { if (!done) { done = true; clearTimeout(timer); resolve(null) } })
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class McpHub {
|
|
31
|
+
private clients = new Map<string, Client>()
|
|
32
|
+
private specs: ToolSpec[] = []
|
|
33
|
+
private route = new Map<string, { server: string; tool: string }>()
|
|
34
|
+
connected = false
|
|
35
|
+
|
|
36
|
+
/** Connect every configured server (bounded). Never throws. */
|
|
37
|
+
async connect(servers: Record<string, McpServerConfig>): Promise<void> {
|
|
38
|
+
for (const [name, cfg] of Object.entries(servers ?? {})) {
|
|
39
|
+
let transport: StdioClientTransport | null = null
|
|
40
|
+
try {
|
|
41
|
+
transport = new StdioClientTransport({ command: cfg.command, args: cfg.args ?? [], env: { ...(process.env as Record<string, string>), ...(cfg.env ?? {}) }, stderr: 'ignore' })
|
|
42
|
+
const client = new Client({ name: 'dragon-cli', version: '3.6.0' }, { capabilities: {} })
|
|
43
|
+
const t = transport
|
|
44
|
+
const ok = await withTimeout(
|
|
45
|
+
(async () => {
|
|
46
|
+
await client.connect(t)
|
|
47
|
+
const { tools } = await client.listTools()
|
|
48
|
+
for (const tool of tools) {
|
|
49
|
+
const ns = `${name}__${tool.name}`
|
|
50
|
+
this.specs.push({ name: ns, description: `[${name}] ${tool.description ?? ''}`.slice(0, 1024), parameters: (tool.inputSchema as Record<string, unknown>) ?? { type: 'object', properties: {} } })
|
|
51
|
+
this.route.set(ns, { server: name, tool: tool.name })
|
|
52
|
+
}
|
|
53
|
+
return true
|
|
54
|
+
})(),
|
|
55
|
+
CONNECT_TIMEOUT,
|
|
56
|
+
() => { try { void transport?.close() } catch { /* ignore */ } },
|
|
57
|
+
)
|
|
58
|
+
if (ok) this.clients.set(name, client)
|
|
59
|
+
else try { await client.close() } catch { /* ignore */ }
|
|
60
|
+
} catch { /* skip this server */ }
|
|
61
|
+
}
|
|
62
|
+
this.connected = this.clients.size > 0
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
get serverCount(): number { return this.clients.size }
|
|
66
|
+
toolSpecs(): ToolSpec[] { return this.specs }
|
|
67
|
+
handles(name: string): boolean { return this.route.has(name) }
|
|
68
|
+
|
|
69
|
+
async call(name: string, args: Record<string, unknown>): Promise<string> {
|
|
70
|
+
const r = this.route.get(name)
|
|
71
|
+
if (!r) return 'error: unknown MCP tool'
|
|
72
|
+
const client = this.clients.get(r.server)
|
|
73
|
+
if (!client) return `error: MCP server "${r.server}" not connected`
|
|
74
|
+
const res = await withTimeout(client.callTool({ name: r.tool, arguments: args }) as Promise<{ content?: { type: string; text?: string }[]; isError?: boolean }>, CALL_TIMEOUT)
|
|
75
|
+
if (res === null) return `error: ${name} timed out`
|
|
76
|
+
const text = (res.content ?? []).map((c) => (c.type === 'text' ? c.text ?? '' : `[${c.type}]`)).join('\n').trim()
|
|
77
|
+
return (res.isError ? 'error: ' : '') + (text || '(no result)')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async close(): Promise<void> {
|
|
81
|
+
for (const c of this.clients.values()) { try { await c.close() } catch { /* ignore */ } }
|
|
82
|
+
this.connected = false
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Build + connect the hub from config (null if no servers configured). */
|
|
87
|
+
export async function loadMcpHub(servers?: Record<string, McpServerConfig>): Promise<McpHub | null> {
|
|
88
|
+
if (!servers || !Object.keys(servers).length) return null
|
|
89
|
+
const hub = new McpHub()
|
|
90
|
+
await hub.connect(servers)
|
|
91
|
+
return hub.serverCount ? hub : null
|
|
92
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session persistence — save the conversation on exit (so `dragon chat --resume`
|
|
3
|
+
* picks up where you left off) and export a clean markdown transcript (`/save`).
|
|
4
|
+
* Stored under ~/.dragon/sessions, 0600 (may contain code/context).
|
|
5
|
+
*
|
|
6
|
+
* Copyright 2026 Ghost Protocol (Pvt) Ltd. All Rights Reserved.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync } from 'node:fs'
|
|
10
|
+
import { homedir } from 'node:os'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import type { BrainMessage } from '../brain/types.js'
|
|
13
|
+
|
|
14
|
+
const DIR = join(homedir(), '.dragon', 'sessions')
|
|
15
|
+
const LAST = join(DIR, 'last.json')
|
|
16
|
+
|
|
17
|
+
export function saveSession(messages: BrainMessage[], meta: { cwd: string; brain: string }): void {
|
|
18
|
+
try {
|
|
19
|
+
if (!messages.length) return
|
|
20
|
+
mkdirSync(DIR, { recursive: true, mode: 0o700 })
|
|
21
|
+
writeFileSync(LAST, JSON.stringify({ ts: new Date().toISOString(), ...meta, messages }), { mode: 0o600 })
|
|
22
|
+
chmodSync(LAST, 0o600)
|
|
23
|
+
} catch { /* never break exit over a save */ }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function loadLastSession(): BrainMessage[] | null {
|
|
27
|
+
try {
|
|
28
|
+
if (!existsSync(LAST)) return null
|
|
29
|
+
const o = JSON.parse(readFileSync(LAST, 'utf-8')) as { messages?: BrainMessage[] }
|
|
30
|
+
return Array.isArray(o.messages) && o.messages.length ? o.messages : null
|
|
31
|
+
} catch { return null }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Export the conversation as markdown (tool results omitted as noise). Returns the path. */
|
|
35
|
+
export function exportMarkdown(messages: BrainMessage[], file?: string): string {
|
|
36
|
+
const md: string[] = [`# Dragon session — ${new Date().toISOString()}`, '']
|
|
37
|
+
for (const m of messages) {
|
|
38
|
+
if (m.role === 'user') md.push(`## You\n\n${m.content}\n`)
|
|
39
|
+
else if (m.role === 'assistant' && m.content) md.push(`## Dragon\n\n${m.content}\n`)
|
|
40
|
+
else if (m.role === 'assistant' && m.toolCalls?.length) md.push(`_ran: ${m.toolCalls.map((t) => t.name).join(', ')}_\n`)
|
|
41
|
+
}
|
|
42
|
+
const out = md.join('\n')
|
|
43
|
+
if (file) { writeFileSync(file, out); return file }
|
|
44
|
+
mkdirSync(DIR, { recursive: true, mode: 0o700 })
|
|
45
|
+
const f = join(DIR, `transcript-${new Date().toISOString().replace(/[:.]/g, '-')}.md`)
|
|
46
|
+
writeFileSync(f, out)
|
|
47
|
+
return f
|
|
48
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skills-as-tools — the moat. Ghost Protocol's skill library (200+ reusable expert
|
|
3
|
+
* playbooks under ~/.copilot/skills) becomes live agent capability: the agent can
|
|
4
|
+
* `skill_search` for a relevant playbook and `skill_read` it to apply its guidance
|
|
5
|
+
* mid-task, instead of solving everything from cold.
|
|
6
|
+
*
|
|
7
|
+
* A dedicated, READ-ONLY library reader — deliberately separate from the cwd-confined
|
|
8
|
+
* file tools (the skill dir lives outside any project cwd, but it's our own curated,
|
|
9
|
+
* non-secret content, so reading it is safe + intentional).
|
|
10
|
+
*
|
|
11
|
+
* Copyright 2026 Ghost Protocol (Pvt) Ltd. All Rights Reserved.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readdirSync, readFileSync, existsSync } from 'node:fs'
|
|
15
|
+
import { homedir } from 'node:os'
|
|
16
|
+
import { join } from 'node:path'
|
|
17
|
+
import type { ToolSpec } from '../brain/types.js'
|
|
18
|
+
|
|
19
|
+
const SKILL_DIRS = [process.env.DRAGON_SKILLS_DIR, join(homedir(), '.copilot/skills'), join(homedir(), '.claude/skills')].filter(Boolean) as string[]
|
|
20
|
+
const MAX_SKILL_CHARS = 14_000
|
|
21
|
+
|
|
22
|
+
export interface SkillMeta { name: string; description: string; path: string }
|
|
23
|
+
|
|
24
|
+
/** Minimal YAML frontmatter parse (name + description, inline or folded) — no dep. */
|
|
25
|
+
export function parseFrontmatter(text: string): { name?: string; description?: string } {
|
|
26
|
+
const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
|
27
|
+
if (!m) return {}
|
|
28
|
+
const lines = m[1].split(/\r?\n/)
|
|
29
|
+
let name: string | undefined
|
|
30
|
+
let description: string | undefined
|
|
31
|
+
const unquote = (s: string) => s.replace(/^["']|["']$/g, '').trim()
|
|
32
|
+
for (let i = 0; i < lines.length; i++) {
|
|
33
|
+
let mm: RegExpMatchArray | null
|
|
34
|
+
if ((mm = lines[i].match(/^name:\s*(.+)$/))) name = unquote(mm[1])
|
|
35
|
+
else if ((mm = lines[i].match(/^description:\s*(.*)$/))) {
|
|
36
|
+
let d = mm[1].trim()
|
|
37
|
+
if (d === '' || d === '>' || d === '|' || d === '>-' || d === '|-') {
|
|
38
|
+
const cont: string[] = []
|
|
39
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
40
|
+
if (/^\s+\S/.test(lines[j])) cont.push(lines[j].trim())
|
|
41
|
+
else break
|
|
42
|
+
}
|
|
43
|
+
d = cont.join(' ')
|
|
44
|
+
}
|
|
45
|
+
description = unquote(d)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return { name, description }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class SkillLibrary {
|
|
52
|
+
private index: SkillMeta[] = []
|
|
53
|
+
|
|
54
|
+
load(): void {
|
|
55
|
+
const seen = new Set<string>()
|
|
56
|
+
for (const dir of SKILL_DIRS) {
|
|
57
|
+
if (!existsSync(dir)) continue
|
|
58
|
+
let entries: string[]
|
|
59
|
+
try { entries = readdirSync(dir) } catch { continue }
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
const skillFile = join(dir, entry, 'SKILL.md')
|
|
62
|
+
if (!existsSync(skillFile)) continue
|
|
63
|
+
let fm
|
|
64
|
+
try { fm = parseFrontmatter(readFileSync(skillFile, 'utf-8')) } catch { continue }
|
|
65
|
+
const name = fm.name || entry
|
|
66
|
+
if (seen.has(name)) continue // first dir wins (~/.copilot is canonical)
|
|
67
|
+
seen.add(name)
|
|
68
|
+
this.index.push({ name, description: fm.description ?? '', path: skillFile })
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
get count(): number { return this.index.length }
|
|
74
|
+
|
|
75
|
+
search(query: string, limit = 8): SkillMeta[] {
|
|
76
|
+
const terms = query.toLowerCase().split(/\W+/).filter((t) => t.length > 2)
|
|
77
|
+
if (!terms.length) return []
|
|
78
|
+
const scored = this.index.map((s) => {
|
|
79
|
+
const name = s.name.toLowerCase()
|
|
80
|
+
const desc = s.description.toLowerCase()
|
|
81
|
+
let score = 0
|
|
82
|
+
for (const t of terms) {
|
|
83
|
+
if (name.includes(t)) score += 5
|
|
84
|
+
if (desc.includes(t)) score += 1
|
|
85
|
+
}
|
|
86
|
+
return { s, score }
|
|
87
|
+
}).filter((x) => x.score > 0).sort((a, b) => b.score - a.score)
|
|
88
|
+
return scored.slice(0, limit).map((x) => x.s)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
read(name: string): string | null {
|
|
92
|
+
const want = name.toLowerCase().trim()
|
|
93
|
+
const hit = this.index.find((s) => s.name.toLowerCase() === want) ?? this.index.find((s) => s.name.toLowerCase().includes(want))
|
|
94
|
+
if (!hit) return null
|
|
95
|
+
try {
|
|
96
|
+
const body = readFileSync(hit.path, 'utf-8')
|
|
97
|
+
return body.length > MAX_SKILL_CHARS ? body.slice(0, MAX_SKILL_CHARS) + '\n… [truncated]' : body
|
|
98
|
+
} catch { return null }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
toolSpecs(): ToolSpec[] {
|
|
102
|
+
if (!this.count) return []
|
|
103
|
+
return [
|
|
104
|
+
{
|
|
105
|
+
name: 'skill_search',
|
|
106
|
+
description: `Search Ghost Protocol's library of ${this.count} reusable expert skills (playbooks for design, security, infra, brand, specific projects). Use BEFORE solving a non-trivial task from scratch — there's likely a skill for it. Returns matching skill names + summaries; then skill_read one.`,
|
|
107
|
+
parameters: { type: 'object', properties: { query: { type: 'string', description: 'what you need help with, e.g. "harden a cli" or "premium dark web design"' } }, required: ['query'] },
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
name: 'skill_read',
|
|
111
|
+
description: 'Read the full content of a named skill (from skill_search) and apply its guidance to the task.',
|
|
112
|
+
parameters: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] },
|
|
113
|
+
},
|
|
114
|
+
]
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
handles(name: string): boolean { return name === 'skill_search' || name === 'skill_read' }
|
|
118
|
+
|
|
119
|
+
call(name: string, args: Record<string, unknown>): string {
|
|
120
|
+
if (name === 'skill_search') {
|
|
121
|
+
const hits = this.search(String(args.query ?? ''))
|
|
122
|
+
if (!hits.length) return 'no matching skills'
|
|
123
|
+
return hits.map((s) => `• ${s.name} — ${s.description.slice(0, 180)}`).join('\n')
|
|
124
|
+
}
|
|
125
|
+
if (name === 'skill_read') {
|
|
126
|
+
const body = this.read(String(args.name ?? ''))
|
|
127
|
+
return body ?? `no skill named "${args.name}" (use skill_search first)`
|
|
128
|
+
}
|
|
129
|
+
return 'error: unknown skill tool'
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Build + load the library (empty + harmless if no skill dirs exist). */
|
|
134
|
+
export function loadSkillLibrary(): SkillLibrary {
|
|
135
|
+
const lib = new SkillLibrary()
|
|
136
|
+
try { lib.load() } catch { /* never block the agent over skills */ }
|
|
137
|
+
return lib
|
|
138
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stack-fused tools — the agent IS the operator console. It can read the live Ghost
|
|
3
|
+
* Protocol stack state and drive `dragon` subcommands (scale/wyrm/pentest/keep/net…)
|
|
4
|
+
* as first-class tools, not just guess at bash.
|
|
5
|
+
*
|
|
6
|
+
* Safety: read-only verbs run freely; everything else (pentest, deploy, install…) is
|
|
7
|
+
* `dangerous` → always prompts + shows the full command (never covered by --auto).
|
|
8
|
+
* Interactive/recursive/long-lived verbs (chat/ask/login/serve/up) are refused.
|
|
9
|
+
* Each invocation is a fresh subprocess of THIS CLI, time-bounded + output-capped.
|
|
10
|
+
*
|
|
11
|
+
* Copyright 2026 Ghost Protocol (Pvt) Ltd. All Rights Reserved.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { execFile } from 'node:child_process'
|
|
15
|
+
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'
|
|
16
|
+
import { join } from 'node:path'
|
|
17
|
+
import type { ToolSpec } from '../brain/types.js'
|
|
18
|
+
import type { ToolContext } from './tools.js'
|
|
19
|
+
import { loadConfig } from '../config.js'
|
|
20
|
+
|
|
21
|
+
const SELF = process.argv[1] ?? '' // the dragon CLI entry (dist/index.js)
|
|
22
|
+
const SAFE = new Set(['list', 'doctor', 'status', 'brains', 'whoami']) // read-only → no approval
|
|
23
|
+
const BLOCKED = new Set(['chat', 'ask', 'login', 'logout', 'serve', 'up', 'down', 'init']) // interactive / recursive / long-lived
|
|
24
|
+
const MAX_OUT = 20_000
|
|
25
|
+
|
|
26
|
+
function runDragon(args: string[], cwd: string, timeout: number): Promise<{ out: string; code: number }> {
|
|
27
|
+
return new Promise((res) => {
|
|
28
|
+
execFile(process.execPath, [SELF, ...args], { cwd, timeout, maxBuffer: 1 << 20 }, (err, stdout, stderr) => {
|
|
29
|
+
const out = ((stdout || '') + (stderr || '')).slice(0, MAX_OUT)
|
|
30
|
+
const code = err && typeof (err as { code?: unknown }).code === 'number' ? (err as { code: number }).code : err ? 1 : 0
|
|
31
|
+
res({ out, code })
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Run an arbitrary external process (a stack product), bounded + capped.
|
|
37
|
+
* Keeps stdout/stderr SEPARATE — products print progress to stderr and the
|
|
38
|
+
* machine-readable JSON to stdout, so a parser must read stdout alone. */
|
|
39
|
+
function runProc(cmd: string, args: string[], cwd: string, timeout: number): Promise<{ stdout: string; stderr: string; code: number }> {
|
|
40
|
+
return new Promise((res) => {
|
|
41
|
+
execFile(cmd, args, { cwd, timeout, maxBuffer: 8 << 20 }, (err, stdout, stderr) => {
|
|
42
|
+
const code = err && typeof (err as { code?: unknown }).code === 'number' ? (err as { code: number }).code : err ? 1 : 0
|
|
43
|
+
res({ stdout: stdout || '', stderr: stderr || '', code })
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Newest reports/<dir>/findings.json under a PhantomDragon repo (the scan output). */
|
|
49
|
+
function newestFindings(pentestRoot: string): string | null {
|
|
50
|
+
const dir = join(pentestRoot, 'reports')
|
|
51
|
+
if (!existsSync(dir)) return null
|
|
52
|
+
const subs = readdirSync(dir).map((d) => join(dir, d)).filter((p) => { try { return statSync(p).isDirectory() } catch { return false } })
|
|
53
|
+
if (!subs.length) return null
|
|
54
|
+
subs.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)
|
|
55
|
+
const f = join(subs[0], 'findings.json')
|
|
56
|
+
return existsSync(f) ? f : null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface StackTools {
|
|
60
|
+
specs: ToolSpec[]
|
|
61
|
+
handles(name: string): boolean
|
|
62
|
+
call(name: string, args: Record<string, unknown>, ctx: ToolContext): Promise<string>
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function makeStackTools(): StackTools {
|
|
66
|
+
const specs: ToolSpec[] = [
|
|
67
|
+
{
|
|
68
|
+
name: 'stack_status',
|
|
69
|
+
description: 'Live state of the Ghost Protocol dragon stack (each product: installed / running / version), as JSON. Read-only — call it to know what is deployed before acting.',
|
|
70
|
+
parameters: { type: 'object', properties: {}, required: [] },
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: 'stack_run',
|
|
74
|
+
description: 'Run a `dragon` stack command for an ops task — e.g. "list", "doctor", "pentest scan <url>", "wyrm <…>", "keep <…>". Read-only verbs run freely; actions require approval. NOT for chat/ask/login/serve.',
|
|
75
|
+
parameters: { type: 'object', properties: { command: { type: 'string', description: 'the dragon subcommand + args, e.g. "pentest scan https://example.com"' } }, required: ['command'] },
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: 'stack_pentest',
|
|
79
|
+
description: 'Run a PhantomDragon web security scan against a URL (AUTHORIZED targets only) and return STRUCTURED findings (severity, category, CVSS, confirmed, remediation) + a risk summary. Long-running; writes a report. Requires PhantomDragon configured.',
|
|
80
|
+
parameters: { type: 'object', properties: { url: { type: 'string' }, profile: { type: 'string', enum: ['quick', 'standard', 'full', 'api_only', 'auth_only'] }, mode: { type: 'string', enum: ['safe', 'standard', 'aggressive'] } }, required: ['url'] },
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: 'stack_keep',
|
|
84
|
+
description: 'Run a DragonKeep system-security scan (READ-ONLY local inspection) and return STRUCTURED findings (severity, CVSS, fix) + a summary. scan_type: full | quick | malware | ransomware | hunt | score.',
|
|
85
|
+
parameters: { type: 'object', properties: { scan_type: { type: 'string', enum: ['full', 'quick', 'malware', 'ransomware', 'hunt', 'score'] }, profile: { type: 'string' } }, required: [] },
|
|
86
|
+
},
|
|
87
|
+
]
|
|
88
|
+
return {
|
|
89
|
+
specs,
|
|
90
|
+
handles: (name) => ['stack_status', 'stack_run', 'stack_pentest', 'stack_keep'].includes(name),
|
|
91
|
+
async call(name, args, ctx) {
|
|
92
|
+
if (name === 'stack_pentest') return runPentest(args, ctx)
|
|
93
|
+
if (name === 'stack_keep') return runKeep(args, ctx)
|
|
94
|
+
if (!SELF) return 'error: stack tools unavailable (no CLI entry path)'
|
|
95
|
+
if (name === 'stack_status') {
|
|
96
|
+
const r = await runDragon(['list', '--json'], ctx.cwd, 30_000)
|
|
97
|
+
return r.out || '(no output)'
|
|
98
|
+
}
|
|
99
|
+
const command = String((args as { command?: string }).command ?? '').trim()
|
|
100
|
+
if (!command) return 'error: no command given'
|
|
101
|
+
const parts = command.split(/\s+/)
|
|
102
|
+
const verb = parts[0]
|
|
103
|
+
if (BLOCKED.has(verb)) return `error: "${verb}" can't be run as a tool (interactive / recursive / long-lived)`
|
|
104
|
+
if (!SAFE.has(verb) && !(await ctx.approve(`dragon ${command.slice(0, 100)}`, { detail: `dragon ${command}`, dangerous: true }))) {
|
|
105
|
+
return 'error: declined by user'
|
|
106
|
+
}
|
|
107
|
+
const r = await runDragon(parts, ctx.cwd, 180_000)
|
|
108
|
+
return `exit ${r.code}\n${r.out || '(no output)'}`
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── Structured stack actions (typed results, not just passthrough) ──
|
|
114
|
+
|
|
115
|
+
async function runPentest(args: Record<string, unknown>, ctx: ToolContext): Promise<string> {
|
|
116
|
+
const root = loadConfig().products?.pentest?.path
|
|
117
|
+
if (!root || !existsSync(root)) return 'error: PhantomDragon not configured — set products.pentest.path (run `dragon init`).'
|
|
118
|
+
const script = ['phantom_dragon_ai.py', 'phantomdragon.py'].map((s) => join(root, s)).find(existsSync)
|
|
119
|
+
if (!script) return `error: PhantomDragon entry script not found in ${root}`
|
|
120
|
+
const url = String(args.url ?? '').trim()
|
|
121
|
+
if (!url) return 'error: url is required'
|
|
122
|
+
const profile = String(args.profile ?? 'standard')
|
|
123
|
+
const mode = String(args.mode ?? 'safe')
|
|
124
|
+
if (!(await ctx.approve(`⚠ pentest scan ${url} (profile ${profile}, mode ${mode}) — authorized targets only`, { dangerous: true }))) return 'error: declined by user'
|
|
125
|
+
const r = await runProc('python3', [script, '-t', url, '--profile', profile, '--mode', mode], root, 600_000)
|
|
126
|
+
const fj = newestFindings(root)
|
|
127
|
+
if (!fj) return `scan exited ${r.code}; no findings.json produced.\n${(r.stderr || r.stdout).slice(-1200)}`
|
|
128
|
+
try {
|
|
129
|
+
const d = JSON.parse(readFileSync(fj, 'utf-8')) as { meta?: unknown; summary?: unknown; findings?: { id?: string; title?: string; severity?: string; category?: string; url?: string; confirmed?: boolean; cvss_score?: number; remediation?: string }[] }
|
|
130
|
+
const findings = (d.findings ?? []).slice(0, 15).map((f) => ({ id: f.id, title: f.title, severity: f.severity, category: f.category, url: f.url, confirmed: f.confirmed, cvss: f.cvss_score, remediation: (f.remediation ?? '').slice(0, 160) }))
|
|
131
|
+
return JSON.stringify({ report: fj, meta: d.meta, summary: d.summary, findings }).slice(0, MAX_OUT)
|
|
132
|
+
} catch {
|
|
133
|
+
return `scan complete but findings.json could not be parsed at ${fj}`
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function runKeep(args: Record<string, unknown>, ctx: ToolContext): Promise<string> {
|
|
138
|
+
const keepRoot = loadConfig().products?.keep?.path
|
|
139
|
+
const bin =
|
|
140
|
+
([keepRoot && join(keepRoot, 'target/release/dragonkeep'), keepRoot && join(keepRoot, 'target/debug/dragonkeep'), '/usr/local/bin/dragonkeep', '/usr/bin/dragonkeep'].filter(Boolean) as string[]).find((p) => existsSync(p)) ?? 'dragonkeep'
|
|
141
|
+
const scanType = String(args.scan_type ?? 'quick')
|
|
142
|
+
const direct = ['malware', 'ransomware', 'hunt', 'score', 'ai', 'supply', 'anomaly']
|
|
143
|
+
const cmd = ['--quiet', '--format', 'json']
|
|
144
|
+
if (direct.includes(scanType)) cmd.push(scanType)
|
|
145
|
+
else cmd.push('scan', '--profile', String(args.profile ?? (scanType === 'full' ? 'standard' : 'quick')))
|
|
146
|
+
const r = await runProc(bin, cmd, ctx.cwd, 300_000)
|
|
147
|
+
try {
|
|
148
|
+
const d = JSON.parse(r.stdout) as { hostname?: string; summary?: unknown; sections?: { name?: string; findings?: { title?: string; severity?: string; cvss?: number; fix?: string }[] }[] }
|
|
149
|
+
const sections = (d.sections ?? []).map((s) => ({ name: s.name, findings: (s.findings ?? []).filter((f) => f.severity !== 'Pass').slice(0, 8).map((f) => ({ title: f.title, severity: f.severity, cvss: f.cvss, fix: (f.fix ?? '').slice(0, 140) })) }))
|
|
150
|
+
return JSON.stringify({ hostname: d.hostname, summary: d.summary, sections }).slice(0, MAX_OUT)
|
|
151
|
+
} catch {
|
|
152
|
+
return `dragonkeep exited ${r.code}${bin === 'dragonkeep' ? ' (binary not found — build it: cargo build --release in DragonKeep, or `dragon install keep`)' : ''}\n${(r.stderr || r.stdout).slice(0, 1500)}`
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sub-agents — the `task` tool. The agent can spawn a focused, READ-ONLY sub-agent
|
|
3
|
+
* to research/analyze a sub-problem (audit a file, summarize how something works,
|
|
4
|
+
* survey a codebase) without polluting the main thread. The sub-agent can read,
|
|
5
|
+
* grep, glob, list, and use skills — but cannot write, edit, or run shell, and
|
|
6
|
+
* cannot itself spawn tasks (no recursion / fork-bombs). It returns its findings.
|
|
7
|
+
*
|
|
8
|
+
* Copyright 2026 Ghost Protocol (Pvt) Ltd. All Rights Reserved.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Brain, ToolSpec } from '../brain/types.js'
|
|
12
|
+
import type { SkillLibrary } from './skills.js'
|
|
13
|
+
import type { ToolContext } from './tools.js'
|
|
14
|
+
import { runAgent, buildSystemPrompt, type AgentDeps, type AgentRender } from './loop.js'
|
|
15
|
+
|
|
16
|
+
export interface TaskTool {
|
|
17
|
+
spec: ToolSpec
|
|
18
|
+
call(args: Record<string, unknown>): Promise<string>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function makeTaskTool(parent: { brain: Brain; skills: SkillLibrary | null; cwd: string }): TaskTool {
|
|
22
|
+
return {
|
|
23
|
+
spec: {
|
|
24
|
+
name: 'task',
|
|
25
|
+
description: 'Spawn a focused READ-ONLY sub-agent to research or analyze a sub-problem in isolation (e.g. "audit src/auth.ts for security bugs", "summarize how the brain adapter works"). It can read/grep/glob/list + use skills, but CANNOT write, edit, or run shell. Returns its findings. Use it to offload big read-heavy investigations and keep your own context clean.',
|
|
26
|
+
parameters: { type: 'object', properties: { task: { type: 'string', description: 'the self-contained subtask to investigate' } }, required: ['task'] },
|
|
27
|
+
},
|
|
28
|
+
async call(args) {
|
|
29
|
+
const prompt = String((args as { task?: string }).task ?? '').trim()
|
|
30
|
+
if (!prompt) return 'error: no task given'
|
|
31
|
+
|
|
32
|
+
const denyAll: ToolContext = { cwd: parent.cwd, approve: async () => false } // read-only sub-agent
|
|
33
|
+
const subDeps: AgentDeps = {
|
|
34
|
+
brain: parent.brain,
|
|
35
|
+
wyrm: null, portal: null, stack: null, mcp: null, task: null, // sub-agent: read + skills only, NO recursion
|
|
36
|
+
skills: parent.skills,
|
|
37
|
+
cwd: parent.cwd,
|
|
38
|
+
system:
|
|
39
|
+
buildSystemPrompt({ cwd: parent.cwd, wyrm: false, portal: false, brainId: `${parent.brain.id}:${parent.brain.model}`, skills: parent.skills?.count || undefined }) +
|
|
40
|
+
'\n\nYou are a FOCUSED SUB-AGENT spawned for ONE read-only investigation. Use read_file/grep/glob/list_dir/skill_* only — you CANNOT write, edit, or run shell (those are denied). Investigate thoroughly, then return a concise, complete, self-contained answer. Do not ask questions.',
|
|
41
|
+
toolCtx: denyAll,
|
|
42
|
+
messages: [],
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let out = ''
|
|
46
|
+
const quiet: AgentRender = { onAssistantStart() {}, onDelta(s) { out += s }, onToolStart() {}, onToolEnd() {} }
|
|
47
|
+
try {
|
|
48
|
+
await runAgent(subDeps, prompt, quiet, new AbortController().signal)
|
|
49
|
+
return out.trim() || '(sub-agent returned nothing)'
|
|
50
|
+
} catch (e) {
|
|
51
|
+
return `sub-agent error: ${String(e instanceof Error ? e.message : e)}`
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
}
|