kifaru 1.0.153 → 1.0.161
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 +21 -0
- package/extensions/kifaru-coverage-tracker.ts +138 -0
- package/extensions/kifaru-cve-intel.ts +92 -0
- package/extensions/kifaru-dossier.ts +347 -0
- package/extensions/kifaru-proof-gate.ts +183 -0
- package/extensions/kifaru-report-lint.ts +237 -0
- package/extensions/kifaru-session.ts +349 -0
- package/kifaru-pi.js +127320 -0
- package/package.json +35 -14
- package/skills/kifaru-claim-discipline/SKILL.md +25 -0
- package/skills/kifaru-coverage-method/SKILL.md +43 -0
- package/skills/kifaru-hunt-intake/SKILL.md +36 -0
- package/skills/kifaru-hypothesis-engineering/SKILL.md +34 -0
- package/skills/kifaru-playbook-auth-flow/SKILL.md +40 -0
- package/skills/kifaru-playbook-business-logic/SKILL.md +35 -0
- package/skills/kifaru-playbook-graphql/SKILL.md +31 -0
- package/skills/kifaru-playbook-idor/SKILL.md +39 -0
- package/skills/kifaru-playbook-race-condition/SKILL.md +33 -0
- package/skills/kifaru-playbook-waf-bypass/SKILL.md +37 -0
- package/skills/kifaru-recon-intelligence/SKILL.md +45 -0
- package/skills/kifaru-source-analysis/SKILL.md +29 -0
- package/skills/kifaru-verification-discipline/SKILL.md +32 -0
- package/skills/kifaru-vulnerability-report/SKILL.md +32 -0
- package/bin/kifaru +0 -41
- package/bin/kifaru-allow-scripts.cjs +0 -80
- package/bin/opencode +0 -41
- package/bin/runtime-package.cjs +0 -100
- package/postinstall.mjs +0 -77
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 opencode
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kifaru-coverage-tracker.ts — pi extension: (Asset × VulnClass) coverage.
|
|
3
|
+
*
|
|
4
|
+
* Machine-readable counterpart of the kifaru-coverage-method skill: keeps a
|
|
5
|
+
* durable coverage ledger (terminal states TESTED_SAFE / VERIFIED / GHOST /
|
|
6
|
+
* JUSTIFIED_BLOCKED, non-terminal PENDING / OBSTRUCTED), records blocked-cell
|
|
7
|
+
* justifications, and reports what is still open — so hunts cannot silently
|
|
8
|
+
* skip cells and completion claims are honest.
|
|
9
|
+
*
|
|
10
|
+
* State persists through pi.appendEntry (custom entries never enter model
|
|
11
|
+
* context) and is restored on session_start. Loaded automatically from
|
|
12
|
+
* .pi/extensions/ (project scope) or via `-e`.
|
|
13
|
+
*
|
|
14
|
+
* Surface:
|
|
15
|
+
* - tool kifaru_coverage { action: "set"|"block"|"list"|"open", ... }
|
|
16
|
+
* - command /kifaru-coverage [list|open]
|
|
17
|
+
*/
|
|
18
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"
|
|
19
|
+
import { Type } from "typebox"
|
|
20
|
+
|
|
21
|
+
export type CoverageState = "TESTED_SAFE" | "VERIFIED" | "GHOST" | "JUSTIFIED_BLOCKED" | "PENDING" | "OBSTRUCTED"
|
|
22
|
+
const TERMINAL: CoverageState[] = ["TESTED_SAFE", "VERIFIED", "GHOST", "JUSTIFIED_BLOCKED"]
|
|
23
|
+
|
|
24
|
+
export interface CoverageCell {
|
|
25
|
+
key: string // `${asset}::${vulnClass}`
|
|
26
|
+
asset: string
|
|
27
|
+
vulnClass: string
|
|
28
|
+
state: CoverageState
|
|
29
|
+
evidence: string // the decisive test / proof / probe / justification
|
|
30
|
+
updatedAt: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let cells: CoverageCell[] = []
|
|
34
|
+
|
|
35
|
+
function cellKey(asset: string, vulnClass: string): string {
|
|
36
|
+
return `${asset.trim().toLowerCase()}::${vulnClass.trim().toLowerCase()}`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function upsert(cell: Omit<CoverageCell, "key" | "updatedAt">): CoverageCell {
|
|
40
|
+
const key = cellKey(cell.asset, cell.vulnClass)
|
|
41
|
+
const existing = cells.find((c) => c.key === key)
|
|
42
|
+
if (existing) {
|
|
43
|
+
existing.state = cell.state
|
|
44
|
+
existing.evidence = cell.evidence
|
|
45
|
+
existing.updatedAt = Date.now()
|
|
46
|
+
return existing
|
|
47
|
+
}
|
|
48
|
+
const next: CoverageCell = { key, ...cell, updatedAt: Date.now() }
|
|
49
|
+
cells.push(next)
|
|
50
|
+
return next
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function render(onlyOpen: boolean): string {
|
|
54
|
+
const open = cells.filter((c) => !TERMINAL.includes(c.state))
|
|
55
|
+
const list = onlyOpen ? open : cells
|
|
56
|
+
const lines = []
|
|
57
|
+
lines.push(`Coverage ledger: ${cells.length} cells (${open.length} open / non-terminal)`)
|
|
58
|
+
for (const c of list) {
|
|
59
|
+
lines.push(` [${c.state}] ${c.asset} × ${c.vulnClass}${c.evidence ? ` — ${c.evidence.slice(0, 140)}` : ""}`)
|
|
60
|
+
}
|
|
61
|
+
if (onlyOpen && open.length === 0) lines.push(" (no open cells — all terminal)")
|
|
62
|
+
return lines.join("\n")
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export default function (pi: ExtensionAPI) {
|
|
66
|
+
pi.on("session_start", async (_event, ctx: ExtensionContext) => {
|
|
67
|
+
cells = []
|
|
68
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
69
|
+
if (entry.type === "custom" && entry.customType === "kifaru-coverage" && Array.isArray(entry.data?.cells)) {
|
|
70
|
+
cells = entry.data.cells as CoverageCell[]
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
const persist = () => pi.appendEntry("kifaru-coverage", { cells })
|
|
76
|
+
|
|
77
|
+
pi.registerTool({
|
|
78
|
+
name: "kifaru_coverage",
|
|
79
|
+
label: "Kifaru Coverage",
|
|
80
|
+
description:
|
|
81
|
+
"Track hunt coverage as (asset × vulnerability class) cells. Terminal states: TESTED_SAFE, VERIFIED, GHOST, JUSTIFIED_BLOCKED. Non-terminal: PENDING, OBSTRUCTED. Use 'block' to justify why a cell cannot be tested (never silently skip), 'list'/'open' to review the ledger. A hunt is not complete while open cells remain.",
|
|
82
|
+
promptSnippet: "Track (asset × vuln class) coverage cells and record honest blocked justifications",
|
|
83
|
+
parameters: Type.Object({
|
|
84
|
+
action: Type.Union([Type.Literal("set"), Type.Literal("block"), Type.Literal("list"), Type.Literal("open")]),
|
|
85
|
+
asset: Type.Optional(Type.String({ description: "Asset identifier (host, endpoint, repo, app)" })),
|
|
86
|
+
vulnClass: Type.Optional(Type.String({ description: "Vulnerability class (e.g. idor, sqli, race-condition)" })),
|
|
87
|
+
state: Type.Optional(
|
|
88
|
+
Type.Union([
|
|
89
|
+
Type.Literal("TESTED_SAFE"),
|
|
90
|
+
Type.Literal("VERIFIED"),
|
|
91
|
+
Type.Literal("GHOST"),
|
|
92
|
+
Type.Literal("JUSTIFIED_BLOCKED"),
|
|
93
|
+
Type.Literal("PENDING"),
|
|
94
|
+
Type.Literal("OBSTRUCTED"),
|
|
95
|
+
]),
|
|
96
|
+
),
|
|
97
|
+
evidence: Type.Optional(Type.String({ description: "Decisive test / minimal proof / probe / justification" })),
|
|
98
|
+
}),
|
|
99
|
+
async execute(_toolCallId, params) {
|
|
100
|
+
if (params.action === "list" || params.action === "open") {
|
|
101
|
+
return { content: [{ type: "text", text: render(params.action === "open") }], details: { ok: true } }
|
|
102
|
+
}
|
|
103
|
+
if (!params.asset || !params.vulnClass || !params.state) {
|
|
104
|
+
return {
|
|
105
|
+
content: [{ type: "text", text: "set/block require asset, vulnClass and state." }],
|
|
106
|
+
details: { ok: false },
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const cell = upsert({
|
|
110
|
+
asset: params.asset,
|
|
111
|
+
vulnClass: params.vulnClass,
|
|
112
|
+
state: params.state,
|
|
113
|
+
evidence: params.evidence ?? "",
|
|
114
|
+
})
|
|
115
|
+
persist()
|
|
116
|
+
const terminal = TERMINAL.includes(cell.state)
|
|
117
|
+
? "terminal"
|
|
118
|
+
: "NOT terminal — must be resolved, pivoted, or formally justified"
|
|
119
|
+
return {
|
|
120
|
+
content: [
|
|
121
|
+
{
|
|
122
|
+
type: "text",
|
|
123
|
+
text: `Recorded [${cell.state}] ${cell.asset} × ${cell.vulnClass} (${terminal}). Open cells: ${cells.filter((c) => !TERMINAL.includes(c.state)).length}`,
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
details: { ok: true },
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
pi.registerCommand("kifaru-coverage", {
|
|
132
|
+
description: "Show the coverage ledger (list|open)",
|
|
133
|
+
handler: async (args, ctx) => {
|
|
134
|
+
const onlyOpen = args?.trim()?.toLowerCase() === "open" || args?.trim()?.toLowerCase() === ""
|
|
135
|
+
pi.sendMessage({ content: render(onlyOpen), display: true, details: {} })
|
|
136
|
+
},
|
|
137
|
+
})
|
|
138
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kifaru-cve-intel.ts — pi extension: CVE lookup + version cross-check.
|
|
3
|
+
*
|
|
4
|
+
* Provides the vulnerability-intelligence surface (opencode intelligence/
|
|
5
|
+
* cve-db + cve-feed) for the Pi runtime: a local curated table for the ~15
|
|
6
|
+
* CVEs Kifaru tooling cites by default, an optional network refresh from the
|
|
7
|
+
* CIRCL API (guarded by timeout; skipped when PI_OFFLINE=1), and the
|
|
8
|
+
* RQ-01 version-consistency cross-check reused from kifaru-report-lint.
|
|
9
|
+
*
|
|
10
|
+
* Loaded automatically from .pi/extensions/ (project scope) or via `-e`.
|
|
11
|
+
*
|
|
12
|
+
* Surface:
|
|
13
|
+
* - tool kifaru_cve_lookup { cveId, version?, refresh? }
|
|
14
|
+
* - command /kifaru-cve <CVE-ID>
|
|
15
|
+
*/
|
|
16
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
|
|
17
|
+
import { Type } from "typebox"
|
|
18
|
+
import { cveVersionCheck } from "./kifaru-report-lint"
|
|
19
|
+
|
|
20
|
+
async function fetchRemoteCve(cveId: string, signal?: AbortSignal): Promise<string | null> {
|
|
21
|
+
if (process.env.PI_OFFLINE === "1") return null
|
|
22
|
+
try {
|
|
23
|
+
const ctrl = new AbortController()
|
|
24
|
+
const t = setTimeout(() => ctrl.abort(), 6000)
|
|
25
|
+
signal?.addEventListener("abort", () => ctrl.abort())
|
|
26
|
+
const res = await fetch(`https://cve.circl.lu/api/cve/${encodeURIComponent(cveId)}`, { signal: ctrl.signal })
|
|
27
|
+
clearTimeout(t)
|
|
28
|
+
if (!res.ok) return null
|
|
29
|
+
const data = (await res.json()) as {
|
|
30
|
+
id?: string
|
|
31
|
+
summary?: string
|
|
32
|
+
cvss?: number
|
|
33
|
+
cwe?: string
|
|
34
|
+
references?: string[]
|
|
35
|
+
} | null
|
|
36
|
+
if (!data?.summary) return null
|
|
37
|
+
return [
|
|
38
|
+
`CVE: ${data.id ?? cveId}`,
|
|
39
|
+
`summary: ${data.summary.slice(0, 400)}`,
|
|
40
|
+
`cvss: ${data.cvss ?? "n/a"} cwe: ${data.cwe ?? "n/a"}`,
|
|
41
|
+
`references: ${(data.references ?? []).slice(0, 3).join(" | ")}`,
|
|
42
|
+
].join("\n")
|
|
43
|
+
} catch {
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export default function (pi: ExtensionAPI) {
|
|
49
|
+
pi.registerTool({
|
|
50
|
+
name: "kifaru_cve_lookup",
|
|
51
|
+
label: "Kifaru CVE Lookup",
|
|
52
|
+
description:
|
|
53
|
+
"Look up a CVE in the local Kifaru table or via the CIRCL API (when online), and cross-check an observed version against the affected range (RQ-01). Use before citing a CVE in a finding.",
|
|
54
|
+
promptSnippet: "Look up CVEs and cross-check version compatibility before citing them",
|
|
55
|
+
parameters: Type.Object({
|
|
56
|
+
cveId: Type.String({ description: "CVE identifier, e.g. CVE-2021-41773" }),
|
|
57
|
+
version: Type.Optional(Type.String({ description: "Observed version (banner/manifest) to cross-check" })),
|
|
58
|
+
refresh: Type.Optional(Type.Boolean({ description: "Fetch fresh data from the CIRCL API" })),
|
|
59
|
+
}),
|
|
60
|
+
async execute(_toolCallId, params: { cveId: string; version?: string; refresh?: boolean }, signal?: AbortSignal) {
|
|
61
|
+
const cveId = params.cveId.toUpperCase()
|
|
62
|
+
const lines: string[] = []
|
|
63
|
+
const local = cveVersionCheck(cveId, params.version ?? "")
|
|
64
|
+
if (params.version) lines.push(`version check: ${local.verdict} — ${local.reason}`)
|
|
65
|
+
lines.push(`local entry: ${local.name || "not in the curated table (verify against an authoritative feed)"}`)
|
|
66
|
+
if (params.refresh) {
|
|
67
|
+
const remote = await fetchRemoteCve(cveId, signal)
|
|
68
|
+
if (remote) {
|
|
69
|
+
lines.push("")
|
|
70
|
+
lines.push(remote)
|
|
71
|
+
} else {
|
|
72
|
+
lines.push("remote lookup unavailable (offline or API error); local entry shown only.")
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return { content: [{ type: "text", text: lines.join("\n") }], details: { ok: true } }
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
pi.registerCommand("kifaru-cve", {
|
|
80
|
+
description: "Look up a CVE (local table + optional CIRCL refresh)",
|
|
81
|
+
handler: async (args, ctx) => {
|
|
82
|
+
const cveId = (args ?? "").trim().toUpperCase()
|
|
83
|
+
if (!/^CVE-\d{4}-\d{4,7}$/.test(cveId)) {
|
|
84
|
+
ctx.ui.notify("usage: /kifaru-cve CVE-YYYY-NNNNN", "warning")
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
const local = cveVersionCheck(cveId, "")
|
|
88
|
+
const remote = await fetchRemoteCve(cveId)
|
|
89
|
+
pi.sendMessage({ content: [local.reason, remote ?? ""].filter(Boolean).join("\n"), display: true, details: {} })
|
|
90
|
+
},
|
|
91
|
+
})
|
|
92
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kifaru-dossier.ts — pi extension: durable target dossier (observations + findings).
|
|
3
|
+
*
|
|
4
|
+
* Port of the opencode target_dossier machinery for the Pi runtime:
|
|
5
|
+
* - persistence/target-dossier.ts (TargetDossier shape, merge-dedupe updates)
|
|
6
|
+
* - tool/target-dossier.ts (load|update surface)
|
|
7
|
+
* - tool/hunt-dossier.ts list_targets (target roll-up)
|
|
8
|
+
*
|
|
9
|
+
* The dossier JSON keeps the exact opencode `TargetDossier` shape
|
|
10
|
+
* ({ id, target, observations, findings, lastUpdated }) so either runtime can
|
|
11
|
+
* consume the same record. Storage is project-scoped at
|
|
12
|
+
* `.kifaru/dossiers/<slug>.json` (gitignored, cross-session, cross-runtime)
|
|
13
|
+
* instead of the opencode XDG data dir — the Pi agent is the primary run
|
|
14
|
+
* channel, and hunt state already lives under this repo's `.kifaru/`.
|
|
15
|
+
*
|
|
16
|
+
* Use for preserving target context, findings, and handoff notes across turns.
|
|
17
|
+
* Do not use for live reconnaissance or vulnerability lookup.
|
|
18
|
+
*
|
|
19
|
+
* Loaded automatically from .pi/extensions/ (project scope) or via `-e`.
|
|
20
|
+
*
|
|
21
|
+
* Session binding (mirrors opencode maybeHandleHuntBind): the active target is
|
|
22
|
+
* resolved at session start from KIFARU_TUI_TARGET (set at tui launch), or
|
|
23
|
+
* detected from the user's first prompt when no target is bound; the dossier is
|
|
24
|
+
* then bound and a compact dossier context block is injected into the system
|
|
25
|
+
* prompt once per target. The kifaru_dossier tool/command can re-bind anytime.
|
|
26
|
+
*
|
|
27
|
+
* Surface:
|
|
28
|
+
* - tool kifaru_dossier { action: "load"|"update"|"list", target?, observations?, findings? }
|
|
29
|
+
* - command /kifaru-dossier [bind <target>|<target>|list|status]
|
|
30
|
+
*/
|
|
31
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
|
|
32
|
+
import fs from "node:fs/promises"
|
|
33
|
+
import path from "node:path"
|
|
34
|
+
import { Type } from "typebox"
|
|
35
|
+
|
|
36
|
+
export interface TargetDossier {
|
|
37
|
+
id: string
|
|
38
|
+
target: string
|
|
39
|
+
observations: string[]
|
|
40
|
+
findings: string[]
|
|
41
|
+
lastUpdated: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Port of opencode persistence/hunt-dossier `getTargetSlug` (host normalization). */
|
|
45
|
+
export function targetSlug(target: string): string {
|
|
46
|
+
const raw = target.trim()
|
|
47
|
+
if (raw.length === 0) return "unknown"
|
|
48
|
+
|
|
49
|
+
const clean = raw.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, "")
|
|
50
|
+
const host = clean.split("/")[0] ?? clean
|
|
51
|
+
const lower = host.toLowerCase()
|
|
52
|
+
const parts = lower.split(":")
|
|
53
|
+
const name = parts[0] ?? lower
|
|
54
|
+
const port = parts[1]
|
|
55
|
+
|
|
56
|
+
if (name === "localhost") {
|
|
57
|
+
if (port && port.length > 0) return `localhost-${port}`
|
|
58
|
+
return "localhost"
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const isIp = /^\d{1,3}(\.\d{1,3}){3}$/.test(name)
|
|
62
|
+
if (isIp) return name
|
|
63
|
+
|
|
64
|
+
const safe = name.replace(/[^a-z0-9._-]/g, "-")
|
|
65
|
+
return safe.length > 0 ? safe : "unknown"
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function dossierFile(target: string): string {
|
|
69
|
+
return path.join(process.cwd(), ".kifaru", "dossiers", `${targetSlug(target)}.json`)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function emptyDossier(target: string): TargetDossier {
|
|
73
|
+
return { id: target, target: target.trim(), observations: [], findings: [], lastUpdated: Date.now() }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function loadDossier(target: string): Promise<TargetDossier> {
|
|
77
|
+
const file = dossierFile(target)
|
|
78
|
+
const data = await fs
|
|
79
|
+
.readFile(file, "utf8")
|
|
80
|
+
.then((text) => JSON.parse(text) as Partial<TargetDossier>)
|
|
81
|
+
.catch(() => null)
|
|
82
|
+
if (!data || typeof data !== "object") return emptyDossier(target)
|
|
83
|
+
return {
|
|
84
|
+
id: target,
|
|
85
|
+
target: target.trim(),
|
|
86
|
+
observations: Array.isArray(data.observations) ? data.observations.filter((s) => typeof s === "string") : [],
|
|
87
|
+
findings: Array.isArray(data.findings) ? data.findings.filter((s) => typeof s === "string") : [],
|
|
88
|
+
lastUpdated: Date.now(),
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** port of opencode mergeList: skip empties, dedupe, preserve existing order. */
|
|
93
|
+
function mergeList(base: string[], extra: string[] | undefined): string[] {
|
|
94
|
+
const list = extra ?? []
|
|
95
|
+
if (list.length === 0) return base
|
|
96
|
+
const set = new Set(base)
|
|
97
|
+
for (const item of list) {
|
|
98
|
+
if (typeof item !== "string" || item.trim().length === 0) continue
|
|
99
|
+
set.add(item.trim())
|
|
100
|
+
}
|
|
101
|
+
return [...set]
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function updateDossier(input: {
|
|
105
|
+
target: string
|
|
106
|
+
observations?: string[]
|
|
107
|
+
findings?: string[]
|
|
108
|
+
}): Promise<TargetDossier> {
|
|
109
|
+
const current = await loadDossier(input.target)
|
|
110
|
+
const next: TargetDossier = {
|
|
111
|
+
...current,
|
|
112
|
+
observations: mergeList(current.observations, input.observations),
|
|
113
|
+
findings: mergeList(current.findings, input.findings),
|
|
114
|
+
lastUpdated: Date.now(),
|
|
115
|
+
}
|
|
116
|
+
const file = dossierFile(input.target)
|
|
117
|
+
await fs.mkdir(path.dirname(file), { recursive: true })
|
|
118
|
+
// atomic-ish write: temp file + rename so concurrent readers never see a partial JSON
|
|
119
|
+
const tmp = `${file}.tmp`
|
|
120
|
+
await fs.writeFile(tmp, JSON.stringify(next, null, 2), "utf8")
|
|
121
|
+
await fs.rename(tmp, file)
|
|
122
|
+
return next
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function listTargets(): Promise<
|
|
126
|
+
Array<{ slug: string; target: string; observations: number; findings: number; lastUpdated: number }>
|
|
127
|
+
> {
|
|
128
|
+
const root = path.join(process.cwd(), ".kifaru", "dossiers")
|
|
129
|
+
const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => [])
|
|
130
|
+
const items: Array<{ slug: string; target: string; observations: number; findings: number; lastUpdated: number }> = []
|
|
131
|
+
for (const entry of entries) {
|
|
132
|
+
if (!entry.isFile() || !entry.name.endsWith(".json") || entry.name.endsWith(".tmp")) continue
|
|
133
|
+
const data = await fs
|
|
134
|
+
.readFile(path.join(root, entry.name), "utf8")
|
|
135
|
+
.then((text) => JSON.parse(text) as Partial<TargetDossier>)
|
|
136
|
+
.catch(() => null)
|
|
137
|
+
if (!data) continue
|
|
138
|
+
items.push({
|
|
139
|
+
slug: entry.name.replace(/\.json$/, ""),
|
|
140
|
+
target: typeof data.target === "string" ? data.target : entry.name.replace(/\.json$/, ""),
|
|
141
|
+
observations: Array.isArray(data.observations) ? data.observations.length : 0,
|
|
142
|
+
findings: Array.isArray(data.findings) ? data.findings.length : 0,
|
|
143
|
+
lastUpdated: typeof data.lastUpdated === "number" ? data.lastUpdated : 0,
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
return items.sort((a, b) => b.lastUpdated - a.lastUpdated)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---- session binding state (mirrors opencode session/prompt.ts HuntBindState) ----
|
|
150
|
+
|
|
151
|
+
const HUNT_URL_REGEX = /https?:\/\/[^\s<>")']+/gi
|
|
152
|
+
const HUNT_DOMAIN_REGEX = /\b(?:[a-z0-9-]+\.)+[a-z]{2,}\b/gi
|
|
153
|
+
|
|
154
|
+
let boundTarget: string | null = null
|
|
155
|
+
const injectedTargets = new Set<string>()
|
|
156
|
+
|
|
157
|
+
function envTarget(): string | null {
|
|
158
|
+
const value = process.env.KIFARU_TUI_TARGET ?? process.env.KIFARU_TARGET ?? ""
|
|
159
|
+
return value.trim().length > 0 ? value.trim() : null
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Port of opencode detectTarget: first URL, else first domain. */
|
|
163
|
+
function detectTarget(text: string): string | null {
|
|
164
|
+
const urls = text.match(HUNT_URL_REGEX)
|
|
165
|
+
if (urls && urls.length > 0) {
|
|
166
|
+
const url = urls[0]!.trim().replace(/[),.!?;:]$/, "")
|
|
167
|
+
if (url.length > 0 && url.length < 512) return url
|
|
168
|
+
}
|
|
169
|
+
const domains = text.match(HUNT_DOMAIN_REGEX)
|
|
170
|
+
if (domains && domains.length > 0) {
|
|
171
|
+
const domain = domains[0]!.trim()
|
|
172
|
+
if (domain.length > 0 && domain.length < 256) return domain
|
|
173
|
+
}
|
|
174
|
+
return null
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function dossierContextBlock(target: string, dossier: TargetDossier): string {
|
|
178
|
+
const lines = [
|
|
179
|
+
`<kifaru_dossier target="${target}">`,
|
|
180
|
+
`Bound target dossier: ${dossier.observations.length} observation(s), ${dossier.findings.length} finding(s).`,
|
|
181
|
+
`Dossier file: ${path.join(process.cwd(), ".kifaru", "dossiers", `${targetSlug(target)}.json`)}`,
|
|
182
|
+
]
|
|
183
|
+
const recentObservations = dossier.observations.slice(-5)
|
|
184
|
+
if (recentObservations.length > 0) {
|
|
185
|
+
lines.push(`Recent observations:`)
|
|
186
|
+
for (const obs of recentObservations) lines.push(` - ${obs.slice(0, 240)}`)
|
|
187
|
+
}
|
|
188
|
+
const recentFindings = dossier.findings.slice(-3)
|
|
189
|
+
if (recentFindings.length > 0) {
|
|
190
|
+
lines.push(`Recent findings:`)
|
|
191
|
+
for (const finding of recentFindings) lines.push(` - ${finding.slice(0, 240)}`)
|
|
192
|
+
}
|
|
193
|
+
lines.push(`Use kifaru_dossier { action: "update", ... } to persist new context; keep entries concise.`)
|
|
194
|
+
lines.push(`</kifaru_dossier>`)
|
|
195
|
+
return lines.join("\n")
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export default function (pi: ExtensionAPI) {
|
|
199
|
+
pi.on("session_start", async () => {
|
|
200
|
+
boundTarget = envTarget()
|
|
201
|
+
injectedTargets.clear()
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
// Bind the target from the first prompt (opencode hunt-bind interception) and
|
|
205
|
+
// inject the dossier context once per target — before_agent_start only.
|
|
206
|
+
pi.on("before_agent_start", async (event) => {
|
|
207
|
+
const candidate = boundTarget ?? envTarget() ?? detectTarget(event.prompt ?? "")
|
|
208
|
+
if (!candidate || injectedTargets.has(candidate)) return undefined
|
|
209
|
+
boundTarget = candidate
|
|
210
|
+
injectedTargets.add(candidate)
|
|
211
|
+
const dossier = await updateDossier({ target: candidate })
|
|
212
|
+
pi.appendEntry("kifaru-dossier", {
|
|
213
|
+
target: candidate,
|
|
214
|
+
observations: dossier.observations.length,
|
|
215
|
+
findings: dossier.findings.length,
|
|
216
|
+
})
|
|
217
|
+
return {
|
|
218
|
+
systemPrompt: `${event.systemPrompt}\n\n${dossierContextBlock(candidate, dossier)}`,
|
|
219
|
+
message: {
|
|
220
|
+
customType: "kifaru-dossier",
|
|
221
|
+
content: `Session bound to target ${candidate}. Dossier created/loaded under .kifaru/dossiers/.`,
|
|
222
|
+
display: false,
|
|
223
|
+
},
|
|
224
|
+
}
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
pi.registerTool({
|
|
228
|
+
name: "kifaru_dossier",
|
|
229
|
+
label: "Kifaru Target Dossier",
|
|
230
|
+
description:
|
|
231
|
+
"Load or update the persistent target dossier (observations + findings), or list all dossiers. Use to preserve target context, findings, and handoff notes across turns. Not for live reconnaissance or vulnerability lookup.",
|
|
232
|
+
promptSnippet: "Persist and reload target observations and findings across turns",
|
|
233
|
+
parameters: Type.Object({
|
|
234
|
+
action: Type.Union([Type.Literal("load"), Type.Literal("update"), Type.Literal("list")]),
|
|
235
|
+
target: Type.Optional(Type.String({ description: "Target identifier (host, app, repo, api base url)" })),
|
|
236
|
+
observations: Type.Optional(Type.Array(Type.String(), { description: "Observations to add (merged, deduped)" })),
|
|
237
|
+
findings: Type.Optional(Type.Array(Type.String(), { description: "Findings to add (merged, deduped)" })),
|
|
238
|
+
}),
|
|
239
|
+
async execute(
|
|
240
|
+
_toolCallId,
|
|
241
|
+
params: { action: "load" | "update" | "list"; target?: string; observations?: string[]; findings?: string[] },
|
|
242
|
+
) {
|
|
243
|
+
if (params.action === "list") {
|
|
244
|
+
const targets = await listTargets()
|
|
245
|
+
const lines = targets.length
|
|
246
|
+
? targets.map(
|
|
247
|
+
(t) =>
|
|
248
|
+
` ${t.slug} — ${t.observations} obs, ${t.findings} findings (${new Date(t.lastUpdated).toISOString()})`,
|
|
249
|
+
)
|
|
250
|
+
: [' (no dossiers yet — kifaru_dossier { action: "update", target, ... } creates one)']
|
|
251
|
+
return {
|
|
252
|
+
content: [{ type: "text", text: `Target dossiers: ${targets.length}\n${lines.join("\n")}` }],
|
|
253
|
+
details: { ok: true, count: targets.length },
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const target = (params.target ?? "").trim()
|
|
257
|
+
if (!target) {
|
|
258
|
+
return { content: [{ type: "text", text: "load/update require a non-empty target." }], details: { ok: false } }
|
|
259
|
+
}
|
|
260
|
+
// Any explicit dossier action on a target re-binds the session to it.
|
|
261
|
+
boundTarget = target
|
|
262
|
+
if (params.action === "load") {
|
|
263
|
+
const dossier = await loadDossier(target)
|
|
264
|
+
return {
|
|
265
|
+
content: [
|
|
266
|
+
{
|
|
267
|
+
type: "text",
|
|
268
|
+
text: JSON.stringify(dossier, null, 2),
|
|
269
|
+
},
|
|
270
|
+
],
|
|
271
|
+
details: { ok: true, observations: dossier.observations.length, findings: dossier.findings.length },
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const dossier = await updateDossier({ target, observations: params.observations, findings: params.findings })
|
|
275
|
+
return {
|
|
276
|
+
content: [
|
|
277
|
+
{
|
|
278
|
+
type: "text",
|
|
279
|
+
text: JSON.stringify(dossier, null, 2),
|
|
280
|
+
},
|
|
281
|
+
],
|
|
282
|
+
details: { ok: true, observations: dossier.observations.length, findings: dossier.findings.length },
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
pi.registerCommand("kifaru-dossier", {
|
|
288
|
+
description: "Show/bind the target dossier (status, list, bind <target>, or a target slug/URL)",
|
|
289
|
+
handler: async (args, ctx) => {
|
|
290
|
+
const arg = (args ?? "").trim()
|
|
291
|
+
const lower = arg.toLowerCase()
|
|
292
|
+
if (lower === "status" || !arg) {
|
|
293
|
+
const target = boundTarget ?? envTarget()
|
|
294
|
+
if (!target) {
|
|
295
|
+
ctx.ui.notify(
|
|
296
|
+
"No target bound yet — start a message with a URL/domain, or use: /kifaru-dossier bind <target>",
|
|
297
|
+
"info",
|
|
298
|
+
)
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
const dossier = await loadDossier(target)
|
|
302
|
+
pi.sendMessage({
|
|
303
|
+
content: `Bound target: ${target}\nObservations: ${dossier.observations.length}\nFindings: ${dossier.findings.length}\nUpdated: ${new Date(dossier.lastUpdated).toISOString()}`,
|
|
304
|
+
display: true,
|
|
305
|
+
details: {},
|
|
306
|
+
})
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
if (lower === "bind") {
|
|
310
|
+
ctx.ui.notify("usage: /kifaru-dossier bind <target>", "warning")
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
if (lower.startsWith("bind ")) {
|
|
314
|
+
const target = arg.slice(5).trim()
|
|
315
|
+
if (!target) {
|
|
316
|
+
ctx.ui.notify("usage: /kifaru-dossier bind <target>", "warning")
|
|
317
|
+
return
|
|
318
|
+
}
|
|
319
|
+
boundTarget = target
|
|
320
|
+
const dossier = await updateDossier({ target })
|
|
321
|
+
ctx.ui.notify(
|
|
322
|
+
`Bound target dossier: ${target} (${dossier.observations.length} obs, ${dossier.findings.length} findings)`,
|
|
323
|
+
"info",
|
|
324
|
+
)
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
if (lower === "list") {
|
|
328
|
+
const targets = await listTargets()
|
|
329
|
+
const body = targets.length
|
|
330
|
+
? targets.map((t) => `- ${t.slug} (${t.observations} obs, ${t.findings} findings)`).join("\n")
|
|
331
|
+
: "No dossiers yet."
|
|
332
|
+
pi.sendMessage({ content: `Target dossiers:\n${body}`, display: true, details: {} })
|
|
333
|
+
return
|
|
334
|
+
}
|
|
335
|
+
const dossier = await loadDossier(arg)
|
|
336
|
+
const body = [
|
|
337
|
+
`Target: ${dossier.target}`,
|
|
338
|
+
`Observations (${dossier.observations.length}):`,
|
|
339
|
+
...dossier.observations.map((o) => ` - ${o}`),
|
|
340
|
+
`Findings (${dossier.findings.length}):`,
|
|
341
|
+
...dossier.findings.map((f) => ` - ${f}`),
|
|
342
|
+
`Last updated: ${new Date(dossier.lastUpdated).toISOString()}`,
|
|
343
|
+
].join("\n")
|
|
344
|
+
pi.sendMessage({ content: body, display: true, details: {} })
|
|
345
|
+
},
|
|
346
|
+
})
|
|
347
|
+
}
|