@gpzhang2001/sharpkit-preset 0.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/LICENSE +201 -0
- package/README.md +18 -0
- package/THIRD_PARTY_NOTICES.md +48 -0
- package/lib/index.d.ts +145 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +548 -0
- package/lib/index.js.map +1 -0
- package/lib/system_prompt.jinja +545 -0
- package/package.json +45 -0
- package/src/index.ts +154 -0
- package/src/jinja.ts +298 -0
- package/src/skills.ts +172 -0
- package/src/system_prompt.jinja +545 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pentest preset — the M3 prompt/knowledge layer: renders the strix system
|
|
3
|
+
* prompt (VERBATIM template + jinja-subset renderer + the skill selection
|
|
4
|
+
* policy) from scan configuration, registers it through the dsh
|
|
5
|
+
* system-prompt seam, and exposes scan-mode semantics (budget/turn
|
|
6
|
+
* defaults) as `pentestPreset` for the orchestrator. Golden-locked against
|
|
7
|
+
* renders from the original jinja template.
|
|
8
|
+
* @module @gpzhang2001/sharpkit-preset
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync } from 'node:fs'
|
|
12
|
+
import { dirname, join } from 'node:path'
|
|
13
|
+
import { fileURLToPath } from 'node:url'
|
|
14
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
15
|
+
import type {} from '@deepseek-ai/dsh-system-prompt'
|
|
16
|
+
import type Schema from '@deepseek-ai/schemastery'
|
|
17
|
+
import z from '@deepseek-ai/schemastery'
|
|
18
|
+
import { bundledSkillsRoot as skillsPackageRoot } from '@gpzhang2001/sharpkit-skills'
|
|
19
|
+
import { renderTemplate } from './jinja.ts'
|
|
20
|
+
import { getAvailableSkills, loadSkills, resolveSkills } from './skills.ts'
|
|
21
|
+
|
|
22
|
+
export { renderTemplate } from './jinja.ts'
|
|
23
|
+
export { getAvailableSkills, loadSkills, resolveSkills, validateRequestedSkills, parseSkillContent } from './skills.ts'
|
|
24
|
+
|
|
25
|
+
/** The template file shipped verbatim from strix. */
|
|
26
|
+
const TEMPLATE = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'system_prompt.jinja'), 'utf8')
|
|
27
|
+
|
|
28
|
+
/** Scan-mode budget semantics (manual §6 M3; orchestrator consumes). */
|
|
29
|
+
export interface ScanModeSemantics {
|
|
30
|
+
readonly maxTurns: number
|
|
31
|
+
readonly maxBudgetUsd: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The quick/deep budget matrix (initial values; M6 tunes). */
|
|
35
|
+
const SCAN_MODE_SEMANTICS: Record<string, ScanModeSemantics> = {
|
|
36
|
+
quick: { maxTurns: 60, maxBudgetUsd: 5 },
|
|
37
|
+
deep: { maxTurns: 250, maxBudgetUsd: 25 },
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Deployment-tunable configuration (cordis resolves defaults before apply). */
|
|
41
|
+
export interface Config {
|
|
42
|
+
/** Scan mode: quick or deep. */
|
|
43
|
+
readonly scanMode?: string
|
|
44
|
+
/** Whether this agent is the root orchestrator. */
|
|
45
|
+
readonly isRoot?: boolean
|
|
46
|
+
/** Whether the scan has source access (whitebox). */
|
|
47
|
+
readonly isWhitebox?: boolean
|
|
48
|
+
/** Whether the session is interactive. */
|
|
49
|
+
readonly interactive?: boolean
|
|
50
|
+
/** Whether the scan is scoped to a change set. */
|
|
51
|
+
readonly isDiffScoped?: boolean
|
|
52
|
+
/** Requested skills (≤5, category-qualified). */
|
|
53
|
+
readonly skills?: readonly string[]
|
|
54
|
+
/** Scan targets rendered into the scope block. */
|
|
55
|
+
readonly authorizedTargets?: ReadonlyArray<{ readonly type: string; readonly value: string; readonly workspace_path?: string }>
|
|
56
|
+
readonly scopeSource?: string
|
|
57
|
+
readonly authorizationSource?: string
|
|
58
|
+
/** MCP connections rendered into the mcp block. */
|
|
59
|
+
readonly mcpConnections?: ReadonlyArray<{ readonly name: string; readonly tool_count: number; readonly purpose?: string }>
|
|
60
|
+
/** Skills tree root; defaults to the bundled pentest-suite skills. */
|
|
61
|
+
readonly skillsRoot?: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const name = 'pentest-preset'
|
|
65
|
+
|
|
66
|
+
export const inject: string[] = []
|
|
67
|
+
|
|
68
|
+
export const Config: Schema<Config> = z.object({
|
|
69
|
+
scanMode: z.string().default('deep'),
|
|
70
|
+
isRoot: z.boolean().default(true),
|
|
71
|
+
isWhitebox: z.boolean().default(false),
|
|
72
|
+
interactive: z.boolean().default(true),
|
|
73
|
+
isDiffScoped: z.boolean().default(false),
|
|
74
|
+
skills: z.array(z.string()),
|
|
75
|
+
authorizedTargets: z.array(z.object({
|
|
76
|
+
type: z.string(),
|
|
77
|
+
value: z.string(),
|
|
78
|
+
workspace_path: z.string(),
|
|
79
|
+
})),
|
|
80
|
+
scopeSource: z.string(),
|
|
81
|
+
authorizationSource: z.string(),
|
|
82
|
+
mcpConnections: z.array(z.object({
|
|
83
|
+
name: z.string(),
|
|
84
|
+
tool_count: z.number(),
|
|
85
|
+
purpose: z.string(),
|
|
86
|
+
})),
|
|
87
|
+
skillsRoot: z.string(),
|
|
88
|
+
}) as unknown as Schema<Config>
|
|
89
|
+
|
|
90
|
+
/** Default skills root: the skills package's bundled corpus (npm-layout safe). */
|
|
91
|
+
export function bundledSkillsRoot(): string {
|
|
92
|
+
return skillsPackageRoot()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Render the system prompt for one configuration (pure given the inputs).
|
|
97
|
+
* @param config - resolved preset config.
|
|
98
|
+
* @returns the rendered markdown prompt.
|
|
99
|
+
*/
|
|
100
|
+
export function renderPresetPrompt(config: Config): string {
|
|
101
|
+
const skillsRoot = config.skillsRoot ?? bundledSkillsRoot()
|
|
102
|
+
const requested = config.skills ?? []
|
|
103
|
+
const skillNames = resolveSkills({
|
|
104
|
+
requested,
|
|
105
|
+
scanMode: config.scanMode ?? 'deep',
|
|
106
|
+
isRoot: config.isRoot ?? true,
|
|
107
|
+
isWhitebox: config.isWhitebox ?? false,
|
|
108
|
+
isDiffScoped: config.isDiffScoped ?? false,
|
|
109
|
+
})
|
|
110
|
+
const skillContent = loadSkills(skillNames, skillsRoot)
|
|
111
|
+
const targets = (config.authorizedTargets ?? []).map(target => ({ ...target }))
|
|
112
|
+
const hasScope = targets.length > 0 || config.scopeSource !== undefined
|
|
113
|
+
const mcpConnections = (config.mcpConnections ?? []).map(connection => ({ ...connection }))
|
|
114
|
+
return renderTemplate(TEMPLATE, {
|
|
115
|
+
is_root: config.isRoot ?? true,
|
|
116
|
+
interactive: config.interactive ?? true,
|
|
117
|
+
loaded_skill_names: [...Object.keys(skillContent)],
|
|
118
|
+
available_skills: getAvailableSkills(skillsRoot),
|
|
119
|
+
get_skill: (name: string): string => skillContent[name] ?? '',
|
|
120
|
+
system_prompt_context: {
|
|
121
|
+
...(hasScope ? { authorized_targets: targets, scope_source: config.scopeSource, authorization_source: config.authorizationSource } : {}),
|
|
122
|
+
...(config.mcpConnections !== undefined && config.mcpConnections.length > 0 ? { mcp_available: true, mcp_connections: mcpConnections } : {}),
|
|
123
|
+
},
|
|
124
|
+
...skillContent,
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Scan-mode semantics lookup (quick default when the mode is unknown). */
|
|
129
|
+
export function scanModeSemantics(scanMode: string): ScanModeSemantics {
|
|
130
|
+
return SCAN_MODE_SEMANTICS[scanMode] ?? { maxTurns: 60, maxBudgetUsd: 5 }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function apply(ctx: Context, config: Config = {}): void {
|
|
134
|
+
const prompt = renderPresetPrompt(config)
|
|
135
|
+
// The persona section exists on host compositions; the headless one-shot
|
|
136
|
+
// tree mounts no system-prompt service, where the preset still provides
|
|
137
|
+
// pentestPreset (the rendered text is available to SDK consumers).
|
|
138
|
+
void ctx.inject(['systemPrompt'], (promptCtx: unknown) => {
|
|
139
|
+
;(promptCtx as Context).systemPrompt.section({
|
|
140
|
+
// PERSONA_SECTION is the slot an agent preset shadows (system-prompt docs).
|
|
141
|
+
name: 'deployment:persona',
|
|
142
|
+
order: (promptCtx as Context).systemPrompt.getSectionOrder('DEPLOYMENT_PERSONA'),
|
|
143
|
+
text: prompt,
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
ctx.provide('pentestPreset', {
|
|
147
|
+
scanMode: config.scanMode ?? 'deep',
|
|
148
|
+
isRoot: config.isRoot ?? true,
|
|
149
|
+
...scanModeSemantics(config.scanMode ?? 'deep'),
|
|
150
|
+
/** Consumed by tool-proxy's repeat_request target enforcement. */
|
|
151
|
+
authorizedTargets: (config.authorizedTargets ?? []).map(target => ({ ...target })),
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
|
package/src/jinja.ts
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A minimal Jinja-subset renderer covering exactly the constructs the
|
|
3
|
+
* strix system prompt template uses: line-level {% if %}/{% else %}/
|
|
4
|
+
* {% endif %}/{% for x in y %}/{% endfor %}, inline {% if %}...{% endif %},
|
|
5
|
+
* and {{ dotted.expr }} interpolation (with `a and b` truthiness and the
|
|
6
|
+
* `available_skills | dictsort` filter). The template file ships VERBATIM
|
|
7
|
+
* from strix — no hand transcription — and whitespace semantics follow
|
|
8
|
+
* default Jinja (a lone tag line renders as an empty line).
|
|
9
|
+
* @module @gpzhang2001/sharpkit-preset/jinja
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** A rendered value: strings, numbers, booleans, null, arrays, objects. */
|
|
13
|
+
export type JinjaValue = string | number | boolean | null | JinjaValue[] | { [key: string]: JinvaRecordValue } | JinvaRecordValue[]
|
|
14
|
+
type JinvaRecordValue = string | number | boolean | null | JinvaRecordValue[] | { [key: string]: JinvaRecordValue }
|
|
15
|
+
|
|
16
|
+
/** Truthiness per Jinja: undefined/null/false/empty string/empty array are falsy. */
|
|
17
|
+
function isTruthy(value: unknown): boolean {
|
|
18
|
+
if (value === undefined || value === null || value === false) return false
|
|
19
|
+
if (typeof value === 'string') return value !== ''
|
|
20
|
+
if (Array.isArray(value)) return value.length > 0
|
|
21
|
+
return true
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Resolve a dotted path — or a zero/one-arg call like get_skill(x) — against the scope chain. */
|
|
25
|
+
function lookup(scopes: ReadonlyArray<Record<string, unknown>>, path: string): unknown {
|
|
26
|
+
const call = /^([^(]+)\(([^)]*)\)$/.exec(path)
|
|
27
|
+
if (call !== null) {
|
|
28
|
+
const fn = resolveName(scopes, (call[1] ?? '').trim())
|
|
29
|
+
if (typeof fn !== 'function') return undefined
|
|
30
|
+
const argExpression = (call[2] ?? '').trim()
|
|
31
|
+
const arg = argExpression === '' ? undefined : lookup(scopes, argExpression)
|
|
32
|
+
return (fn as (value?: unknown) => unknown)(arg)
|
|
33
|
+
}
|
|
34
|
+
return resolveName(scopes, path)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Resolve a dotted name against a scope chain (top of stack first). */
|
|
38
|
+
function resolveName(scopes: ReadonlyArray<Record<string, unknown>>, path: string): unknown {
|
|
39
|
+
const parts = path.split('.')
|
|
40
|
+
const head = parts[0] ?? ''
|
|
41
|
+
const rest = parts.slice(1)
|
|
42
|
+
for (let index = scopes.length - 1; index >= 0; index--) {
|
|
43
|
+
const scope = scopes[index]
|
|
44
|
+
if (scope === undefined || !(head in scope)) continue
|
|
45
|
+
let current: unknown = scope[head]
|
|
46
|
+
for (const part of rest) {
|
|
47
|
+
if (typeof current !== 'object' || current === null) return undefined
|
|
48
|
+
current = (current as Record<string, unknown>)[part]
|
|
49
|
+
}
|
|
50
|
+
return current
|
|
51
|
+
}
|
|
52
|
+
return undefined
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Evaluate one condition expression: `a`, `a.b`, `x and y`. */
|
|
56
|
+
function evaluateCondition(scopes: ReadonlyArray<Record<string, unknown>>, expression: string): boolean {
|
|
57
|
+
const conjuncts = expression.split(' and ').map(part => part.trim())
|
|
58
|
+
return conjuncts.every(part => isTruthy(lookup(scopes, part)))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Interpolate {{ expr }} occurrences (dotted lookup; undefined renders as empty per strix usage guard). */
|
|
62
|
+
function interpolate(scopes: ReadonlyArray<Record<string, unknown>>, text: string): string {
|
|
63
|
+
return text.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_match, expression: string) => {
|
|
64
|
+
const value = lookup(scopes, expression.trim())
|
|
65
|
+
return value === undefined || value === null ? '' : String(value)
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** dictsort: sort object entries by key (jinja dictsort default). */
|
|
70
|
+
function dictSort(value: unknown): Array<{ key: string; value: unknown }> {
|
|
71
|
+
if (typeof value !== 'object' || value === null) return []
|
|
72
|
+
return Object.entries(value as Record<string, unknown>)
|
|
73
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
74
|
+
.map(([key, entryValue]) => ({ key, value: entryValue }))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** One parsed template directive. */
|
|
78
|
+
type Line =
|
|
79
|
+
| { readonly kind: 'text'; readonly text: string }
|
|
80
|
+
| { readonly kind: 'if'; readonly condition: string }
|
|
81
|
+
| { readonly kind: 'else' }
|
|
82
|
+
| { readonly kind: 'endif' }
|
|
83
|
+
| { readonly kind: 'for'; readonly variable: string; readonly iterable: string; readonly rightTrim: boolean }
|
|
84
|
+
| { readonly kind: 'endfor' }
|
|
85
|
+
|
|
86
|
+
/** Parse template text into lines, splitting inline {% if %}/{% endif %} fragments. */
|
|
87
|
+
function parseLines(text: string): Line[] {
|
|
88
|
+
const lines: Line[] = []
|
|
89
|
+
for (const rawLine of text.split('\n')) {
|
|
90
|
+
// Line-alone block tags take precedence over inline-fragment splitting.
|
|
91
|
+
const trimmed = rawLine.trim()
|
|
92
|
+
const loneIf = /^\{%-?\s*if\s+(.+?)\s*-?%\}$/.exec(trimmed)
|
|
93
|
+
const loneElse = /^\{%-?\s*else\s*-?%\}$/.test(trimmed)
|
|
94
|
+
const loneEndif = /^\{%-?\s*endif\s*-?%\}$/.test(trimmed)
|
|
95
|
+
const loneFor = /^\{%-?\s*for\s+(.+?)\s*-?%\}$/.exec(trimmed)
|
|
96
|
+
const loneEndfor = /^\{%-?\s*endfor\s*-?%\}$/.test(trimmed)
|
|
97
|
+
if (loneIf !== null) {
|
|
98
|
+
lines.push({ kind: 'if', condition: loneIf[1] ?? '' })
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
if (loneElse) {
|
|
102
|
+
lines.push({ kind: 'else' })
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
if (loneEndif) {
|
|
106
|
+
lines.push({ kind: 'endif' })
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
if (loneFor !== null) {
|
|
110
|
+
const rightTrim = loneFor[0].endsWith('-%}')
|
|
111
|
+
const declaration = loneFor[1] ?? ''
|
|
112
|
+
const two = /^(\w+),\s*(\w+)\s+in\s+(.+?)\s*\|\s*(\w+)$/.exec(declaration)
|
|
113
|
+
const one = /^(\w+)\s+in\s+(.+?)\s*\|\s*(\w+)$/.exec(declaration)
|
|
114
|
+
const onePlain = /^(\w+)\s+in\s+(.+?)$/.exec(declaration)
|
|
115
|
+
if (two !== null) {
|
|
116
|
+
lines.push({ kind: 'for', variable: `${two[1]},${two[2]}`, iterable: `${two[3]}|${two[4] ?? ''}`, rightTrim })
|
|
117
|
+
} else if (one !== null) {
|
|
118
|
+
lines.push({ kind: 'for', variable: one[1] ?? '', iterable: `${one[2] ?? ''}|${one[3] ?? ''}`, rightTrim })
|
|
119
|
+
} else if (onePlain !== null) {
|
|
120
|
+
lines.push({ kind: 'for', variable: onePlain[1] ?? '', iterable: onePlain[2] ?? '', rightTrim })
|
|
121
|
+
}
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
if (loneEndfor) {
|
|
125
|
+
lines.push({ kind: 'endfor' })
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
const inlineParts = rawLine.split(/(\{%-?\s*(?:if|endif)\s+[^%]*?-?%\}|\{\{-?[^-]*?-?\}\})/g)
|
|
129
|
+
if (inlineParts.length > 1 && inlineParts.some(part => part.includes('{%'))) {
|
|
130
|
+
// A line mixing text and inline if/endif tags: expand into sub-lines
|
|
131
|
+
// while preserving the join (rendered pieces re-join with '').
|
|
132
|
+
let buffer = ''
|
|
133
|
+
const segments: Array<{ readonly tag: string | null; readonly text: string }> = []
|
|
134
|
+
for (const part of inlineParts) {
|
|
135
|
+
const ifMatch = /^\{%-?\s*if\s+(.+?)\s*-?%\}$/.exec(part)
|
|
136
|
+
const endifMatch = /^\{%-?\s*endif\s*-?%\}$/.exec(part)
|
|
137
|
+
if (ifMatch !== null) {
|
|
138
|
+
if (buffer !== '') segments.push({ tag: null, text: buffer })
|
|
139
|
+
buffer = ''
|
|
140
|
+
segments.push({ tag: `if ${ifMatch[1]}`, text: '' })
|
|
141
|
+
} else if (endifMatch !== null) {
|
|
142
|
+
if (buffer !== '') segments.push({ tag: null, text: buffer })
|
|
143
|
+
buffer = ''
|
|
144
|
+
segments.push({ tag: 'endif', text: '' })
|
|
145
|
+
} else {
|
|
146
|
+
buffer += part
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (buffer !== '') segments.push({ tag: null, text: buffer })
|
|
150
|
+
// Emit as a synthetic single line handled by renderSegments.
|
|
151
|
+
lines.push({ kind: 'text', text: `\u0000SEG${JSON.stringify(segments)}\u0000` })
|
|
152
|
+
continue
|
|
153
|
+
}
|
|
154
|
+
const ifMatch = /^\{%-?\s*if\s+(.+?)\s*-?%\}$/.exec(rawLine.trim())
|
|
155
|
+
if (ifMatch !== null) {
|
|
156
|
+
lines.push({ kind: 'if', condition: ifMatch[1] ?? '' })
|
|
157
|
+
continue
|
|
158
|
+
}
|
|
159
|
+
if (/^\{%-?\s*else\s*-?%\}$/.test(rawLine.trim())) {
|
|
160
|
+
lines.push({ kind: 'else' })
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
163
|
+
if (/^\{%-?\s*endif\s*-?%\}$/.test(rawLine.trim())) {
|
|
164
|
+
lines.push({ kind: 'endif' })
|
|
165
|
+
continue
|
|
166
|
+
}
|
|
167
|
+
lines.push({ kind: 'text', text: rawLine })
|
|
168
|
+
}
|
|
169
|
+
return lines
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Render segment-marked text lines (inline if fragments). */
|
|
173
|
+
function renderSegments(text: string, scopes: ReadonlyArray<Record<string, unknown>>): string {
|
|
174
|
+
const payload = text.slice(4, -1)
|
|
175
|
+
const segments = JSON.parse(payload) as Array<{ tag: string | null; text: string }>
|
|
176
|
+
let out = ''
|
|
177
|
+
let include = true
|
|
178
|
+
for (const segment of segments) {
|
|
179
|
+
if (segment.tag !== null) {
|
|
180
|
+
include = segment.tag.startsWith('if') ? evaluateCondition(scopes, segment.tag.slice(3)) : false
|
|
181
|
+
continue
|
|
182
|
+
}
|
|
183
|
+
if (include) out += interpolate(scopes, segment.text)
|
|
184
|
+
}
|
|
185
|
+
return out
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Render the template text with a variable scope.
|
|
190
|
+
* @param template - the raw jinja template text.
|
|
191
|
+
* @param variables - top-level template variables.
|
|
192
|
+
* @returns the rendered output.
|
|
193
|
+
*/
|
|
194
|
+
export function renderTemplate(template: string, variables: Record<string, unknown>): string {
|
|
195
|
+
const scopes: Record<string, unknown>[] = [variables]
|
|
196
|
+
// Jinja default keep_trailing_newline=False: the template's final newline
|
|
197
|
+
// is removed before rendering.
|
|
198
|
+
const lines = parseLines(template.replace(/\n$/, ''))
|
|
199
|
+
const output: string[] = []
|
|
200
|
+
// Block stack for if/for with instruction pointers.
|
|
201
|
+
const emit = (from: number, to: number): void => {
|
|
202
|
+
let index = from
|
|
203
|
+
while (index < to) {
|
|
204
|
+
const line = lines[index]
|
|
205
|
+
index++
|
|
206
|
+
if (line === undefined) break
|
|
207
|
+
if (line.kind === 'text') {
|
|
208
|
+
output.push(line.text.startsWith('\u0000SEG') ? renderSegments(line.text, scopes) : interpolate(scopes, line.text))
|
|
209
|
+
continue
|
|
210
|
+
}
|
|
211
|
+
if (line.kind === 'if') {
|
|
212
|
+
const taken = evaluateCondition(scopes, line.condition)
|
|
213
|
+
// Find matching else/endif at the same depth.
|
|
214
|
+
let depth = 0
|
|
215
|
+
let elseAt = -1
|
|
216
|
+
let endifAt = -1
|
|
217
|
+
let cursor = index
|
|
218
|
+
while (cursor < to) {
|
|
219
|
+
const probe = lines[cursor]
|
|
220
|
+
if (probe?.kind === 'if') depth++
|
|
221
|
+
if (probe?.kind === 'endif') {
|
|
222
|
+
if (depth === 0) {
|
|
223
|
+
endifAt = cursor
|
|
224
|
+
break
|
|
225
|
+
}
|
|
226
|
+
depth--
|
|
227
|
+
}
|
|
228
|
+
if (probe?.kind === 'else' && depth === 0 && elseAt === -1) elseAt = cursor
|
|
229
|
+
cursor++
|
|
230
|
+
}
|
|
231
|
+
if (endifAt === -1) endifAt = to
|
|
232
|
+
// Jinja default whitespace: a block tag adjacent to the TAKEN side
|
|
233
|
+
// keeps its line's newline (renders as one empty line); everything
|
|
234
|
+
// inside a FALSE region — including its opening tag — vanishes,
|
|
235
|
+
// except the closing endif, which always leaves one blank line.
|
|
236
|
+
if (taken) {
|
|
237
|
+
output.push('')
|
|
238
|
+
emit(index, elseAt === -1 ? endifAt : elseAt)
|
|
239
|
+
} else if (elseAt !== -1) {
|
|
240
|
+
// The else tag's trailing newline belongs to the taken side.
|
|
241
|
+
output.push('')
|
|
242
|
+
emit(elseAt + 1, endifAt)
|
|
243
|
+
}
|
|
244
|
+
output.push('')
|
|
245
|
+
index = endifAt + 1
|
|
246
|
+
continue
|
|
247
|
+
}
|
|
248
|
+
if (line.kind === 'for') {
|
|
249
|
+
const [iterableSource, filter = ''] = line.iterable.split('|')
|
|
250
|
+
const raw = lookup(scopes, iterableSource ?? '')
|
|
251
|
+
void filter
|
|
252
|
+
// Find endfor at the same depth.
|
|
253
|
+
let depth = 0
|
|
254
|
+
let endforAt = to
|
|
255
|
+
let cursor = index
|
|
256
|
+
while (cursor < to) {
|
|
257
|
+
const probe = lines[cursor]
|
|
258
|
+
if (probe?.kind === 'for') depth++
|
|
259
|
+
if (probe?.kind === 'endfor') {
|
|
260
|
+
if (depth === 0) {
|
|
261
|
+
endforAt = cursor
|
|
262
|
+
break
|
|
263
|
+
}
|
|
264
|
+
depth--
|
|
265
|
+
}
|
|
266
|
+
cursor++
|
|
267
|
+
}
|
|
268
|
+
const [nameA, nameB] = line.variable.split(',')
|
|
269
|
+
const items: Array<{ readonly key: string; readonly value: unknown }> = nameB !== undefined && nameA !== undefined
|
|
270
|
+
? dictSort(raw)
|
|
271
|
+
: (Array.isArray(raw) ? raw : []).map(item => ({ key: '', value: item }))
|
|
272
|
+
if (items.length > 0) {
|
|
273
|
+
for (const entry of items) {
|
|
274
|
+
// The for tag's trailing newline is INSIDE the loop body (default
|
|
275
|
+
// Jinja); `-%}` right-trims it away for every iteration.
|
|
276
|
+
if (!line.rightTrim) output.push('')
|
|
277
|
+
if (nameB !== undefined && nameA !== undefined) {
|
|
278
|
+
const scope: Record<string, unknown> = {}
|
|
279
|
+
scope[nameA] = entry.key
|
|
280
|
+
scope[nameB] = entry.value
|
|
281
|
+
scopes.push(scope)
|
|
282
|
+
} else {
|
|
283
|
+
scopes.push({ [line.variable]: entry.value })
|
|
284
|
+
}
|
|
285
|
+
emit(index, endforAt)
|
|
286
|
+
scopes.pop()
|
|
287
|
+
}
|
|
288
|
+
if (!line.rightTrim) output.push('')
|
|
289
|
+
}
|
|
290
|
+
index = endforAt + 1
|
|
291
|
+
continue
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
emit(0, lines.length)
|
|
296
|
+
return output.join('\n')
|
|
297
|
+
}
|
|
298
|
+
|
package/src/skills.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill resolution — port of strix skills/__init__.py `_resolve_skills`,
|
|
3
|
+
* `load_skills`, `get_available_skills`, and `validate_requested_skills`
|
|
4
|
+
* over a filesystem skills root with the `<root>/<category>/<name>.md`
|
|
5
|
+
* layout (the bundled pentest-suite skills tree, or the strix original for
|
|
6
|
+
* golden tests).
|
|
7
|
+
* @module @gpzhang2001/sharpkit-preset/skills
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
|
|
13
|
+
/** Internal categories excluded from the selectable catalog (strix parity). */
|
|
14
|
+
const INTERNAL_SKILL_CATEGORIES = new Set(['scan_modes', 'coordination', 'analysis'])
|
|
15
|
+
|
|
16
|
+
/** Frontmatter regex (strix `_FRONTMATTER_PATTERN`). */
|
|
17
|
+
const FRONTMATTER = /^---\s*\n([\s\S]*?)\n---\s*\n/
|
|
18
|
+
|
|
19
|
+
/** Parsed skill metadata + body. */
|
|
20
|
+
export interface SkillFile {
|
|
21
|
+
readonly metadata: Record<string, string>
|
|
22
|
+
readonly body: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Parse frontmatter and body (strix `_parse_skill_content`). */
|
|
26
|
+
export function parseSkillContent(content: string): SkillFile {
|
|
27
|
+
const match = FRONTMATTER.exec(content)
|
|
28
|
+
if (match === null) return { metadata: {}, body: content.replace(/^\s+/, '') }
|
|
29
|
+
const body = match[1] ?? ''
|
|
30
|
+
const metadata: Record<string, string> = {}
|
|
31
|
+
for (const line of body.split('\n')) {
|
|
32
|
+
const colon = line.indexOf(':')
|
|
33
|
+
if (colon === -1) continue
|
|
34
|
+
const key = line.slice(0, colon).trim()
|
|
35
|
+
let value = line.slice(colon + 1).trim()
|
|
36
|
+
if (value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1)
|
|
37
|
+
if (value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1)
|
|
38
|
+
metadata[key] = value
|
|
39
|
+
}
|
|
40
|
+
const rest = content.slice(match[0].length)
|
|
41
|
+
return { metadata, body: rest.replace(/^\s+/, '') }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** One selectable skill in the catalog. */
|
|
45
|
+
export interface SkillCatalogEntry {
|
|
46
|
+
readonly category: string
|
|
47
|
+
readonly name: string
|
|
48
|
+
readonly description: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* List selectable skills (strix `_iter_user_skill_files` + frontmatter):
|
|
53
|
+
* root-level `*.md` plus category directories, excluding internal
|
|
54
|
+
* categories from the catalog.
|
|
55
|
+
* @param skillsRoot - the skills tree root.
|
|
56
|
+
*/
|
|
57
|
+
export function getAvailableSkills(skillsRoot: string): Record<string, Array<{ name: string; description: string }>> {
|
|
58
|
+
const grouped: Record<string, Array<{ name: string; description: string }>> = {}
|
|
59
|
+
if (!existsSync(skillsRoot)) return grouped
|
|
60
|
+
const seen = new Set<string>()
|
|
61
|
+
const consider = (category: string, name: string): void => {
|
|
62
|
+
const key = `${category}/${name}`
|
|
63
|
+
if (seen.has(key)) return
|
|
64
|
+
const path = category === 'root' ? join(skillsRoot, `${name}.md`) : join(skillsRoot, category, `${name}.md`)
|
|
65
|
+
if (!existsSync(path)) return
|
|
66
|
+
const { metadata } = parseSkillContent(readFileSync(path, 'utf8'))
|
|
67
|
+
const description = (metadata['description'] ?? '').split(/\s+/).filter(Boolean).join(' ')
|
|
68
|
+
grouped[category] ??= []
|
|
69
|
+
grouped[category]?.push({ name, description })
|
|
70
|
+
seen.add(key)
|
|
71
|
+
}
|
|
72
|
+
const entries = readdirSync(skillsRoot, { withFileTypes: true })
|
|
73
|
+
const rootFiles = entries.filter(entry => entry.isFile() && entry.name.endsWith('.md') && !entry.name.startsWith('__') && entry.name !== 'README.md').map(entry => entry.name).sort()
|
|
74
|
+
for (const name of rootFiles) consider('root', name.replace(/\.md$/, ''))
|
|
75
|
+
const categoryDirs = entries.filter(entry => entry.isDirectory() && !entry.name.startsWith('__')).map(entry => entry.name).sort()
|
|
76
|
+
for (const category of categoryDirs) {
|
|
77
|
+
if (INTERNAL_SKILL_CATEGORIES.has(category)) continue
|
|
78
|
+
const files = readdirSync(join(skillsRoot, category)).filter(name => name.endsWith('.md')).sort()
|
|
79
|
+
for (const file of files) consider(category, file.replace(/\.md$/, ''))
|
|
80
|
+
}
|
|
81
|
+
return grouped
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Resolve the deduped, ordered skills list (strix `_resolve_skills`).
|
|
86
|
+
* @param options - requested skills and the shape flags.
|
|
87
|
+
*/
|
|
88
|
+
export function resolveSkills(options: {
|
|
89
|
+
readonly requested?: readonly string[]
|
|
90
|
+
readonly scanMode: string
|
|
91
|
+
readonly isRoot: boolean
|
|
92
|
+
readonly isWhitebox: boolean
|
|
93
|
+
readonly isDiffScoped: boolean
|
|
94
|
+
}): string[] {
|
|
95
|
+
const ordered: string[] = [...(options.requested ?? [])]
|
|
96
|
+
ordered.push(`scan_modes/${options.scanMode}`)
|
|
97
|
+
if (options.isDiffScoped) ordered.push('scan_modes/diff')
|
|
98
|
+
ordered.push('tooling/agent_browser')
|
|
99
|
+
ordered.push('tooling/python')
|
|
100
|
+
ordered.push('analysis/counterevidence')
|
|
101
|
+
ordered.push('analysis/severity_calibration')
|
|
102
|
+
if (options.isRoot) ordered.push('coordination/root_agent')
|
|
103
|
+
if (options.isWhitebox) {
|
|
104
|
+
ordered.push('coordination/source_aware_whitebox')
|
|
105
|
+
ordered.push('custom/source_aware_sast')
|
|
106
|
+
ordered.push('analysis/source_aware_discovery')
|
|
107
|
+
ordered.push('analysis/fix_verification')
|
|
108
|
+
}
|
|
109
|
+
const deduped: string[] = []
|
|
110
|
+
const seen = new Set<string>()
|
|
111
|
+
for (const skill of ordered) {
|
|
112
|
+
if (skill !== '' && !seen.has(skill)) {
|
|
113
|
+
deduped.push(skill)
|
|
114
|
+
seen.add(skill)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return deduped
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Load skill bodies keyed by their BARE names (strix `load_skills` returns
|
|
122
|
+
* {'deep': ...}, not {'scan_modes/deep': ...} — the `<skill_name>` tags in
|
|
123
|
+
* the prompt use bare names). Missing skills resolve to ''.
|
|
124
|
+
* @param names - qualified skill names (category/name).
|
|
125
|
+
* @param skillsRoot - the skills tree root.
|
|
126
|
+
*/
|
|
127
|
+
export function loadSkills(names: readonly string[], skillsRoot: string): Record<string, string> {
|
|
128
|
+
const content: Record<string, string> = {}
|
|
129
|
+
for (const name of names) {
|
|
130
|
+
const [category, fileName] = name.split('/')
|
|
131
|
+
if (category === undefined || fileName === undefined) {
|
|
132
|
+
content[name] = ''
|
|
133
|
+
continue
|
|
134
|
+
}
|
|
135
|
+
const path = join(skillsRoot, category, `${fileName}.md`)
|
|
136
|
+
if (existsSync(path)) content[fileName] = parseSkillContent(readFileSync(path, 'utf8')).body
|
|
137
|
+
}
|
|
138
|
+
return content
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Validate a requested skill list (strix `validate_requested_skills`).
|
|
143
|
+
* @param skillList - the requested names.
|
|
144
|
+
* @param skillsRoot - the skills tree root.
|
|
145
|
+
* @returns an error message, or null when valid.
|
|
146
|
+
*/
|
|
147
|
+
export function validateRequestedSkills(skillList: readonly string[], skillsRoot: string, maxSkills = 5): string | null {
|
|
148
|
+
if (skillList.length > maxSkills) {
|
|
149
|
+
return `Cannot specify more than ${String(maxSkills)} skills per agent; got ${String(skillList.length)}. Aim for 1-3 related skills per specialist.`
|
|
150
|
+
}
|
|
151
|
+
if (skillList.length === 0) return null
|
|
152
|
+
const catalog = getAvailableSkills(skillsRoot)
|
|
153
|
+
const availableNames = new Set(Object.values(catalog).flat().map(entry => entry.name))
|
|
154
|
+
const availableKeys = new Set(Object.entries(catalog).flatMap(([category, entries]) => entries.map(entry => `${category}/${entry.name}`)))
|
|
155
|
+
const invalid = [...new Set(skillList.filter(skill => !availableNames.has(skill) && !availableKeys.has(skill)))].sort()
|
|
156
|
+
if (invalid.length > 0) {
|
|
157
|
+
return `Invalid skill name(s): ${JSON.stringify(invalid)}. Available skills: ${JSON.stringify(Object.values(catalog).flat().map(entry => entry.name).sort())}`
|
|
158
|
+
}
|
|
159
|
+
const ambiguousNames = new Set<string>()
|
|
160
|
+
const nameCounts = new Map<string, number>()
|
|
161
|
+
for (const entries of Object.values(catalog)) {
|
|
162
|
+
for (const entry of entries) nameCounts.set(entry.name, (nameCounts.get(entry.name) ?? 0) + 1)
|
|
163
|
+
}
|
|
164
|
+
for (const [name, count] of nameCounts) {
|
|
165
|
+
if (count > 1) ambiguousNames.add(name)
|
|
166
|
+
}
|
|
167
|
+
const ambiguous = skillList.filter(skill => !skill.includes('/') && ambiguousNames.has(skill)).sort()
|
|
168
|
+
if (ambiguous.length > 0) {
|
|
169
|
+
return `Ambiguous skill name(s): ${JSON.stringify(ambiguous)}. Use category-qualified names from: ${JSON.stringify([...availableKeys].sort())}`
|
|
170
|
+
}
|
|
171
|
+
return null
|
|
172
|
+
}
|