mirror of
https://github.com/ruvnet/ruflo.git
synced 2026-10-23 03:47:00 +09:00
feat(plugins): goals, graph-intelligence, intelligence, iot-cognitum as mods
Tighten-only guards on each plugin's own tools (secrets; dossier ID numbers; intelligence reset confirm + public-transfer email screen; destructive cognitum-iot confirm), /<short>-mod local commands, status files, tests and smoke steps. Minor version bumps. Co-Authored-By: RuFlo <ruv@ruv.net>
This commit is contained in:
1 parent
bd3daee854
commit
1ca7d0e788
51 files changed
+1896
-21
No files matched your search
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ruflo-goals",
|
"name": "ruflo-goals",
|
||||||
"description": "Long-horizon goal planning, deep research orchestration, and adaptive replanning using GOAP algorithms",
|
"description": "Long-horizon goal planning, deep research orchestration, and adaptive replanning using GOAP algorithms",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "ruvnet",
|
"name": "ruvnet",
|
||||||
"url": "https://github.com/ruvnet"
|
"url": "https://github.com/ruvnet"
|
||||||
@@ -21,6 +21,33 @@
|
|||||||
"osint",
|
"osint",
|
||||||
"mcp",
|
"mcp",
|
||||||
"evidence-grading",
|
"evidence-grading",
|
||||||
"legacy-namespaces"
|
"legacy-namespaces",
|
||||||
]
|
"mod",
|
||||||
|
"function-hooks",
|
||||||
|
"guard"
|
||||||
|
],
|
||||||
|
"userConfig": {
|
||||||
|
"guard": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Write guard",
|
||||||
|
"description": "Refuse writes and commands on this plugin's own tools that hold a key, token or password.",
|
||||||
|
"required": false,
|
||||||
|
"default": "on",
|
||||||
|
"options": [
|
||||||
|
"on",
|
||||||
|
"off"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"personal": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Dossier ID screen",
|
||||||
|
"description": "Refuse dossier and research writes that hold a government ID number (SSN shape).",
|
||||||
|
"required": false,
|
||||||
|
"default": "on",
|
||||||
|
"options": [
|
||||||
|
"on",
|
||||||
|
"off"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -96,6 +96,23 @@ bash plugins/ruflo-goals/scripts/smoke.sh
|
|||||||
# Expected: "12 passed, 0 failed"
|
# Expected: "12 passed, 0 failed"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## As a mod (0.4.0)
|
||||||
|
|
||||||
|
A function-hook mod ships beside the skills (ADR-445 pattern). Needs a Claude Code with mods (2.1.287+); older builds ignore it. No network, no process spawning: it only tightens calls to this plugin's own tools and reads through tools already connected.
|
||||||
|
|
||||||
|
| Piece | Default | What it does |
|
||||||
|
|---|---|---|
|
||||||
|
| **Write guard** | on | Refuses a write to the goals, horizons, research or dossier namespaces (`memory_store`, `agentdb_*-store`, `hooks_intelligence_pattern-store`) or a `task_create` that holds a key, token or password. |
|
||||||
|
| **Dossier ID screen** | on | Refuses a dossier or research note that holds a government ID number (SSN shape). |
|
||||||
|
| **`/goals-mod`** | — | `status`, `scan <text>`, `horizons` (reads the horizons namespace through the connected memory tool); answered locally, no model call. |
|
||||||
|
| **Status file** | — | `.claude-flow/goals-mod/status.json` (`version`, `updatedMs`, mode flags and counters); written at session start and when a counter changes. |
|
||||||
|
|
||||||
|
Options (`userConfig`): `guard` on\|off, `personal` on\|off. Refusals never echo the value they matched.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude plugin test plugins/ruflo-goals # 10 tests
|
||||||
|
```
|
||||||
|
|
||||||
## Architecture Decisions
|
## Architecture Decisions
|
||||||
|
|
||||||
- [`ADR-0001` — ruflo-goals plugin contract (legacy-vs-canonical namespaces, GOAP/dossier workflow contract)](./docs/adrs/0001-goals-contract.md)
|
- [`ADR-0001` — ruflo-goals plugin contract (legacy-vs-canonical namespaces, GOAP/dossier workflow contract)](./docs/adrs/0001-goals-contract.md)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { scan, tidy } from './screen'
|
||||||
|
import type { ModOptions } from './options'
|
||||||
|
import type { Stats } from './status'
|
||||||
|
|
||||||
|
export type CommandDeps = {
|
||||||
|
readonly opts: ModOptions
|
||||||
|
readonly stats: Stats
|
||||||
|
readonly peek: (suffix: string, args: Record<string, unknown>) => Promise<string | undefined>
|
||||||
|
readonly toolNames: () => Promise<readonly string[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
const HELP = ['/goals-mod status', '/goals-mod scan <text>', '/goals-mod horizons'].join('\n')
|
||||||
|
|
||||||
|
/** `/goals-mod` is answered locally and takes no model turn (the plugin's `/goals` is a prompt command, which no hook can answer). */
|
||||||
|
export async function answer(args: string, deps: CommandDeps): Promise<string> {
|
||||||
|
const [verb = '', ...rest] = args.trim().split(/\s+/)
|
||||||
|
const arg = rest.join(' ')
|
||||||
|
const { opts, stats } = deps
|
||||||
|
|
||||||
|
if (verb === '' || verb === 'help') return HELP
|
||||||
|
|
||||||
|
if (verb === 'status') {
|
||||||
|
return `guard ${opts.guard ? 'on' : 'off'} · dossier ID screen ${opts.personal ? 'on' : 'off'}\nwrites blocked ${stats.blocked} · scans ${stats.scans} · commands ${stats.commands}${stats.lastDenied ? ` · last denied ${stats.lastDenied}` : ''}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verb === 'scan') {
|
||||||
|
if (arg === '') return 'usage: /goals-mod scan <text>'
|
||||||
|
stats.scans++
|
||||||
|
const found = scan(arg).secrets
|
||||||
|
return found.length ? `The guard would refuse a goals or research write of that (secrets: ${found.join(', ')}).` : 'No secret shape found: the guard would let that be stored.'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verb === 'horizons') {
|
||||||
|
const text = await deps.peek('memory_list', { namespace: 'horizons', limit: 10 })
|
||||||
|
if (text === undefined) return 'No memory tool is connected (or it errored). Connect the ruflo MCP server and try again.'
|
||||||
|
return text.trim() === '' ? 'No horizons stored.' : `Horizons (as stored, untrusted data):\n${tidy(text, 1200)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Unknown: ${verb}\n${HELP}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { hasSecret } from './screen'
|
||||||
|
import { bare, namespaceOf, textsOf } from './tools'
|
||||||
|
import type { ModOptions } from './options'
|
||||||
|
|
||||||
|
/** The namespaces the goals, research and dossier skills write to (legacy names included). */
|
||||||
|
const OWNED = /^(?:horizons?|research|goap|dossier|goals?)/i
|
||||||
|
/** Namespaces that can hold facts about a person: dossiers and research notes. */
|
||||||
|
const PEOPLE = /^(?:dossier|research)/i
|
||||||
|
|
||||||
|
const WRITERS = new Set(['memory_store', 'agentdb_hierarchical-store', 'agentdb_pattern-store', 'agentdb_batch', 'hooks_intelligence_pattern-store', 'task_create'])
|
||||||
|
|
||||||
|
// A US social security number; the one personal identifier with a shape tight enough to refuse on.
|
||||||
|
const GOV_ID = /\b\d{3}-\d{2}-\d{4}\b/
|
||||||
|
|
||||||
|
/** The reason a goals/research write is refused, or undefined when it may go. Never names or echoes the value. */
|
||||||
|
export function verdict(tool: string, input: unknown, opts: ModOptions): string | undefined {
|
||||||
|
const name = bare(tool)
|
||||||
|
if (!WRITERS.has(name)) return undefined
|
||||||
|
const ns = namespaceOf(input)
|
||||||
|
if (!OWNED.test(ns) && name !== 'task_create') return undefined
|
||||||
|
const texts = textsOf(input)
|
||||||
|
if (texts.some(hasSecret)) {
|
||||||
|
return 'ruflo-goals: this write holds what looks like a secret (a key, token or password). Record where it lives, not the value.'
|
||||||
|
}
|
||||||
|
if (opts.personal && PEOPLE.test(ns) && texts.some(t => GOV_ID.test(t))) {
|
||||||
|
return 'ruflo-goals: this dossier or research note holds what looks like a government ID number. Leave it out; keep sources and findings only.'
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"description": "Goals as a mod: a tighten-only guard on the plugin's own tools (default on), /goals-mod (local, no model call), and a status file the console reads.",
|
||||||
|
"modules": ["./register.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { PluginOptions } from 'claude-code'
|
||||||
|
|
||||||
|
/** The plugin's `userConfig`, validated: a bad value is the default (both on). */
|
||||||
|
export type ModOptions = { readonly guard: boolean; readonly personal: boolean }
|
||||||
|
|
||||||
|
const flag = (value: unknown, fallback: boolean) =>
|
||||||
|
value === true || value === 'true' || value === 'on' ? true : value === false || value === 'false' || value === 'off' ? false : fallback
|
||||||
|
|
||||||
|
export function readOptions(options: PluginOptions | undefined): ModOptions {
|
||||||
|
const o = options ?? {}
|
||||||
|
return { guard: flag(o.guard, true), personal: flag(o.personal, true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const modeOf = (o: ModOptions): Record<string, boolean> => ({ guard: o.guard, personal: o.personal })
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import type { Hook, Register } from 'claude-code'
|
||||||
|
|
||||||
|
import { answer } from './command'
|
||||||
|
import { verdict } from './guard'
|
||||||
|
import { modeOf, readOptions, type ModOptions } from './options'
|
||||||
|
import { newStats, STATUS_PATH, statusText, type Stats } from './status'
|
||||||
|
import { findTool } from './tools'
|
||||||
|
|
||||||
|
const TEXT_CAP = 20_000
|
||||||
|
|
||||||
|
type Dollar = Parameters<Hook<'session.start'>>[0]
|
||||||
|
|
||||||
|
/** Everything one session of the mod keeps: its settings, its counters and the project root. */
|
||||||
|
type Session = { readonly opts: ModOptions; readonly stats: Stats; root?: string }
|
||||||
|
|
||||||
|
async function flush($: Dollar, s: Session): Promise<void> {
|
||||||
|
if (s.root === undefined) return
|
||||||
|
try {
|
||||||
|
await $.fs.write(`${s.root}/${STATUS_PATH}`, statusText(s.stats, modeOf(s.opts), await $.clock.now()))
|
||||||
|
} catch {
|
||||||
|
/* the status file is a courtesy */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A read-only call through an already-connected tool: its first text block, or undefined when absent or errored. */
|
||||||
|
async function peek($: Dollar, suffix: string, args: Record<string, unknown>): Promise<string | undefined> {
|
||||||
|
const hit = findTool(await $.tool.list(), suffix)
|
||||||
|
if (!hit) return undefined
|
||||||
|
const res = await $.mcp.call(hit.server, hit.tool, args)
|
||||||
|
return res.isError ? undefined : (res.content.find(b => b.type === 'text')?.text ?? '').slice(0, TEXT_CAP)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Goals as a mod (ADR-445 pattern): a tighten-only guard on this plugin's own tools, `/goals-mod`, and a status file the console
|
||||||
|
* reads. No network, no process spawning: only tools already connected.
|
||||||
|
*/
|
||||||
|
export const register: Register = (on, options) => {
|
||||||
|
const s: Session = { opts: readOptions(options), stats: newStats() }
|
||||||
|
|
||||||
|
on('session.start', async ($, e, next) => {
|
||||||
|
const result = await next(e)
|
||||||
|
s.root = (await $.session.root()) as string | undefined
|
||||||
|
try {
|
||||||
|
await $.command.register({ name: 'goals-mod', description: 'Goals mod: status, scan <text>, horizons' })
|
||||||
|
} catch {
|
||||||
|
/* a name taken by another plugin must not stop the mod */
|
||||||
|
}
|
||||||
|
await flush($, s)
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
if (s.opts.guard) {
|
||||||
|
on('tool.call', async ($, e, next) => {
|
||||||
|
const reason = verdict(e.tool, e, s.opts)
|
||||||
|
if (reason === undefined) return next(e)
|
||||||
|
s.stats.blocked++
|
||||||
|
s.stats.lastDenied = e.tool
|
||||||
|
await flush($, s)
|
||||||
|
return { deny: reason }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `/goals-mod` is answered locally and takes no model turn. */
|
||||||
|
on('command.run', { command: 'goals-mod' }, async ($, e) => {
|
||||||
|
s.stats.commands++
|
||||||
|
const text = await answer(typeof e.args === 'string' ? e.args : '', {
|
||||||
|
opts: s.opts,
|
||||||
|
stats: s.stats,
|
||||||
|
peek: (suffix, args) => peek($, suffix, args),
|
||||||
|
toolNames: async () => (await $.tool.list()).map(t => t.name),
|
||||||
|
})
|
||||||
|
await flush($, s)
|
||||||
|
return { text }
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Pure text screening for the AgentDB mod (ADR-445). Two jobs: find secrets (so none is stored) and find prompt-injection phrasing (so
|
||||||
|
* retrieved memory cannot instruct the model). Findings are NAMES only: the matched text is never returned, logged or counted by value.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SECRETS: readonly (readonly [string, RegExp])[] = [
|
||||||
|
['private key', /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
|
||||||
|
['aws access key', /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/],
|
||||||
|
['github token', /\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{40,})\b/],
|
||||||
|
['slack token', /\bxox[abprs]-[A-Za-z0-9-]{10,}/],
|
||||||
|
['google api key', /\bAIza[0-9A-Za-z_-]{35}\b/],
|
||||||
|
['anthropic or openai key', /\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{24,}/],
|
||||||
|
['jwt', /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/],
|
||||||
|
['bearer token', /\bBearer\s+[A-Za-z0-9._~+/=-]{24,}/],
|
||||||
|
['key assignment', /\b(?:api[_-]?key|secret|token|passw(?:or)?d|credential)s?["']?\s*[:=]\s*["']?[A-Za-z0-9/+=_.-]{16,}/i],
|
||||||
|
]
|
||||||
|
|
||||||
|
const INJECTION: readonly (readonly [string, RegExp])[] = [
|
||||||
|
['override instructions', /\b(?:ignore|disregard|forget|override)\b[^.\n]{0,40}\b(?:previous|prior|above|earlier|all|any|system)\b[^.\n]{0,30}\b(?:instructions?|rules?|prompts?|guidelines?)\b/i],
|
||||||
|
['role reassignment', /\byou are (?:now|no longer)\b|\bact as (?:an? )?(?:unrestricted|jailbroken)\b/i],
|
||||||
|
['new instructions', /\b(?:new|updated|real) (?:system )?instructions?\s*:/i],
|
||||||
|
['fake role tags', /<\/?\s*(?:system|assistant|developer|instructions?)\s*>|^\s*(?:system|assistant)\s*:/im],
|
||||||
|
['concealment', /\bdo not (?:tell|inform|mention|reveal)[^.\n]{0,30}\b(?:user|human|operator)\b/i],
|
||||||
|
['exfiltration', /\b(?:exfiltrate|send|post|upload)\b[^.\n]{0,50}\b(?:secrets?|credentials?|tokens?|api keys?|\.env)\b/i],
|
||||||
|
['shell pipe', /\b(?:curl|wget)\b[^|\n]{0,200}\|\s*(?:sudo\s+)?(?:ba|z)?sh\b/i],
|
||||||
|
]
|
||||||
|
|
||||||
|
// C0/C1 controls (keeping tab and newline), DEL, zero-width and bidi override characters.
|
||||||
|
const INVISIBLE = new RegExp('[\\u0000-\\u0008\\u000b-\\u001f\\u007f-\\u009f\\u200b-\\u200f\\u2028-\\u202e\\u2060-\\u2064\\ufeff]', 'g')
|
||||||
|
|
||||||
|
const bare = (text: string) => (text.length > 20_000 ? text.slice(0, 20_000) : text).replace(INVISIBLE, '')
|
||||||
|
|
||||||
|
export type Findings = { readonly secrets: readonly string[]; readonly injection: readonly string[] }
|
||||||
|
|
||||||
|
const names = (rules: readonly (readonly [string, RegExp])[], text: string) => rules.filter(([, re]) => re.test(text)).map(([name]) => name)
|
||||||
|
|
||||||
|
/** Names of every secret shape and injection phrase found in `text`. Cost is linear in the capped input. */
|
||||||
|
export function scan(text: string): Findings {
|
||||||
|
const bounded = bare(text)
|
||||||
|
return { secrets: names(SECRETS, bounded), injection: names(INJECTION, bounded) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hasSecret = (text: string) => names(SECRETS, bare(text)).length > 0
|
||||||
|
|
||||||
|
|
||||||
|
/** Makes stored text safe to show: no control or bidi characters, whitespace collapsed, at most `max` characters. */
|
||||||
|
export function tidy(text: string, max: number): string {
|
||||||
|
const flat = text.replace(INVISIBLE, '').replace(/\s+/g, ' ').trim()
|
||||||
|
return flat.length > max ? `${flat.slice(0, Math.max(0, max - 1))}…` : flat
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/** Counters the mod keeps for the session and writes to `.claude-flow/goals-mod/status.json` for the console. */
|
||||||
|
export type Stats = {
|
||||||
|
blocked: number
|
||||||
|
scans: number
|
||||||
|
commands: number
|
||||||
|
lastDenied?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const newStats = (): Stats => ({ blocked: 0, scans: 0, commands: 0 })
|
||||||
|
|
||||||
|
export const STATUS_PATH = '.claude-flow/goals-mod/status.json'
|
||||||
|
|
||||||
|
/** The file's text: mode flags plus the counters; `version` lets the console refuse a shape it does not know. */
|
||||||
|
export function statusText(stats: Stats, mode: Record<string, boolean>, nowMs: number): string {
|
||||||
|
return `${JSON.stringify({ version: 1, updatedMs: nowMs, ...mode, ...stats }, null, 2)}\n`
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { ToolInfo } from 'claude-code'
|
||||||
|
|
||||||
|
/** `mcp__<server>__<tool>` into its two halves; tool names never hold a double underscore. */
|
||||||
|
export function splitName(name: string): { server: string; tool: string } | undefined {
|
||||||
|
if (!name.startsWith('mcp__')) return undefined
|
||||||
|
const at = name.lastIndexOf('__')
|
||||||
|
return at > 5 ? { server: name.slice(5, at), tool: name.slice(at + 2) } : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The bare tool name: `mcp__srv__memory_store` and `memory_store` both give `memory_store`. */
|
||||||
|
export const bare = (name: string) => splitName(name)?.tool ?? name
|
||||||
|
|
||||||
|
/** A connected MCP tool whose bare name is `suffix`, split into what `$.mcp.call` takes. */
|
||||||
|
export function findTool(tools: readonly ToolInfo[], suffix: string): { server: string; tool: string } | undefined {
|
||||||
|
const hit = tools.find(t => t.mcp && bare(t.name) === suffix)
|
||||||
|
return hit && splitName(hit.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every string in a tool's input, to a bounded depth and size: what the guard reads. */
|
||||||
|
export function textsOf(input: unknown, budget = { left: 20_000 }, depth = 0): string[] {
|
||||||
|
if (budget.left <= 0 || depth > 6) return []
|
||||||
|
if (typeof input === 'string') {
|
||||||
|
budget.left -= input.length
|
||||||
|
return [input]
|
||||||
|
}
|
||||||
|
if (Array.isArray(input)) return input.slice(0, 200).flatMap(v => textsOf(v, budget, depth + 1))
|
||||||
|
if (typeof input === 'object' && input !== null) return Object.values(input).slice(0, 200).flatMap(v => textsOf(v, budget, depth + 1))
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tool input's `namespace` field when it is a string, else ''. */
|
||||||
|
export const namespaceOf = (input: unknown): string => {
|
||||||
|
const ns = (input as { namespace?: unknown } | null)?.namespace
|
||||||
|
return typeof ns === 'string' ? ns : ''
|
||||||
|
}
|
||||||
@@ -7,9 +7,9 @@ step() { printf "→ %s ... " "$1"; }
|
|||||||
ok() { printf "PASS\n"; PASS=$((PASS+1)); }
|
ok() { printf "PASS\n"; PASS=$((PASS+1)); }
|
||||||
bad() { printf "FAIL: %s\n" "$1"; FAIL=$((FAIL+1)); }
|
bad() { printf "FAIL: %s\n" "$1"; FAIL=$((FAIL+1)); }
|
||||||
|
|
||||||
step "1. plugin.json declares 0.3.0 with new keywords"
|
step "1. plugin.json declares 0.4.0 with new keywords"
|
||||||
v=$(grep -E '"version"' "$ROOT/.claude-plugin/plugin.json" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)
|
v=$(grep -E '"version"' "$ROOT/.claude-plugin/plugin.json" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)
|
||||||
if [[ "$v" != "0.3.0" ]]; then bad "expected 0.3.0, got '$v'"; else
|
if [[ "$v" != "0.4.0" ]]; then bad "expected 0.4.0, got '$v'"; else
|
||||||
miss=""
|
miss=""
|
||||||
for k in mcp evidence-grading legacy-namespaces; do
|
for k in mcp evidence-grading legacy-namespaces; do
|
||||||
grep -q "\"$k\"" "$ROOT/.claude-plugin/plugin.json" || miss="$miss $k"
|
grep -q "\"$k\"" "$ROOT/.claude-plugin/plugin.json" || miss="$miss $k"
|
||||||
@@ -98,5 +98,26 @@ grep -E '^ - .*aidefence_scan' "$ROOT/agents/deep-researcher.md" >/dev/null ||
|
|||||||
step "12. research-list.mjs runtime test passes"
|
step "12. research-list.mjs runtime test passes"
|
||||||
out=$(node "$ROOT/scripts/test-research-list.mjs" 2>&1) && ok || bad "$out"
|
out=$(node "$ROOT/scripts/test-research-list.mjs" 2>&1) && ok || bad "$out"
|
||||||
|
|
||||||
|
|
||||||
|
# M1. The mod (ADR-445 pattern): hooks module registered, files within the 500-line rule
|
||||||
|
step "M1. mod: hooks.json names register.ts, every hook file is present and under 500 lines"
|
||||||
|
mod_ok=1
|
||||||
|
grep -q '"./register.ts"' "$ROOT/hooks/hooks.json" || mod_ok=0
|
||||||
|
for f in options screen tools guard command status register; do
|
||||||
|
[[ -f "$ROOT/hooks/$f.ts" ]] || mod_ok=0
|
||||||
|
[[ $(wc -l < "$ROOT/hooks/$f.ts" 2>/dev/null || echo 9999) -le 500 ]] || mod_ok=0
|
||||||
|
done
|
||||||
|
[[ $mod_ok -eq 1 ]] && ok || bad "mod hooks incomplete or too long"
|
||||||
|
|
||||||
|
step "M2. mod: userConfig defaults are the safe ones (guard personal all on)"
|
||||||
|
node -e '
|
||||||
|
const c = require(process.argv[1]).userConfig || {}
|
||||||
|
const need = process.argv.slice(2)
|
||||||
|
process.exit(need.every(k => c[k] && c[k].default === "on") ? 0 : 1)
|
||||||
|
' "$ROOT/.claude-plugin/plugin.json" guard personal && ok || bad "userConfig defaults wrong"
|
||||||
|
|
||||||
|
step "M3. mod: hooks never touch the network or spawn a process"
|
||||||
|
if grep -nE '\$\.(http|process)\.|child_process|fetch\(' "$ROOT"/hooks/*.ts >/dev/null; then bad "network or process call in hooks"; else ok; fi
|
||||||
|
|
||||||
printf "\n%s passed, %s failed\n" "$PASS" "$FAIL"
|
printf "\n%s passed, %s failed\n" "$PASS" "$FAIL"
|
||||||
[[ $FAIL -eq 0 ]] || exit 1
|
[[ $FAIL -eq 0 ]] || exit 1
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import type { On } from 'claude-code'
|
||||||
|
import { describe, expect, test, tier } from 'claude-code/testing'
|
||||||
|
|
||||||
|
tier('user')
|
||||||
|
|
||||||
|
const ROOT = '/work'
|
||||||
|
const START = { surface: 'terminal', isInteractive: true, cwd: ROOT } as const
|
||||||
|
const slash = (args: string) => ({ command: 'goals-mod', args, origin: { kind: 'composer' }, presentation: { isFullscreen: false, columns: 100 } }) as const
|
||||||
|
const SECRET = `ghp_${'a1B2'.repeat(10)}`
|
||||||
|
const STATUS = `${ROOT}/.claude-flow/goals-mod/status.json`
|
||||||
|
|
||||||
|
type Calls = { server: string; tool: string; args: unknown }[]
|
||||||
|
|
||||||
|
/** The world beneath the mod: a project, a file map, the connected tools, and an MCP that answers every call with `reply`. */
|
||||||
|
function world(on: On, tools: readonly string[] = [], reply = '') {
|
||||||
|
const files = new Map<string, string>()
|
||||||
|
const calls: Calls = []
|
||||||
|
on('session.start', ($, e) => ({ cwd: e.cwd }))
|
||||||
|
on('session.root', () => ({ value: ROOT }))
|
||||||
|
on('command.register', ($, e) => ({ value: { command: e.name } }))
|
||||||
|
on('fs.write', ($, e) => (files.set(e.path, e.text), { value: undefined }))
|
||||||
|
on('tool.list', () => ({ value: tools.map(name => ({ name, description: '', mcp: name.startsWith('mcp__') })) }))
|
||||||
|
on('clock.now', () => ({ value: 1_700_000_000_000 }))
|
||||||
|
on('mcp.call', ($, e) => (calls.push({ server: e.server, tool: e.tool, args: e.args }), { value: { content: [{ type: 'text', text: reply }], isError: false } }))
|
||||||
|
return { files, calls, status: () => JSON.parse(files.get(STATUS) ?? '{}') as Record<string, unknown> }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs a tool call through the mod; a denial comes back as its reason text, an allowed call as `ALLOWED`. */
|
||||||
|
async function attempt($: { tool: { call: (i: never) => Promise<unknown> } }, input: Record<string, unknown>): Promise<string> {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(await $.tool.call(input as never))
|
||||||
|
} catch (e) {
|
||||||
|
return String(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CORE = 'mcp__plugin_ruflo-core_ruflo__'
|
||||||
|
|
||||||
|
describe('status file and defaults', () => {
|
||||||
|
test('session start registers /goals-mod and writes status.json (version 1, guard on)', async ($, on) => {
|
||||||
|
const w = world(on)
|
||||||
|
await $.session.start(START)
|
||||||
|
expect(w.status()).toMatchObject({ version: 1, guard: true, blocked: 0, commands: 0 })
|
||||||
|
expect(typeof w.status().updatedMs).toBe('number')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('status, an unknown verb and help answer locally', async ($, on) => {
|
||||||
|
world(on)
|
||||||
|
await $.session.start(START)
|
||||||
|
expect((await $.command.run(slash('status'))).text).toContain('guard on')
|
||||||
|
expect((await $.command.run(slash('what is this'))).text).toContain('/goals-mod status')
|
||||||
|
expect((await $.command.run(slash(''))).text).toContain('/goals-mod scan <text>')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('scan names the secret shape and never echoes the value', async ($, on) => {
|
||||||
|
world(on)
|
||||||
|
await $.session.start(START)
|
||||||
|
const hit = (await $.command.run(slash(`scan key ${SECRET}`))).text ?? ''
|
||||||
|
expect(hit).toContain('github token')
|
||||||
|
expect(hit).not.toContain('ghp_')
|
||||||
|
expect((await $.command.run(slash('scan plain words'))).text).toContain('No secret shape')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('guard', () => {
|
||||||
|
const store = (value: string, namespace = 'horizons') => ({ tool: `${CORE}memory_store`, key: 'k', value, namespace })
|
||||||
|
|
||||||
|
test('refuses a secret in a goals namespace, passes clean text, leaves other namespaces to their own guard', async ($, on) => {
|
||||||
|
const w = world(on)
|
||||||
|
on('tool.call', () => ({ result: 'stored' }))
|
||||||
|
await $.session.start(START)
|
||||||
|
const denied = await attempt($, store(`deploy with ${SECRET}`))
|
||||||
|
expect(denied).toContain('secret')
|
||||||
|
expect(denied).not.toContain('ghp_')
|
||||||
|
expect(await attempt($, store('ship the beta by friday'))).toContain('stored')
|
||||||
|
expect(await attempt($, store(`deploy with ${SECRET}`, 'scratch'))).toContain('stored')
|
||||||
|
expect(w.status()).toMatchObject({ blocked: 1, lastDenied: `${CORE}memory_store` })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a government ID number is refused in a dossier, not in a plan', async ($, on) => {
|
||||||
|
world(on)
|
||||||
|
on('tool.call', () => ({ result: 'stored' }))
|
||||||
|
await $.session.start(START)
|
||||||
|
const denied = await attempt($, store('subject SSN 123-45-6789', 'dossier-acme'))
|
||||||
|
expect(denied).toContain('government ID')
|
||||||
|
expect(denied).not.toContain('123-45')
|
||||||
|
expect(await attempt($, store('order 123-45-6789 shipped', 'goap-plans'))).toContain('stored')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('task_create is screened too', async ($, on) => {
|
||||||
|
world(on)
|
||||||
|
on('tool.call', () => ({ result: 'stored' }))
|
||||||
|
await $.session.start(START)
|
||||||
|
expect(await attempt($, { tool: `${CORE}task_create`, description: `use ${SECRET}` })).toContain('secret')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('guard: off and personal: off relax it', { options: { guard: 'off' } }, async ($, on) => {
|
||||||
|
world(on)
|
||||||
|
on('tool.call', () => ({ result: 'stored' }))
|
||||||
|
await $.session.start(START)
|
||||||
|
expect(await attempt($, store(`deploy with ${SECRET}`))).toContain('stored')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('personal: off lets an ID number through but not a secret', { options: { personal: 'off' } }, async ($, on) => {
|
||||||
|
world(on)
|
||||||
|
on('tool.call', () => ({ result: 'stored' }))
|
||||||
|
await $.session.start(START)
|
||||||
|
expect(await attempt($, store('SSN 123-45-6789', 'dossier-acme'))).toContain('stored')
|
||||||
|
expect(await attempt($, store(SECRET, 'dossier-acme'))).toContain('secret')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('/goals-mod horizons', () => {
|
||||||
|
test('reads the horizons namespace through the connected memory tool', async ($, on) => {
|
||||||
|
const w = world(on, [`${CORE}memory_list`], 'Q4 launch: milestone 2 of 5')
|
||||||
|
await $.session.start(START)
|
||||||
|
const text = (await $.command.run(slash('horizons'))).text ?? ''
|
||||||
|
expect(text).toContain('Q4 launch')
|
||||||
|
expect(w.calls[0]).toMatchObject({ server: 'plugin_ruflo-core_ruflo', tool: 'memory_list', args: { namespace: 'horizons' } })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('says plainly when no memory tool is connected', async ($, on) => {
|
||||||
|
world(on)
|
||||||
|
await $.session.start(START)
|
||||||
|
expect((await $.command.run(slash('horizons'))).text).toContain('No memory tool is connected')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"//": "Self-contained on purpose: bundlers (vite/esbuild in the root CI vitest) load this file for every module they transform, so it must not extend a file that only exists after Claude Code (>= 2.1.287) has loaded the plugin. Claude Code writes its declarations to .claude-plugin/types/ (gitignored) on load or validate; tsc -p . then reads them through typeRoots.",
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["es2023"],
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"jsx": "react",
|
||||||
|
"jsxFactory": "h",
|
||||||
|
"jsxFragmentFactory": "Fragment",
|
||||||
|
"typeRoots": ["./.claude-plugin/types"],
|
||||||
|
"types": ["claude-code", "claude-code-tools", "claude-code-mcp"]
|
||||||
|
},
|
||||||
|
"include": ["hooks", "tests"]
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ruflo-graph-intelligence",
|
"name": "ruflo-graph-intelligence",
|
||||||
"description": "RuFlo Graph Intelligence Engine — real-time relationship intelligence with complexity-aware execution. Single-entry personalized PageRank, streaming delta updates, witness-signed reasoning artifacts, federation-distributable PR vectors. Built on sublinear-time-solver@1.7.0.",
|
"description": "RuFlo Graph Intelligence Engine — real-time relationship intelligence with complexity-aware execution. Single-entry personalized PageRank, streaming delta updates, witness-signed reasoning artifacts, federation-distributable PR vectors. Built on sublinear-time-solver@1.7.0.",
|
||||||
"version": "0.1.0-alpha.1",
|
"version": "0.2.0-alpha.1",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "ruvnet",
|
"name": "ruvnet",
|
||||||
"url": "https://github.com/ruvnet"
|
"url": "https://github.com/ruvnet"
|
||||||
@@ -20,8 +20,29 @@
|
|||||||
"federation",
|
"federation",
|
||||||
"mcp",
|
"mcp",
|
||||||
"claude-flow",
|
"claude-flow",
|
||||||
"adr-123"
|
"adr-123",
|
||||||
|
"mod",
|
||||||
|
"function-hooks",
|
||||||
|
"guard"
|
||||||
],
|
],
|
||||||
"categories": ["intelligence", "graph", "memory", "substrate"],
|
"categories": [
|
||||||
"repository": "https://github.com/ruvnet/ruflo"
|
"intelligence",
|
||||||
|
"graph",
|
||||||
|
"memory",
|
||||||
|
"substrate"
|
||||||
|
],
|
||||||
|
"repository": "https://github.com/ruvnet/ruflo",
|
||||||
|
"userConfig": {
|
||||||
|
"guard": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Write guard",
|
||||||
|
"description": "Refuse writes and commands on this plugin's own tools that hold a key, token or password.",
|
||||||
|
"required": false,
|
||||||
|
"default": "on",
|
||||||
|
"options": [
|
||||||
|
"on",
|
||||||
|
"off"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# ruflo-graph-intelligence
|
||||||
|
|
||||||
|
Graph Intelligence Engine for RuFlo: single-entry personalized PageRank, streaming delta updates and witness-signed reasoning artifacts (ADR-123), built on sublinear-time-solver. Library: `npm test`, `npm run build`.
|
||||||
|
|
||||||
|
## As a mod (0.2.0-alpha.1)
|
||||||
|
|
||||||
|
A function-hook mod ships beside the skills (ADR-445 pattern). Needs a Claude Code with mods (2.1.287+); older builds ignore it. No network, no process spawning: it only tightens calls to this plugin's own tools and reads through tools already connected.
|
||||||
|
|
||||||
|
| Piece | Default | What it does |
|
||||||
|
|---|---|---|
|
||||||
|
| **Write guard** | on | Refuses a call to a `sublinear/*` (`sublinear_*`) tool whose input holds a key, token or password: graph, node and seed ids land in signed artifacts that federation can copy. |
|
||||||
|
| **`/graph-mod`** | — | `status`, `scan <text>`, `tools` (lists connected graph-intelligence tools); answered locally, no model call. |
|
||||||
|
| **Status file** | — | `.claude-flow/graph-mod/status.json` (`version`, `updatedMs`, mode flags and counters); written at session start and when a counter changes. |
|
||||||
|
|
||||||
|
Options (`userConfig`): `guard` on\|off. Refusals never echo the value they matched.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash plugins/ruflo-graph-intelligence/scripts/smoke.sh # stages the mod and runs claude plugin test (7 tests); the engine's own tests: npm test
|
||||||
|
```
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { scan } from './screen'
|
||||||
|
import { isGraphTool } from './guard'
|
||||||
|
import type { ModOptions } from './options'
|
||||||
|
import type { Stats } from './status'
|
||||||
|
|
||||||
|
export type CommandDeps = {
|
||||||
|
readonly opts: ModOptions
|
||||||
|
readonly stats: Stats
|
||||||
|
readonly peek: (suffix: string, args: Record<string, unknown>) => Promise<string | undefined>
|
||||||
|
readonly toolNames: () => Promise<readonly string[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
const HELP = ['/graph-mod status', '/graph-mod scan <text>', '/graph-mod tools'].join('\n')
|
||||||
|
|
||||||
|
/** `/graph-mod` is answered locally and takes no model turn. */
|
||||||
|
export async function answer(args: string, deps: CommandDeps): Promise<string> {
|
||||||
|
const [verb = '', ...rest] = args.trim().split(/\s+/)
|
||||||
|
const arg = rest.join(' ')
|
||||||
|
const { opts, stats } = deps
|
||||||
|
|
||||||
|
if (verb === '' || verb === 'help') return HELP
|
||||||
|
|
||||||
|
if (verb === 'status') {
|
||||||
|
return `guard ${opts.guard ? 'on' : 'off'}\ncalls blocked ${stats.blocked} · scans ${stats.scans} · commands ${stats.commands}${stats.lastDenied ? ` · last denied ${stats.lastDenied}` : ''}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verb === 'scan') {
|
||||||
|
if (arg === '') return 'usage: /graph-mod scan <text>'
|
||||||
|
stats.scans++
|
||||||
|
const found = scan(arg).secrets
|
||||||
|
return found.length ? `The guard would refuse a graph call with that (secrets: ${found.join(', ')}).` : 'No secret shape found: the guard would let that through.'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verb === 'tools') {
|
||||||
|
const names = (await deps.toolNames()).filter(isGraphTool)
|
||||||
|
return names.length ? `Connected graph-intelligence tools:\n${names.map(n => `- ${n}`).join('\n')}` : 'No graph-intelligence tool is connected (the engine ships as a library; register its MCP tools to use it).'
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Unknown: ${verb}\n${HELP}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { hasSecret } from './screen'
|
||||||
|
import { bare, textsOf } from './tools'
|
||||||
|
import type { ModOptions } from './options'
|
||||||
|
|
||||||
|
/** The engine's tools are named `sublinear/<verb>` and surface as `sublinear_*` once an MCP server carries them. */
|
||||||
|
export const isGraphTool = (name: string) => /^sublinear[/_-]|graph[-_]intelligence/i.test(bare(name))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reason a graph-intelligence call is refused, or undefined when it may go. Its inputs (graph ids, node ids, seed nodes) end up in
|
||||||
|
* signed reasoning artifacts and federation-distributable vectors, so a secret there is copied to every peer. Never echoes the value.
|
||||||
|
*/
|
||||||
|
export function verdict(tool: string, input: unknown, _opts: ModOptions): string | undefined {
|
||||||
|
if (!isGraphTool(tool)) return undefined
|
||||||
|
return textsOf(input).some(hasSecret)
|
||||||
|
? 'ruflo-graph-intelligence: this call holds what looks like a secret (a key, token or password). Graph ids and node ids end up in signed artifacts that federation can copy; use an opaque id.'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"description": "Graph intelligence as a mod: a tighten-only guard on the plugin's own tools (default on), /graph-mod (local, no model call), and a status file the console reads.",
|
||||||
|
"modules": ["./register.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { PluginOptions } from 'claude-code'
|
||||||
|
|
||||||
|
/** The plugin's `userConfig`, validated: a bad value is the default (guard on). */
|
||||||
|
export type ModOptions = { readonly guard: boolean }
|
||||||
|
|
||||||
|
const flag = (value: unknown, fallback: boolean) =>
|
||||||
|
value === true || value === 'true' || value === 'on' ? true : value === false || value === 'false' || value === 'off' ? false : fallback
|
||||||
|
|
||||||
|
export function readOptions(options: PluginOptions | undefined): ModOptions {
|
||||||
|
return { guard: flag((options ?? {}).guard, true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const modeOf = (o: ModOptions): Record<string, boolean> => ({ guard: o.guard })
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import type { Hook, Register } from 'claude-code'
|
||||||
|
|
||||||
|
import { answer } from './command'
|
||||||
|
import { verdict } from './guard'
|
||||||
|
import { modeOf, readOptions, type ModOptions } from './options'
|
||||||
|
import { newStats, STATUS_PATH, statusText, type Stats } from './status'
|
||||||
|
import { findTool } from './tools'
|
||||||
|
|
||||||
|
const TEXT_CAP = 20_000
|
||||||
|
|
||||||
|
type Dollar = Parameters<Hook<'session.start'>>[0]
|
||||||
|
|
||||||
|
/** Everything one session of the mod keeps: its settings, its counters and the project root. */
|
||||||
|
type Session = { readonly opts: ModOptions; readonly stats: Stats; root?: string }
|
||||||
|
|
||||||
|
async function flush($: Dollar, s: Session): Promise<void> {
|
||||||
|
if (s.root === undefined) return
|
||||||
|
try {
|
||||||
|
await $.fs.write(`${s.root}/${STATUS_PATH}`, statusText(s.stats, modeOf(s.opts), await $.clock.now()))
|
||||||
|
} catch {
|
||||||
|
/* the status file is a courtesy */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A read-only call through an already-connected tool: its first text block, or undefined when absent or errored. */
|
||||||
|
async function peek($: Dollar, suffix: string, args: Record<string, unknown>): Promise<string | undefined> {
|
||||||
|
const hit = findTool(await $.tool.list(), suffix)
|
||||||
|
if (!hit) return undefined
|
||||||
|
const res = await $.mcp.call(hit.server, hit.tool, args)
|
||||||
|
return res.isError ? undefined : (res.content.find(b => b.type === 'text')?.text ?? '').slice(0, TEXT_CAP)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Graph intelligence as a mod (ADR-445 pattern): a tighten-only guard on this plugin's own tools, `/graph-mod`, and a status file the console
|
||||||
|
* reads. No network, no process spawning: only tools already connected.
|
||||||
|
*/
|
||||||
|
export const register: Register = (on, options) => {
|
||||||
|
const s: Session = { opts: readOptions(options), stats: newStats() }
|
||||||
|
|
||||||
|
on('session.start', async ($, e, next) => {
|
||||||
|
const result = await next(e)
|
||||||
|
s.root = (await $.session.root()) as string | undefined
|
||||||
|
try {
|
||||||
|
await $.command.register({ name: 'graph-mod', description: 'Graph intelligence mod: status, scan <text>, tools' })
|
||||||
|
} catch {
|
||||||
|
/* a name taken by another plugin must not stop the mod */
|
||||||
|
}
|
||||||
|
await flush($, s)
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
if (s.opts.guard) {
|
||||||
|
on('tool.call', async ($, e, next) => {
|
||||||
|
const reason = verdict(e.tool, e, s.opts)
|
||||||
|
if (reason === undefined) return next(e)
|
||||||
|
s.stats.blocked++
|
||||||
|
s.stats.lastDenied = e.tool
|
||||||
|
await flush($, s)
|
||||||
|
return { deny: reason }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `/graph-mod` is answered locally and takes no model turn. */
|
||||||
|
on('command.run', { command: 'graph-mod' }, async ($, e) => {
|
||||||
|
s.stats.commands++
|
||||||
|
const text = await answer(typeof e.args === 'string' ? e.args : '', {
|
||||||
|
opts: s.opts,
|
||||||
|
stats: s.stats,
|
||||||
|
peek: (suffix, args) => peek($, suffix, args),
|
||||||
|
toolNames: async () => (await $.tool.list()).map(t => t.name),
|
||||||
|
})
|
||||||
|
await flush($, s)
|
||||||
|
return { text }
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Pure text screening for the AgentDB mod (ADR-445). Two jobs: find secrets (so none is stored) and find prompt-injection phrasing (so
|
||||||
|
* retrieved memory cannot instruct the model). Findings are NAMES only: the matched text is never returned, logged or counted by value.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SECRETS: readonly (readonly [string, RegExp])[] = [
|
||||||
|
['private key', /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
|
||||||
|
['aws access key', /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/],
|
||||||
|
['github token', /\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{40,})\b/],
|
||||||
|
['slack token', /\bxox[abprs]-[A-Za-z0-9-]{10,}/],
|
||||||
|
['google api key', /\bAIza[0-9A-Za-z_-]{35}\b/],
|
||||||
|
['anthropic or openai key', /\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{24,}/],
|
||||||
|
['jwt', /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/],
|
||||||
|
['bearer token', /\bBearer\s+[A-Za-z0-9._~+/=-]{24,}/],
|
||||||
|
['key assignment', /\b(?:api[_-]?key|secret|token|passw(?:or)?d|credential)s?["']?\s*[:=]\s*["']?[A-Za-z0-9/+=_.-]{16,}/i],
|
||||||
|
]
|
||||||
|
|
||||||
|
const INJECTION: readonly (readonly [string, RegExp])[] = [
|
||||||
|
['override instructions', /\b(?:ignore|disregard|forget|override)\b[^.\n]{0,40}\b(?:previous|prior|above|earlier|all|any|system)\b[^.\n]{0,30}\b(?:instructions?|rules?|prompts?|guidelines?)\b/i],
|
||||||
|
['role reassignment', /\byou are (?:now|no longer)\b|\bact as (?:an? )?(?:unrestricted|jailbroken)\b/i],
|
||||||
|
['new instructions', /\b(?:new|updated|real) (?:system )?instructions?\s*:/i],
|
||||||
|
['fake role tags', /<\/?\s*(?:system|assistant|developer|instructions?)\s*>|^\s*(?:system|assistant)\s*:/im],
|
||||||
|
['concealment', /\bdo not (?:tell|inform|mention|reveal)[^.\n]{0,30}\b(?:user|human|operator)\b/i],
|
||||||
|
['exfiltration', /\b(?:exfiltrate|send|post|upload)\b[^.\n]{0,50}\b(?:secrets?|credentials?|tokens?|api keys?|\.env)\b/i],
|
||||||
|
['shell pipe', /\b(?:curl|wget)\b[^|\n]{0,200}\|\s*(?:sudo\s+)?(?:ba|z)?sh\b/i],
|
||||||
|
]
|
||||||
|
|
||||||
|
// C0/C1 controls (keeping tab and newline), DEL, zero-width and bidi override characters.
|
||||||
|
const INVISIBLE = new RegExp('[\\u0000-\\u0008\\u000b-\\u001f\\u007f-\\u009f\\u200b-\\u200f\\u2028-\\u202e\\u2060-\\u2064\\ufeff]', 'g')
|
||||||
|
|
||||||
|
const bare = (text: string) => (text.length > 20_000 ? text.slice(0, 20_000) : text).replace(INVISIBLE, '')
|
||||||
|
|
||||||
|
export type Findings = { readonly secrets: readonly string[]; readonly injection: readonly string[] }
|
||||||
|
|
||||||
|
const names = (rules: readonly (readonly [string, RegExp])[], text: string) => rules.filter(([, re]) => re.test(text)).map(([name]) => name)
|
||||||
|
|
||||||
|
/** Names of every secret shape and injection phrase found in `text`. Cost is linear in the capped input. */
|
||||||
|
export function scan(text: string): Findings {
|
||||||
|
const bounded = bare(text)
|
||||||
|
return { secrets: names(SECRETS, bounded), injection: names(INJECTION, bounded) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hasSecret = (text: string) => names(SECRETS, bare(text)).length > 0
|
||||||
|
|
||||||
|
|
||||||
|
/** Makes stored text safe to show: no control or bidi characters, whitespace collapsed, at most `max` characters. */
|
||||||
|
export function tidy(text: string, max: number): string {
|
||||||
|
const flat = text.replace(INVISIBLE, '').replace(/\s+/g, ' ').trim()
|
||||||
|
return flat.length > max ? `${flat.slice(0, Math.max(0, max - 1))}…` : flat
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/** Counters the mod keeps for the session and writes to `.claude-flow/graph-mod/status.json` for the console. */
|
||||||
|
export type Stats = {
|
||||||
|
blocked: number
|
||||||
|
scans: number
|
||||||
|
commands: number
|
||||||
|
lastDenied?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const newStats = (): Stats => ({ blocked: 0, scans: 0, commands: 0 })
|
||||||
|
|
||||||
|
export const STATUS_PATH = '.claude-flow/graph-mod/status.json'
|
||||||
|
|
||||||
|
/** The file's text: mode flags plus the counters; `version` lets the console refuse a shape it does not know. */
|
||||||
|
export function statusText(stats: Stats, mode: Record<string, boolean>, nowMs: number): string {
|
||||||
|
return `${JSON.stringify({ version: 1, updatedMs: nowMs, ...mode, ...stats }, null, 2)}\n`
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import type { On } from 'claude-code'
|
||||||
|
import { describe, expect, test, tier } from 'claude-code/testing'
|
||||||
|
|
||||||
|
tier('user')
|
||||||
|
|
||||||
|
const ROOT = '/work'
|
||||||
|
const START = { surface: 'terminal', isInteractive: true, cwd: ROOT } as const
|
||||||
|
const slash = (args: string) => ({ command: 'graph-mod', args, origin: { kind: 'composer' }, presentation: { isFullscreen: false, columns: 100 } }) as const
|
||||||
|
const SECRET = `ghp_${'a1B2'.repeat(10)}`
|
||||||
|
const STATUS = `${ROOT}/.claude-flow/graph-mod/status.json`
|
||||||
|
|
||||||
|
type Calls = { server: string; tool: string; args: unknown }[]
|
||||||
|
|
||||||
|
/** The world beneath the mod: a project, a file map, the connected tools, and an MCP that answers every call with `reply`. */
|
||||||
|
function world(on: On, tools: readonly string[] = [], reply = '') {
|
||||||
|
const files = new Map<string, string>()
|
||||||
|
const calls: Calls = []
|
||||||
|
on('session.start', ($, e) => ({ cwd: e.cwd }))
|
||||||
|
on('session.root', () => ({ value: ROOT }))
|
||||||
|
on('command.register', ($, e) => ({ value: { command: e.name } }))
|
||||||
|
on('fs.write', ($, e) => (files.set(e.path, e.text), { value: undefined }))
|
||||||
|
on('tool.list', () => ({ value: tools.map(name => ({ name, description: '', mcp: name.startsWith('mcp__') })) }))
|
||||||
|
on('clock.now', () => ({ value: 1_700_000_000_000 }))
|
||||||
|
on('mcp.call', ($, e) => (calls.push({ server: e.server, tool: e.tool, args: e.args }), { value: { content: [{ type: 'text', text: reply }], isError: false } }))
|
||||||
|
return { files, calls, status: () => JSON.parse(files.get(STATUS) ?? '{}') as Record<string, unknown> }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs a tool call through the mod; a denial comes back as its reason text, an allowed call as `ALLOWED`. */
|
||||||
|
async function attempt($: { tool: { call: (i: never) => Promise<unknown> } }, input: Record<string, unknown>): Promise<string> {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(await $.tool.call(input as never))
|
||||||
|
} catch (e) {
|
||||||
|
return String(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CORE = 'mcp__plugin_ruflo-core_ruflo__'
|
||||||
|
|
||||||
|
describe('status file and defaults', () => {
|
||||||
|
test('session start registers /graph-mod and writes status.json (version 1, guard on)', async ($, on) => {
|
||||||
|
const w = world(on)
|
||||||
|
await $.session.start(START)
|
||||||
|
expect(w.status()).toMatchObject({ version: 1, guard: true, blocked: 0, commands: 0 })
|
||||||
|
expect(typeof w.status().updatedMs).toBe('number')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('status, an unknown verb and help answer locally', async ($, on) => {
|
||||||
|
world(on)
|
||||||
|
await $.session.start(START)
|
||||||
|
expect((await $.command.run(slash('status'))).text).toContain('guard on')
|
||||||
|
expect((await $.command.run(slash('what is this'))).text).toContain('/graph-mod status')
|
||||||
|
expect((await $.command.run(slash(''))).text).toContain('/graph-mod scan <text>')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('scan names the secret shape and never echoes the value', async ($, on) => {
|
||||||
|
world(on)
|
||||||
|
await $.session.start(START)
|
||||||
|
const hit = (await $.command.run(slash(`scan key ${SECRET}`))).text ?? ''
|
||||||
|
expect(hit).toContain('github token')
|
||||||
|
expect(hit).not.toContain('ghp_')
|
||||||
|
expect((await $.command.run(slash('scan plain words'))).text).toContain('No secret shape')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('guard', () => {
|
||||||
|
test('refuses a secret in a graph call (either spelling), passes a clean one, ignores other tools', async ($, on) => {
|
||||||
|
const w = world(on)
|
||||||
|
on('tool.call', () => ({ result: 'ranked' }))
|
||||||
|
await $.session.start(START)
|
||||||
|
const denied = await attempt($, { tool: `${CORE}sublinear_page-rank-entry`, graphId: 'g', nodeId: SECRET })
|
||||||
|
expect(denied).toContain('secret')
|
||||||
|
expect(denied).not.toContain('ghp_')
|
||||||
|
expect(await attempt($, { tool: 'sublinear/solve', graphId: SECRET })).toContain('secret')
|
||||||
|
expect(await attempt($, { tool: `${CORE}sublinear_page-rank-entry`, graphId: 'agents', nodeId: 'a-1', seedNodes: ['a-2'] })).toContain('ranked')
|
||||||
|
expect(await attempt($, { tool: `${CORE}memory_store`, value: SECRET })).toContain('ranked')
|
||||||
|
expect(w.status()).toMatchObject({ blocked: 2 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('guard: off lets it through', { options: { guard: 'off' } }, async ($, on) => {
|
||||||
|
world(on)
|
||||||
|
on('tool.call', () => ({ result: 'ranked' }))
|
||||||
|
await $.session.start(START)
|
||||||
|
expect(await attempt($, { tool: 'sublinear/solve', graphId: SECRET })).toContain('ranked')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('/graph-mod tools', () => {
|
||||||
|
test('lists the connected graph-intelligence tools', async ($, on) => {
|
||||||
|
world(on, [`${CORE}sublinear_solve`, `${CORE}memory_store`])
|
||||||
|
await $.session.start(START)
|
||||||
|
const text = (await $.command.run(slash('tools'))).text ?? ''
|
||||||
|
expect(text).toContain('sublinear_solve')
|
||||||
|
expect(text).not.toContain('memory_store')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('says plainly when none is connected', async ($, on) => {
|
||||||
|
world(on, [`${CORE}memory_store`])
|
||||||
|
await $.session.start(START)
|
||||||
|
expect((await $.command.run(slash('tools'))).text).toContain('No graph-intelligence tool is connected')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { ToolInfo } from 'claude-code'
|
||||||
|
|
||||||
|
/** `mcp__<server>__<tool>` into its two halves; tool names never hold a double underscore. */
|
||||||
|
export function splitName(name: string): { server: string; tool: string } | undefined {
|
||||||
|
if (!name.startsWith('mcp__')) return undefined
|
||||||
|
const at = name.lastIndexOf('__')
|
||||||
|
return at > 5 ? { server: name.slice(5, at), tool: name.slice(at + 2) } : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The bare tool name: `mcp__srv__memory_store` and `memory_store` both give `memory_store`. */
|
||||||
|
export const bare = (name: string) => splitName(name)?.tool ?? name
|
||||||
|
|
||||||
|
/** A connected MCP tool whose bare name is `suffix`, split into what `$.mcp.call` takes. */
|
||||||
|
export function findTool(tools: readonly ToolInfo[], suffix: string): { server: string; tool: string } | undefined {
|
||||||
|
const hit = tools.find(t => t.mcp && bare(t.name) === suffix)
|
||||||
|
return hit && splitName(hit.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every string in a tool's input, to a bounded depth and size: what the guard reads. */
|
||||||
|
export function textsOf(input: unknown, budget = { left: 20_000 }, depth = 0): string[] {
|
||||||
|
if (budget.left <= 0 || depth > 6) return []
|
||||||
|
if (typeof input === 'string') {
|
||||||
|
budget.left -= input.length
|
||||||
|
return [input]
|
||||||
|
}
|
||||||
|
if (Array.isArray(input)) return input.slice(0, 200).flatMap(v => textsOf(v, budget, depth + 1))
|
||||||
|
if (typeof input === 'object' && input !== null) return Object.values(input).slice(0, 200).flatMap(v => textsOf(v, budget, depth + 1))
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tool input's `namespace` field when it is a string, else ''. */
|
||||||
|
export const namespaceOf = (input: unknown): string => {
|
||||||
|
const ns = (input as { namespace?: unknown } | null)?.namespace
|
||||||
|
return typeof ns === 'string' ? ns : ''
|
||||||
|
}
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Smoke for the ruflo-graph-intelligence mod (the engine itself is tested by vitest: npm test).
|
||||||
|
set -u
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
step() { printf "→ %s ... " "$1"; }
|
||||||
|
ok() { printf "PASS\n"; PASS=$((PASS+1)); }
|
||||||
|
bad() { printf "FAIL: %s\n" "$1"; FAIL=$((FAIL+1)); }
|
||||||
|
|
||||||
|
# M1. The mod (ADR-445 pattern): hooks module registered, files within the 500-line rule
|
||||||
|
step "M1. mod: hooks.json names register.ts, every hook file is present and under 500 lines"
|
||||||
|
mod_ok=1
|
||||||
|
grep -q '"./register.ts"' "$ROOT/hooks/hooks.json" || mod_ok=0
|
||||||
|
for f in options screen tools guard command status register; do
|
||||||
|
[[ -f "$ROOT/hooks/$f.ts" ]] || mod_ok=0
|
||||||
|
[[ $(wc -l < "$ROOT/hooks/$f.ts" 2>/dev/null || echo 9999) -le 500 ]] || mod_ok=0
|
||||||
|
done
|
||||||
|
[[ $mod_ok -eq 1 ]] && ok || bad "mod hooks incomplete or too long"
|
||||||
|
|
||||||
|
step "M2. mod: userConfig defaults are the safe ones (guard all on)"
|
||||||
|
node -e '
|
||||||
|
const c = require(process.argv[1]).userConfig || {}
|
||||||
|
const need = process.argv.slice(2)
|
||||||
|
process.exit(need.every(k => c[k] && c[k].default === "on") ? 0 : 1)
|
||||||
|
' "$ROOT/.claude-plugin/plugin.json" guard && ok || bad "userConfig defaults wrong"
|
||||||
|
|
||||||
|
step "M3. mod: hooks never touch the network or spawn a process"
|
||||||
|
if grep -nE '\$\.(http|process)\.|child_process|fetch\(' "$ROOT"/hooks/*.ts >/dev/null; then bad "network or process call in hooks"; else ok; fi
|
||||||
|
|
||||||
|
# M4. The mod's tests run from a staged copy: `claude plugin test` runs every *.test.ts under a dir, and this package's own tests/ are vitest (library) tests
|
||||||
|
step "M4. mod: claude plugin test passes on a staged copy (skipped when claude is not on PATH)"
|
||||||
|
if command -v claude >/dev/null 2>&1; then
|
||||||
|
T="$(mktemp -d)"; mkdir "$T/tests"
|
||||||
|
cp -r "$ROOT/.claude-plugin" "$ROOT/hooks" "$T/" && mv "$T/hooks/tests/register.test.ts" "$T/tests/" && rmdir "$T/hooks/tests"
|
||||||
|
claude plugin test "$T" >/dev/null 2>&1 && ok || bad "mod tests failed"
|
||||||
|
rm -rf "$T"
|
||||||
|
else
|
||||||
|
ok
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf "\n%s passed, %s failed\n" "$PASS" "$FAIL"
|
||||||
|
[[ $FAIL -eq 0 ]] || exit 1
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ruflo-intelligence",
|
"name": "ruflo-intelligence",
|
||||||
"description": "User-facing surface for Ruflo's self-learning system: 6 neural_* + 10 hooks_intelligence_* + 6 routing/meta hooks + 3 hooks_model-* + 4 SONA/MicroLoRA tools (29 total). Implements the 4-step pipeline (RETRIEVE → JUDGE → DISTILL → CONSOLIDATE) and IPFS-based cross-project pattern transfer.",
|
"description": "User-facing surface for Ruflo's self-learning system: 6 neural_* + 10 hooks_intelligence_* + 6 routing/meta hooks + 3 hooks_model-* + 4 SONA/MicroLoRA tools (29 total). Implements the 4-step pipeline (RETRIEVE → JUDGE → DISTILL → CONSOLIDATE) and IPFS-based cross-project pattern transfer.",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "ruvnet",
|
"name": "ruvnet",
|
||||||
"url": "https://github.com/ruvnet"
|
"url": "https://github.com/ruvnet"
|
||||||
@@ -21,6 +21,33 @@
|
|||||||
"moe",
|
"moe",
|
||||||
"pattern-transfer",
|
"pattern-transfer",
|
||||||
"model-routing",
|
"model-routing",
|
||||||
"mcp"
|
"mcp",
|
||||||
]
|
"mod",
|
||||||
|
"function-hooks",
|
||||||
|
"guard"
|
||||||
|
],
|
||||||
|
"userConfig": {
|
||||||
|
"guard": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Write guard",
|
||||||
|
"description": "Refuse writes and commands on this plugin's own tools that hold a key, token or password.",
|
||||||
|
"required": false,
|
||||||
|
"default": "on",
|
||||||
|
"options": [
|
||||||
|
"on",
|
||||||
|
"off"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"confirmReset": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Confirm intelligence reset",
|
||||||
|
"description": "Refuse hooks_intelligence-reset unless the call carries confirm: true.",
|
||||||
|
"required": false,
|
||||||
|
"default": "on",
|
||||||
|
"options": [
|
||||||
|
"on",
|
||||||
|
"off"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -127,6 +127,23 @@ Configure once via `mcp tool call hooks_intelligence -- '{"mode": "moe", "enable
|
|||||||
- `intelligence-route` — Route tasks using learned patterns; produces a `hooks_explain` rationale
|
- `intelligence-route` — Route tasks using learned patterns; produces a `hooks_explain` rationale
|
||||||
- `intelligence-transfer` — Publish/fetch patterns via IPFS (`hooks_transfer`)
|
- `intelligence-transfer` — Publish/fetch patterns via IPFS (`hooks_transfer`)
|
||||||
|
|
||||||
|
## As a mod (0.4.0)
|
||||||
|
|
||||||
|
A function-hook mod ships beside the skills (ADR-445 pattern). Needs a Claude Code with mods (2.1.287+); older builds ignore it. No network, no process spawning: it only tightens calls to this plugin's own tools and reads through tools already connected.
|
||||||
|
|
||||||
|
| Piece | Default | What it does |
|
||||||
|
|---|---|---|
|
||||||
|
| **Write guard** | on | Refuses a secret in a learned pattern, trajectory step/end, `neural_train` or `hooks_transfer` call, or a `memory_store` into a pattern namespace. A transfer also refuses an email address (IPFS is public). |
|
||||||
|
| **Reset confirm** | on | Refuses `hooks_intelligence-reset` unless the call carries `confirm: true`. |
|
||||||
|
| **`/intelligence-mod`** | — | `status`, `scan <text>`, `stats` (calls `hooks_intelligence_stats` through the connected tool); answered locally, no model call. |
|
||||||
|
| **Status file** | — | `.claude-flow/intelligence-mod/status.json` (`version`, `updatedMs`, mode flags and counters); written at session start and when a counter changes. |
|
||||||
|
|
||||||
|
Options (`userConfig`): `guard` on\|off, `confirmReset` on\|off. Refusals never echo the value they matched.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude plugin test plugins/ruflo-intelligence # 11 tests
|
||||||
|
```
|
||||||
|
|
||||||
## Architecture Decisions
|
## Architecture Decisions
|
||||||
|
|
||||||
- [`ADR-0001` — Optimize ruflo-intelligence (surface completeness, 4-step pipeline, IPFS transfer, namespace coordination)](./docs/adrs/0001-intelligence-surface-completeness.md)
|
- [`ADR-0001` — Optimize ruflo-intelligence (surface completeness, 4-step pipeline, IPFS transfer, namespace coordination)](./docs/adrs/0001-intelligence-surface-completeness.md)
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { scan, tidy } from './screen'
|
||||||
|
import type { ModOptions } from './options'
|
||||||
|
import type { Stats } from './status'
|
||||||
|
|
||||||
|
export type CommandDeps = {
|
||||||
|
readonly opts: ModOptions
|
||||||
|
readonly stats: Stats
|
||||||
|
readonly peek: (suffix: string, args: Record<string, unknown>) => Promise<string | undefined>
|
||||||
|
readonly toolNames: () => Promise<readonly string[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
const HELP = ['/intelligence-mod status', '/intelligence-mod scan <text>', '/intelligence-mod stats'].join('\n')
|
||||||
|
|
||||||
|
/** `/intelligence-mod` is answered locally and takes no model turn (the plugin's `/intelligence` and `/neural` are prompt commands). */
|
||||||
|
export async function answer(args: string, deps: CommandDeps): Promise<string> {
|
||||||
|
const [verb = '', ...rest] = args.trim().split(/\s+/)
|
||||||
|
const arg = rest.join(' ')
|
||||||
|
const { opts, stats } = deps
|
||||||
|
|
||||||
|
if (verb === '' || verb === 'help') return HELP
|
||||||
|
|
||||||
|
if (verb === 'status') {
|
||||||
|
return `guard ${opts.guard ? 'on' : 'off'} · reset needs confirm ${opts.confirmReset ? 'on' : 'off'}\ncalls blocked ${stats.blocked} · scans ${stats.scans} · commands ${stats.commands}${stats.lastDenied ? ` · last denied ${stats.lastDenied}` : ''}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verb === 'scan') {
|
||||||
|
if (arg === '') return 'usage: /intelligence-mod scan <text>'
|
||||||
|
stats.scans++
|
||||||
|
const found = scan(arg).secrets
|
||||||
|
return found.length ? `The guard would refuse a learning write of that (secrets: ${found.join(', ')}).` : 'No secret shape found: the guard would let that be learned.'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verb === 'stats') {
|
||||||
|
const text = await deps.peek('hooks_intelligence_stats', {})
|
||||||
|
if (text === undefined) return 'No intelligence tool is connected (or it errored). Connect the ruflo MCP server and try again.'
|
||||||
|
return text.trim() === '' ? 'No intelligence stats reported.' : tidy(text, 1200)
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Unknown: ${verb}\n${HELP}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { hasSecret } from './screen'
|
||||||
|
import { bare, namespaceOf, textsOf } from './tools'
|
||||||
|
import type { ModOptions } from './options'
|
||||||
|
|
||||||
|
/** Learning writes: a secret in a stored pattern, trajectory or training row would be learned, recalled and, via transfer, published. */
|
||||||
|
const LEARNERS = new Set(['hooks_intelligence_pattern-store', 'hooks_intelligence_trajectory-step', 'hooks_intelligence_trajectory-end', 'neural_train', 'hooks_transfer', 'memory_store'])
|
||||||
|
const PATTERN_NS = /^(?:patterns?|intelligence|sona|neural|trajector)/i
|
||||||
|
|
||||||
|
// `hooks_transfer` publishes to IPFS, which is public; an email address is personal data the plugin's own skill says to strip first.
|
||||||
|
const EMAIL = /[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9-]{1,63}(?:\.[A-Za-z0-9-]{1,63})*\.[A-Za-z]{2,}/
|
||||||
|
|
||||||
|
const isReset = (name: string) => name.replace(/_/g, '-') === 'hooks-intelligence-reset'
|
||||||
|
|
||||||
|
/** The reason an intelligence call is refused, or undefined when it may go. Never names or echoes the value. */
|
||||||
|
export function verdict(tool: string, input: unknown, opts: ModOptions): string | undefined {
|
||||||
|
const name = bare(tool)
|
||||||
|
|
||||||
|
if (isReset(name)) {
|
||||||
|
if (!opts.confirmReset || (input as { confirm?: unknown } | null)?.confirm === true) return undefined
|
||||||
|
return 'ruflo-intelligence: resetting the intelligence store erases every learned pattern. Ask the user first; once they agree, call again with confirm: true.'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!LEARNERS.has(name)) return undefined
|
||||||
|
if (name === 'memory_store' && !PATTERN_NS.test(namespaceOf(input))) return undefined
|
||||||
|
const texts = textsOf(input)
|
||||||
|
if (texts.some(hasSecret)) {
|
||||||
|
return 'ruflo-intelligence: this learning write holds what looks like a secret (a key, token or password). Learned patterns are recalled and can be published, so leave it out.'
|
||||||
|
}
|
||||||
|
if (name === 'hooks_transfer' && texts.some(t => EMAIL.test(t))) {
|
||||||
|
return 'ruflo-intelligence: this transfer holds an email address, and IPFS is public. Strip personal data first (aidefence_has_pii), then publish.'
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"description": "Intelligence as a mod: a tighten-only guard on the plugin's own tools (default on), /intelligence-mod (local, no model call), and a status file the console reads.",
|
||||||
|
"modules": ["./register.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { PluginOptions } from 'claude-code'
|
||||||
|
|
||||||
|
/** The plugin's `userConfig`, validated: a bad value is the default (both on). */
|
||||||
|
export type ModOptions = { readonly guard: boolean; readonly confirmReset: boolean }
|
||||||
|
|
||||||
|
const flag = (value: unknown, fallback: boolean) =>
|
||||||
|
value === true || value === 'true' || value === 'on' ? true : value === false || value === 'false' || value === 'off' ? false : fallback
|
||||||
|
|
||||||
|
export function readOptions(options: PluginOptions | undefined): ModOptions {
|
||||||
|
const o = options ?? {}
|
||||||
|
return { guard: flag(o.guard, true), confirmReset: flag(o.confirmReset, true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const modeOf = (o: ModOptions): Record<string, boolean> => ({ guard: o.guard, confirmReset: o.confirmReset })
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import type { Hook, Register } from 'claude-code'
|
||||||
|
|
||||||
|
import { answer } from './command'
|
||||||
|
import { verdict } from './guard'
|
||||||
|
import { modeOf, readOptions, type ModOptions } from './options'
|
||||||
|
import { newStats, STATUS_PATH, statusText, type Stats } from './status'
|
||||||
|
import { findTool } from './tools'
|
||||||
|
|
||||||
|
const TEXT_CAP = 20_000
|
||||||
|
|
||||||
|
type Dollar = Parameters<Hook<'session.start'>>[0]
|
||||||
|
|
||||||
|
/** Everything one session of the mod keeps: its settings, its counters and the project root. */
|
||||||
|
type Session = { readonly opts: ModOptions; readonly stats: Stats; root?: string }
|
||||||
|
|
||||||
|
async function flush($: Dollar, s: Session): Promise<void> {
|
||||||
|
if (s.root === undefined) return
|
||||||
|
try {
|
||||||
|
await $.fs.write(`${s.root}/${STATUS_PATH}`, statusText(s.stats, modeOf(s.opts), await $.clock.now()))
|
||||||
|
} catch {
|
||||||
|
/* the status file is a courtesy */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A read-only call through an already-connected tool: its first text block, or undefined when absent or errored. */
|
||||||
|
async function peek($: Dollar, suffix: string, args: Record<string, unknown>): Promise<string | undefined> {
|
||||||
|
const hit = findTool(await $.tool.list(), suffix)
|
||||||
|
if (!hit) return undefined
|
||||||
|
const res = await $.mcp.call(hit.server, hit.tool, args)
|
||||||
|
return res.isError ? undefined : (res.content.find(b => b.type === 'text')?.text ?? '').slice(0, TEXT_CAP)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intelligence as a mod (ADR-445 pattern): a tighten-only guard on this plugin's own tools, `/intelligence-mod`, and a status file the console
|
||||||
|
* reads. No network, no process spawning: only tools already connected.
|
||||||
|
*/
|
||||||
|
export const register: Register = (on, options) => {
|
||||||
|
const s: Session = { opts: readOptions(options), stats: newStats() }
|
||||||
|
|
||||||
|
on('session.start', async ($, e, next) => {
|
||||||
|
const result = await next(e)
|
||||||
|
s.root = (await $.session.root()) as string | undefined
|
||||||
|
try {
|
||||||
|
await $.command.register({ name: 'intelligence-mod', description: 'Intelligence mod: status, scan <text>, stats' })
|
||||||
|
} catch {
|
||||||
|
/* a name taken by another plugin must not stop the mod */
|
||||||
|
}
|
||||||
|
await flush($, s)
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
if (s.opts.guard) {
|
||||||
|
on('tool.call', async ($, e, next) => {
|
||||||
|
const reason = verdict(e.tool, e, s.opts)
|
||||||
|
if (reason === undefined) return next(e)
|
||||||
|
s.stats.blocked++
|
||||||
|
s.stats.lastDenied = e.tool
|
||||||
|
await flush($, s)
|
||||||
|
return { deny: reason }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `/intelligence-mod` is answered locally and takes no model turn. */
|
||||||
|
on('command.run', { command: 'intelligence-mod' }, async ($, e) => {
|
||||||
|
s.stats.commands++
|
||||||
|
const text = await answer(typeof e.args === 'string' ? e.args : '', {
|
||||||
|
opts: s.opts,
|
||||||
|
stats: s.stats,
|
||||||
|
peek: (suffix, args) => peek($, suffix, args),
|
||||||
|
toolNames: async () => (await $.tool.list()).map(t => t.name),
|
||||||
|
})
|
||||||
|
await flush($, s)
|
||||||
|
return { text }
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Pure text screening for the AgentDB mod (ADR-445). Two jobs: find secrets (so none is stored) and find prompt-injection phrasing (so
|
||||||
|
* retrieved memory cannot instruct the model). Findings are NAMES only: the matched text is never returned, logged or counted by value.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SECRETS: readonly (readonly [string, RegExp])[] = [
|
||||||
|
['private key', /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
|
||||||
|
['aws access key', /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/],
|
||||||
|
['github token', /\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{40,})\b/],
|
||||||
|
['slack token', /\bxox[abprs]-[A-Za-z0-9-]{10,}/],
|
||||||
|
['google api key', /\bAIza[0-9A-Za-z_-]{35}\b/],
|
||||||
|
['anthropic or openai key', /\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{24,}/],
|
||||||
|
['jwt', /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/],
|
||||||
|
['bearer token', /\bBearer\s+[A-Za-z0-9._~+/=-]{24,}/],
|
||||||
|
['key assignment', /\b(?:api[_-]?key|secret|token|passw(?:or)?d|credential)s?["']?\s*[:=]\s*["']?[A-Za-z0-9/+=_.-]{16,}/i],
|
||||||
|
]
|
||||||
|
|
||||||
|
const INJECTION: readonly (readonly [string, RegExp])[] = [
|
||||||
|
['override instructions', /\b(?:ignore|disregard|forget|override)\b[^.\n]{0,40}\b(?:previous|prior|above|earlier|all|any|system)\b[^.\n]{0,30}\b(?:instructions?|rules?|prompts?|guidelines?)\b/i],
|
||||||
|
['role reassignment', /\byou are (?:now|no longer)\b|\bact as (?:an? )?(?:unrestricted|jailbroken)\b/i],
|
||||||
|
['new instructions', /\b(?:new|updated|real) (?:system )?instructions?\s*:/i],
|
||||||
|
['fake role tags', /<\/?\s*(?:system|assistant|developer|instructions?)\s*>|^\s*(?:system|assistant)\s*:/im],
|
||||||
|
['concealment', /\bdo not (?:tell|inform|mention|reveal)[^.\n]{0,30}\b(?:user|human|operator)\b/i],
|
||||||
|
['exfiltration', /\b(?:exfiltrate|send|post|upload)\b[^.\n]{0,50}\b(?:secrets?|credentials?|tokens?|api keys?|\.env)\b/i],
|
||||||
|
['shell pipe', /\b(?:curl|wget)\b[^|\n]{0,200}\|\s*(?:sudo\s+)?(?:ba|z)?sh\b/i],
|
||||||
|
]
|
||||||
|
|
||||||
|
// C0/C1 controls (keeping tab and newline), DEL, zero-width and bidi override characters.
|
||||||
|
const INVISIBLE = new RegExp('[\\u0000-\\u0008\\u000b-\\u001f\\u007f-\\u009f\\u200b-\\u200f\\u2028-\\u202e\\u2060-\\u2064\\ufeff]', 'g')
|
||||||
|
|
||||||
|
const bare = (text: string) => (text.length > 20_000 ? text.slice(0, 20_000) : text).replace(INVISIBLE, '')
|
||||||
|
|
||||||
|
export type Findings = { readonly secrets: readonly string[]; readonly injection: readonly string[] }
|
||||||
|
|
||||||
|
const names = (rules: readonly (readonly [string, RegExp])[], text: string) => rules.filter(([, re]) => re.test(text)).map(([name]) => name)
|
||||||
|
|
||||||
|
/** Names of every secret shape and injection phrase found in `text`. Cost is linear in the capped input. */
|
||||||
|
export function scan(text: string): Findings {
|
||||||
|
const bounded = bare(text)
|
||||||
|
return { secrets: names(SECRETS, bounded), injection: names(INJECTION, bounded) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hasSecret = (text: string) => names(SECRETS, bare(text)).length > 0
|
||||||
|
|
||||||
|
|
||||||
|
/** Makes stored text safe to show: no control or bidi characters, whitespace collapsed, at most `max` characters. */
|
||||||
|
export function tidy(text: string, max: number): string {
|
||||||
|
const flat = text.replace(INVISIBLE, '').replace(/\s+/g, ' ').trim()
|
||||||
|
return flat.length > max ? `${flat.slice(0, Math.max(0, max - 1))}…` : flat
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/** Counters the mod keeps for the session and writes to `.claude-flow/intelligence-mod/status.json` for the console. */
|
||||||
|
export type Stats = {
|
||||||
|
blocked: number
|
||||||
|
scans: number
|
||||||
|
commands: number
|
||||||
|
lastDenied?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const newStats = (): Stats => ({ blocked: 0, scans: 0, commands: 0 })
|
||||||
|
|
||||||
|
export const STATUS_PATH = '.claude-flow/intelligence-mod/status.json'
|
||||||
|
|
||||||
|
/** The file's text: mode flags plus the counters; `version` lets the console refuse a shape it does not know. */
|
||||||
|
export function statusText(stats: Stats, mode: Record<string, boolean>, nowMs: number): string {
|
||||||
|
return `${JSON.stringify({ version: 1, updatedMs: nowMs, ...mode, ...stats }, null, 2)}\n`
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { ToolInfo } from 'claude-code'
|
||||||
|
|
||||||
|
/** `mcp__<server>__<tool>` into its two halves; tool names never hold a double underscore. */
|
||||||
|
export function splitName(name: string): { server: string; tool: string } | undefined {
|
||||||
|
if (!name.startsWith('mcp__')) return undefined
|
||||||
|
const at = name.lastIndexOf('__')
|
||||||
|
return at > 5 ? { server: name.slice(5, at), tool: name.slice(at + 2) } : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The bare tool name: `mcp__srv__memory_store` and `memory_store` both give `memory_store`. */
|
||||||
|
export const bare = (name: string) => splitName(name)?.tool ?? name
|
||||||
|
|
||||||
|
/** A connected MCP tool whose bare name is `suffix`, split into what `$.mcp.call` takes. */
|
||||||
|
export function findTool(tools: readonly ToolInfo[], suffix: string): { server: string; tool: string } | undefined {
|
||||||
|
const hit = tools.find(t => t.mcp && bare(t.name) === suffix)
|
||||||
|
return hit && splitName(hit.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every string in a tool's input, to a bounded depth and size: what the guard reads. */
|
||||||
|
export function textsOf(input: unknown, budget = { left: 20_000 }, depth = 0): string[] {
|
||||||
|
if (budget.left <= 0 || depth > 6) return []
|
||||||
|
if (typeof input === 'string') {
|
||||||
|
budget.left -= input.length
|
||||||
|
return [input]
|
||||||
|
}
|
||||||
|
if (Array.isArray(input)) return input.slice(0, 200).flatMap(v => textsOf(v, budget, depth + 1))
|
||||||
|
if (typeof input === 'object' && input !== null) return Object.values(input).slice(0, 200).flatMap(v => textsOf(v, budget, depth + 1))
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tool input's `namespace` field when it is a string, else ''. */
|
||||||
|
export const namespaceOf = (input: unknown): string => {
|
||||||
|
const ns = (input as { namespace?: unknown } | null)?.namespace
|
||||||
|
return typeof ns === 'string' ? ns : ''
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Structural smoke test for ruflo-intelligence v0.3.1.
|
# Structural smoke test for ruflo-intelligence v0.4.0.
|
||||||
# Per ADR-0001 §6 Verification: 13 contract checks + 3 doc invariants.
|
# Per ADR-0001 §6 Verification: 13 contract checks + 3 doc invariants.
|
||||||
# Offline-safe; no live MCP calls.
|
# Offline-safe; no live MCP calls.
|
||||||
set -u
|
set -u
|
||||||
@@ -27,10 +27,10 @@ grep -qE "IPFS.+pattern|pattern.+IPFS|Pinata" "$ROOT/README.md" \
|
|||||||
&& ok || bad "README missing IPFS / Pinata transfer docs"
|
&& ok || bad "README missing IPFS / Pinata transfer docs"
|
||||||
|
|
||||||
# 1. plugin.json bump + new keywords
|
# 1. plugin.json bump + new keywords
|
||||||
step "1. plugin.json declares 0.3.1 with new keywords"
|
step "1. plugin.json declares 0.4.0 with new keywords"
|
||||||
v=$(grep -E '"version"[[:space:]]*:' "$ROOT/.claude-plugin/plugin.json" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)
|
v=$(grep -E '"version"[[:space:]]*:' "$ROOT/.claude-plugin/plugin.json" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)
|
||||||
if [[ "$v" != "0.3.1" ]]; then
|
if [[ "$v" != "0.4.0" ]]; then
|
||||||
bad "expected 0.3.1, got '$v'"
|
bad "expected 0.4.0, got '$v'"
|
||||||
else
|
else
|
||||||
miss=""
|
miss=""
|
||||||
for k in microlora ewc attention moe pattern-transfer model-routing; do
|
for k in microlora ewc attention moe pattern-transfer model-routing; do
|
||||||
@@ -148,5 +148,26 @@ else
|
|||||||
bad "Compatibility pin to v3.6 missing"
|
bad "Compatibility pin to v3.6 missing"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# M1. The mod (ADR-445 pattern): hooks module registered, files within the 500-line rule
|
||||||
|
step "M1. mod: hooks.json names register.ts, every hook file is present and under 500 lines"
|
||||||
|
mod_ok=1
|
||||||
|
grep -q '"./register.ts"' "$ROOT/hooks/hooks.json" || mod_ok=0
|
||||||
|
for f in options screen tools guard command status register; do
|
||||||
|
[[ -f "$ROOT/hooks/$f.ts" ]] || mod_ok=0
|
||||||
|
[[ $(wc -l < "$ROOT/hooks/$f.ts" 2>/dev/null || echo 9999) -le 500 ]] || mod_ok=0
|
||||||
|
done
|
||||||
|
[[ $mod_ok -eq 1 ]] && ok || bad "mod hooks incomplete or too long"
|
||||||
|
|
||||||
|
step "M2. mod: userConfig defaults are the safe ones (guard confirmReset all on)"
|
||||||
|
node -e '
|
||||||
|
const c = require(process.argv[1]).userConfig || {}
|
||||||
|
const need = process.argv.slice(2)
|
||||||
|
process.exit(need.every(k => c[k] && c[k].default === "on") ? 0 : 1)
|
||||||
|
' "$ROOT/.claude-plugin/plugin.json" guard confirmReset && ok || bad "userConfig defaults wrong"
|
||||||
|
|
||||||
|
step "M3. mod: hooks never touch the network or spawn a process"
|
||||||
|
if grep -nE '\$\.(http|process)\.|child_process|fetch\(' "$ROOT"/hooks/*.ts >/dev/null; then bad "network or process call in hooks"; else ok; fi
|
||||||
|
|
||||||
printf "\n%s passed, %s failed\n" "$PASS" "$FAIL"
|
printf "\n%s passed, %s failed\n" "$PASS" "$FAIL"
|
||||||
[[ $FAIL -eq 0 ]] || exit 1
|
[[ $FAIL -eq 0 ]] || exit 1
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import type { On } from 'claude-code'
|
||||||
|
import { describe, expect, test, tier } from 'claude-code/testing'
|
||||||
|
|
||||||
|
tier('user')
|
||||||
|
|
||||||
|
const ROOT = '/work'
|
||||||
|
const START = { surface: 'terminal', isInteractive: true, cwd: ROOT } as const
|
||||||
|
const slash = (args: string) => ({ command: 'intelligence-mod', args, origin: { kind: 'composer' }, presentation: { isFullscreen: false, columns: 100 } }) as const
|
||||||
|
const SECRET = `ghp_${'a1B2'.repeat(10)}`
|
||||||
|
const STATUS = `${ROOT}/.claude-flow/intelligence-mod/status.json`
|
||||||
|
|
||||||
|
type Calls = { server: string; tool: string; args: unknown }[]
|
||||||
|
|
||||||
|
/** The world beneath the mod: a project, a file map, the connected tools, and an MCP that answers every call with `reply`. */
|
||||||
|
function world(on: On, tools: readonly string[] = [], reply = '') {
|
||||||
|
const files = new Map<string, string>()
|
||||||
|
const calls: Calls = []
|
||||||
|
on('session.start', ($, e) => ({ cwd: e.cwd }))
|
||||||
|
on('session.root', () => ({ value: ROOT }))
|
||||||
|
on('command.register', ($, e) => ({ value: { command: e.name } }))
|
||||||
|
on('fs.write', ($, e) => (files.set(e.path, e.text), { value: undefined }))
|
||||||
|
on('tool.list', () => ({ value: tools.map(name => ({ name, description: '', mcp: name.startsWith('mcp__') })) }))
|
||||||
|
on('clock.now', () => ({ value: 1_700_000_000_000 }))
|
||||||
|
on('mcp.call', ($, e) => (calls.push({ server: e.server, tool: e.tool, args: e.args }), { value: { content: [{ type: 'text', text: reply }], isError: false } }))
|
||||||
|
return { files, calls, status: () => JSON.parse(files.get(STATUS) ?? '{}') as Record<string, unknown> }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs a tool call through the mod; a denial comes back as its reason text, an allowed call as `ALLOWED`. */
|
||||||
|
async function attempt($: { tool: { call: (i: never) => Promise<unknown> } }, input: Record<string, unknown>): Promise<string> {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(await $.tool.call(input as never))
|
||||||
|
} catch (e) {
|
||||||
|
return String(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CORE = 'mcp__plugin_ruflo-core_ruflo__'
|
||||||
|
|
||||||
|
describe('status file and defaults', () => {
|
||||||
|
test('session start registers /intelligence-mod and writes status.json (version 1, guard on)', async ($, on) => {
|
||||||
|
const w = world(on)
|
||||||
|
await $.session.start(START)
|
||||||
|
expect(w.status()).toMatchObject({ version: 1, guard: true, blocked: 0, commands: 0 })
|
||||||
|
expect(typeof w.status().updatedMs).toBe('number')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('status, an unknown verb and help answer locally', async ($, on) => {
|
||||||
|
world(on)
|
||||||
|
await $.session.start(START)
|
||||||
|
expect((await $.command.run(slash('status'))).text).toContain('guard on')
|
||||||
|
expect((await $.command.run(slash('what is this'))).text).toContain('/intelligence-mod status')
|
||||||
|
expect((await $.command.run(slash(''))).text).toContain('/intelligence-mod scan <text>')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('scan names the secret shape and never echoes the value', async ($, on) => {
|
||||||
|
world(on)
|
||||||
|
await $.session.start(START)
|
||||||
|
const hit = (await $.command.run(slash(`scan key ${SECRET}`))).text ?? ''
|
||||||
|
expect(hit).toContain('github token')
|
||||||
|
expect(hit).not.toContain('ghp_')
|
||||||
|
expect((await $.command.run(slash('scan plain words'))).text).toContain('No secret shape')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('guard', () => {
|
||||||
|
test('refuses a secret in a learned pattern, passes a clean one', async ($, on) => {
|
||||||
|
const w = world(on)
|
||||||
|
on('tool.call', () => ({ result: 'learned' }))
|
||||||
|
await $.session.start(START)
|
||||||
|
const denied = await attempt($, { tool: `${CORE}hooks_intelligence_pattern-store`, pattern: `auth uses ${SECRET}` })
|
||||||
|
expect(denied).toContain('secret')
|
||||||
|
expect(denied).not.toContain('ghp_')
|
||||||
|
expect(await attempt($, { tool: `${CORE}hooks_intelligence_pattern-store`, pattern: 'retry with backoff' })).toContain('learned')
|
||||||
|
expect(w.status()).toMatchObject({ blocked: 1 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('memory_store is screened only in pattern namespaces', async ($, on) => {
|
||||||
|
world(on)
|
||||||
|
on('tool.call', () => ({ result: 'learned' }))
|
||||||
|
await $.session.start(START)
|
||||||
|
expect(await attempt($, { tool: `${CORE}memory_store`, value: SECRET, namespace: 'patterns' })).toContain('secret')
|
||||||
|
expect(await attempt($, { tool: `${CORE}memory_store`, value: SECRET, namespace: 'scratch' })).toContain('learned')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a transfer refuses an email address, a plain pattern publishes', async ($, on) => {
|
||||||
|
world(on)
|
||||||
|
on('tool.call', () => ({ result: 'published' }))
|
||||||
|
await $.session.start(START)
|
||||||
|
const denied = await attempt($, { tool: `${CORE}hooks_transfer`, action: 'store', patterns: 'owner is jane.doe@example.com' })
|
||||||
|
expect(denied).toContain('IPFS is public')
|
||||||
|
expect(denied).not.toContain('jane.doe')
|
||||||
|
expect(await attempt($, { tool: `${CORE}hooks_transfer`, action: 'store', patterns: 'retry with backoff' })).toContain('published')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reset needs confirm: true', async ($, on) => {
|
||||||
|
world(on)
|
||||||
|
on('tool.call', () => ({ result: 'reset done' }))
|
||||||
|
await $.session.start(START)
|
||||||
|
expect(await attempt($, { tool: `${CORE}hooks_intelligence-reset` })).toContain('confirm: true')
|
||||||
|
expect(await attempt($, { tool: `${CORE}hooks_intelligence-reset`, confirm: false })).toContain('confirm: true')
|
||||||
|
expect(await attempt($, { tool: `${CORE}hooks_intelligence-reset`, confirm: true })).toContain('reset done')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('confirmReset: off lets a bare reset through', { options: { confirmReset: 'off' } }, async ($, on) => {
|
||||||
|
world(on)
|
||||||
|
on('tool.call', () => ({ result: 'reset done' }))
|
||||||
|
await $.session.start(START)
|
||||||
|
expect(await attempt($, { tool: `${CORE}hooks_intelligence-reset` })).toContain('reset done')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('guard: off lets a secret through', { options: { guard: 'off' } }, async ($, on) => {
|
||||||
|
world(on)
|
||||||
|
on('tool.call', () => ({ result: 'learned' }))
|
||||||
|
await $.session.start(START)
|
||||||
|
expect(await attempt($, { tool: `${CORE}neural_train`, data: SECRET })).toContain('learned')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('/intelligence-mod stats', () => {
|
||||||
|
test('reads hooks_intelligence_stats through the connected tool', async ($, on) => {
|
||||||
|
const w = world(on, [`${CORE}hooks_intelligence_stats`], '{"patterns": 42}')
|
||||||
|
await $.session.start(START)
|
||||||
|
expect((await $.command.run(slash('stats'))).text).toContain('42')
|
||||||
|
expect(w.calls[0]).toMatchObject({ tool: 'hooks_intelligence_stats' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('says plainly when no intelligence tool is connected', async ($, on) => {
|
||||||
|
world(on)
|
||||||
|
await $.session.start(START)
|
||||||
|
expect((await $.command.run(slash('stats'))).text).toContain('No intelligence tool is connected')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"//": "Self-contained on purpose: bundlers (vite/esbuild in the root CI vitest) load this file for every module they transform, so it must not extend a file that only exists after Claude Code (>= 2.1.287) has loaded the plugin. Claude Code writes its declarations to .claude-plugin/types/ (gitignored) on load or validate; tsc -p . then reads them through typeRoots.",
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["es2023"],
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"jsx": "react",
|
||||||
|
"jsxFactory": "h",
|
||||||
|
"jsxFragmentFactory": "Fragment",
|
||||||
|
"typeRoots": ["./.claude-plugin/types"],
|
||||||
|
"types": ["claude-code", "claude-code-tools", "claude-code-mcp"]
|
||||||
|
},
|
||||||
|
"include": ["hooks", "tests"]
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ruflo-iot-cognitum",
|
"name": "ruflo-iot-cognitum",
|
||||||
"description": "IoT device lifecycle, telemetry anomaly detection, fleet management, and witness chain verification for Cognitum Seed hardware",
|
"description": "IoT device lifecycle, telemetry anomaly detection, fleet management, and witness chain verification for Cognitum Seed hardware",
|
||||||
"version": "0.2.1",
|
"version": "0.3.0",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "ruvnet",
|
"name": "ruvnet",
|
||||||
"url": "https://github.com/ruvnet"
|
"url": "https://github.com/ruvnet"
|
||||||
@@ -21,6 +21,33 @@
|
|||||||
"sona",
|
"sona",
|
||||||
"mcp",
|
"mcp",
|
||||||
"cognitum-seed",
|
"cognitum-seed",
|
||||||
"5-tier-trust"
|
"5-tier-trust",
|
||||||
]
|
"mod",
|
||||||
|
"function-hooks",
|
||||||
|
"guard"
|
||||||
|
],
|
||||||
|
"userConfig": {
|
||||||
|
"guard": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Write guard",
|
||||||
|
"description": "Refuse writes and commands on this plugin's own tools that hold a key, token or password.",
|
||||||
|
"required": false,
|
||||||
|
"default": "on",
|
||||||
|
"options": [
|
||||||
|
"on",
|
||||||
|
"off"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"confirmDestructive": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Confirm destructive IoT commands",
|
||||||
|
"description": "Refuse cognitum-iot fleet delete and device delete/remove/revoke unless the command carries --confirm or COGNITUM_IOT_CONFIRM=1.",
|
||||||
|
"required": false,
|
||||||
|
"default": "on",
|
||||||
|
"options": [
|
||||||
|
"on",
|
||||||
|
"off"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -154,6 +154,23 @@ This plugin owns five AgentDB namespaces, all compliant with the [ruflo-agentdb
|
|||||||
|
|
||||||
Reserved namespaces (`pattern`, `claude-memories`, `default`) MUST NOT be shadowed.
|
Reserved namespaces (`pattern`, `claude-memories`, `default`) MUST NOT be shadowed.
|
||||||
|
|
||||||
|
## As a mod (0.3.0)
|
||||||
|
|
||||||
|
A function-hook mod ships beside the skills (ADR-445 pattern). Needs a Claude Code with mods (2.1.287+); older builds ignore it. No network, no process spawning: it only tightens calls to this plugin's own tools and reads through tools already connected.
|
||||||
|
|
||||||
|
| Piece | Default | What it does |
|
||||||
|
|---|---|---|
|
||||||
|
| **Write guard** | on | Refuses a secret in an `iot-*` memory record, or on a `cognitum-iot` command line. |
|
||||||
|
| **Destructive confirm** | on | Refuses `cognitum-iot fleet delete` and `device delete/remove/revoke/decommission/deregister` unless the command has `--confirm`/`--yes` or the `COGNITUM_IOT_CONFIRM=1` prefix (rollbacks and lists are untouched). |
|
||||||
|
| **`/iot-mod`** | — | `status`, `scan <text>`, `devices` (reads the `iot-devices` namespace through the connected memory tool); answered locally, no model call. |
|
||||||
|
| **Status file** | — | `.claude-flow/iot-mod/status.json` (`version`, `updatedMs`, mode flags and counters); written at session start and when a counter changes. |
|
||||||
|
|
||||||
|
Options (`userConfig`): `guard` on\|off, `confirmDestructive` on\|off. Refusals never echo the value they matched.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude plugin test plugins/ruflo-iot-cognitum # 10 tests
|
||||||
|
```
|
||||||
|
|
||||||
## Trust model parallel with federation
|
## Trust model parallel with federation
|
||||||
|
|
||||||
This plugin's 5-tier device trust model (UNKNOWN → REGISTERED → PROVISIONED → CERTIFIED → FLEET_TRUSTED) follows the same shape as the [ruflo-federation 5-tier trust model](../ruflo-federation/docs/adrs/0001-federation-contract.md) (UNTRUSTED → VERIFIED → ATTESTED → TRUSTED → PRIVILEGED). Different surface (IoT devices vs federation peers) and distinct naming, but the score-driven progression and capability-gating principle are the same.
|
This plugin's 5-tier device trust model (UNKNOWN → REGISTERED → PROVISIONED → CERTIFIED → FLEET_TRUSTED) follows the same shape as the [ruflo-federation 5-tier trust model](../ruflo-federation/docs/adrs/0001-federation-contract.md) (UNTRUSTED → VERIFIED → ATTESTED → TRUSTED → PRIVILEGED). Different surface (IoT devices vs federation peers) and distinct naming, but the score-driven progression and capability-gating principle are the same.
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { scan, tidy } from './screen'
|
||||||
|
import type { ModOptions } from './options'
|
||||||
|
import type { Stats } from './status'
|
||||||
|
|
||||||
|
export type CommandDeps = {
|
||||||
|
readonly opts: ModOptions
|
||||||
|
readonly stats: Stats
|
||||||
|
readonly peek: (suffix: string, args: Record<string, unknown>) => Promise<string | undefined>
|
||||||
|
readonly toolNames: () => Promise<readonly string[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
const HELP = ['/iot-mod status', '/iot-mod scan <text>', '/iot-mod devices'].join('\n')
|
||||||
|
|
||||||
|
/** `/iot-mod` is answered locally and takes no model turn (the plugin's `/iot` is a prompt command, which no hook can answer). */
|
||||||
|
export async function answer(args: string, deps: CommandDeps): Promise<string> {
|
||||||
|
const [verb = '', ...rest] = args.trim().split(/\s+/)
|
||||||
|
const arg = rest.join(' ')
|
||||||
|
const { opts, stats } = deps
|
||||||
|
|
||||||
|
if (verb === '' || verb === 'help') return HELP
|
||||||
|
|
||||||
|
if (verb === 'status') {
|
||||||
|
return `guard ${opts.guard ? 'on' : 'off'} · destructive needs confirm ${opts.confirmDestructive ? 'on' : 'off'}\ncalls blocked ${stats.blocked} · scans ${stats.scans} · commands ${stats.commands}${stats.lastDenied ? ` · last denied ${stats.lastDenied}` : ''}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verb === 'scan') {
|
||||||
|
if (arg === '') return 'usage: /iot-mod scan <text>'
|
||||||
|
stats.scans++
|
||||||
|
const found = scan(arg).secrets
|
||||||
|
return found.length ? `The guard would refuse a device record or command with that (secrets: ${found.join(', ')}).` : 'No secret shape found: the guard would let that through.'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verb === 'devices') {
|
||||||
|
const text = await deps.peek('memory_list', { namespace: 'iot-devices', limit: 20 })
|
||||||
|
if (text === undefined) return 'No memory tool is connected (or it errored). Connect the ruflo MCP server and try again.'
|
||||||
|
return text.trim() === '' ? 'No registered devices stored.' : `Registered devices (as stored, untrusted data):\n${tidy(text, 1200)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Unknown: ${verb}\n${HELP}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { hasSecret } from './screen'
|
||||||
|
import { bare, namespaceOf, textsOf } from './tools'
|
||||||
|
import type { ModOptions } from './options'
|
||||||
|
|
||||||
|
const IOT_NS = /^iot-/i
|
||||||
|
/** The plugin's CLI; its skills run it through Bash. */
|
||||||
|
const CLI = /\bcognitum-iot\b/
|
||||||
|
// Deleting a fleet, or deleting/revoking/decommissioning a device, is not undone by a rollback.
|
||||||
|
const DESTRUCTIVE = /\b(?:fleet\s+delete|device\s+(?:delete|remove|revoke|decommission|deregister))\b/
|
||||||
|
// An explicit go-ahead the person has given: a flag, or an env prefix (which the CLI never sees as an unknown option). Not `-y`: that is npx's own flag.
|
||||||
|
const CONFIRMED = /(?:^|\s)(?:--confirm|--yes)(?:\s|=|$)|\bCOGNITUM_IOT_CONFIRM=1\b/
|
||||||
|
|
||||||
|
/** The reason an IoT call is refused, or undefined when it may go. Never names or echoes the value. */
|
||||||
|
export function verdict(tool: string, input: unknown, opts: ModOptions): string | undefined {
|
||||||
|
const name = bare(tool)
|
||||||
|
|
||||||
|
if (name === 'memory_store') {
|
||||||
|
if (!IOT_NS.test(namespaceOf(input))) return undefined
|
||||||
|
return textsOf(input).some(hasSecret)
|
||||||
|
? 'ruflo-iot-cognitum: this device record holds what looks like a secret (a device token, key or password). Store the device id, not the credential.'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name !== 'Bash') return undefined
|
||||||
|
const command = (input as { command?: unknown } | null)?.command
|
||||||
|
if (typeof command !== 'string' || !CLI.test(command)) return undefined
|
||||||
|
|
||||||
|
if (hasSecret(command)) {
|
||||||
|
return 'ruflo-iot-cognitum: this cognitum-iot command carries what looks like a secret on its command line. Pass it through the environment or a prompt, not as an argument.'
|
||||||
|
}
|
||||||
|
if (opts.confirmDestructive && DESTRUCTIVE.test(command) && !CONFIRMED.test(command)) {
|
||||||
|
return 'ruflo-iot-cognitum: deleting a fleet or removing a device cannot be undone by a rollback. Ask the user first; once they agree, run it again prefixed with COGNITUM_IOT_CONFIRM=1.'
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"description": "IoT Cognitum as a mod: a tighten-only guard on the plugin's own tools (default on), /iot-mod (local, no model call), and a status file the console reads.",
|
||||||
|
"modules": ["./register.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { PluginOptions } from 'claude-code'
|
||||||
|
|
||||||
|
/** The plugin's `userConfig`, validated: a bad value is the default (both on). */
|
||||||
|
export type ModOptions = { readonly guard: boolean; readonly confirmDestructive: boolean }
|
||||||
|
|
||||||
|
const flag = (value: unknown, fallback: boolean) =>
|
||||||
|
value === true || value === 'true' || value === 'on' ? true : value === false || value === 'false' || value === 'off' ? false : fallback
|
||||||
|
|
||||||
|
export function readOptions(options: PluginOptions | undefined): ModOptions {
|
||||||
|
const o = options ?? {}
|
||||||
|
return { guard: flag(o.guard, true), confirmDestructive: flag(o.confirmDestructive, true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const modeOf = (o: ModOptions): Record<string, boolean> => ({ guard: o.guard, confirmDestructive: o.confirmDestructive })
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import type { Hook, Register } from 'claude-code'
|
||||||
|
|
||||||
|
import { answer } from './command'
|
||||||
|
import { verdict } from './guard'
|
||||||
|
import { modeOf, readOptions, type ModOptions } from './options'
|
||||||
|
import { newStats, STATUS_PATH, statusText, type Stats } from './status'
|
||||||
|
import { findTool } from './tools'
|
||||||
|
|
||||||
|
const TEXT_CAP = 20_000
|
||||||
|
|
||||||
|
type Dollar = Parameters<Hook<'session.start'>>[0]
|
||||||
|
|
||||||
|
/** Everything one session of the mod keeps: its settings, its counters and the project root. */
|
||||||
|
type Session = { readonly opts: ModOptions; readonly stats: Stats; root?: string }
|
||||||
|
|
||||||
|
async function flush($: Dollar, s: Session): Promise<void> {
|
||||||
|
if (s.root === undefined) return
|
||||||
|
try {
|
||||||
|
await $.fs.write(`${s.root}/${STATUS_PATH}`, statusText(s.stats, modeOf(s.opts), await $.clock.now()))
|
||||||
|
} catch {
|
||||||
|
/* the status file is a courtesy */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A read-only call through an already-connected tool: its first text block, or undefined when absent or errored. */
|
||||||
|
async function peek($: Dollar, suffix: string, args: Record<string, unknown>): Promise<string | undefined> {
|
||||||
|
const hit = findTool(await $.tool.list(), suffix)
|
||||||
|
if (!hit) return undefined
|
||||||
|
const res = await $.mcp.call(hit.server, hit.tool, args)
|
||||||
|
return res.isError ? undefined : (res.content.find(b => b.type === 'text')?.text ?? '').slice(0, TEXT_CAP)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IoT Cognitum as a mod (ADR-445 pattern): a tighten-only guard on this plugin's own tools, `/iot-mod`, and a status file the console
|
||||||
|
* reads. No network, no process spawning: only tools already connected.
|
||||||
|
*/
|
||||||
|
export const register: Register = (on, options) => {
|
||||||
|
const s: Session = { opts: readOptions(options), stats: newStats() }
|
||||||
|
|
||||||
|
on('session.start', async ($, e, next) => {
|
||||||
|
const result = await next(e)
|
||||||
|
s.root = (await $.session.root()) as string | undefined
|
||||||
|
try {
|
||||||
|
await $.command.register({ name: 'iot-mod', description: 'IoT Cognitum mod: status, scan <text>, devices' })
|
||||||
|
} catch {
|
||||||
|
/* a name taken by another plugin must not stop the mod */
|
||||||
|
}
|
||||||
|
await flush($, s)
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
if (s.opts.guard) {
|
||||||
|
on('tool.call', async ($, e, next) => {
|
||||||
|
const reason = verdict(e.tool, e, s.opts)
|
||||||
|
if (reason === undefined) return next(e)
|
||||||
|
s.stats.blocked++
|
||||||
|
s.stats.lastDenied = e.tool
|
||||||
|
await flush($, s)
|
||||||
|
return { deny: reason }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `/iot-mod` is answered locally and takes no model turn. */
|
||||||
|
on('command.run', { command: 'iot-mod' }, async ($, e) => {
|
||||||
|
s.stats.commands++
|
||||||
|
const text = await answer(typeof e.args === 'string' ? e.args : '', {
|
||||||
|
opts: s.opts,
|
||||||
|
stats: s.stats,
|
||||||
|
peek: (suffix, args) => peek($, suffix, args),
|
||||||
|
toolNames: async () => (await $.tool.list()).map(t => t.name),
|
||||||
|
})
|
||||||
|
await flush($, s)
|
||||||
|
return { text }
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Pure text screening for the AgentDB mod (ADR-445). Two jobs: find secrets (so none is stored) and find prompt-injection phrasing (so
|
||||||
|
* retrieved memory cannot instruct the model). Findings are NAMES only: the matched text is never returned, logged or counted by value.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SECRETS: readonly (readonly [string, RegExp])[] = [
|
||||||
|
['private key', /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
|
||||||
|
['aws access key', /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/],
|
||||||
|
['github token', /\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{40,})\b/],
|
||||||
|
['slack token', /\bxox[abprs]-[A-Za-z0-9-]{10,}/],
|
||||||
|
['google api key', /\bAIza[0-9A-Za-z_-]{35}\b/],
|
||||||
|
['anthropic or openai key', /\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{24,}/],
|
||||||
|
['jwt', /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/],
|
||||||
|
['bearer token', /\bBearer\s+[A-Za-z0-9._~+/=-]{24,}/],
|
||||||
|
['key assignment', /\b(?:api[_-]?key|secret|token|passw(?:or)?d|credential)s?["']?\s*[:=]\s*["']?[A-Za-z0-9/+=_.-]{16,}/i],
|
||||||
|
]
|
||||||
|
|
||||||
|
const INJECTION: readonly (readonly [string, RegExp])[] = [
|
||||||
|
['override instructions', /\b(?:ignore|disregard|forget|override)\b[^.\n]{0,40}\b(?:previous|prior|above|earlier|all|any|system)\b[^.\n]{0,30}\b(?:instructions?|rules?|prompts?|guidelines?)\b/i],
|
||||||
|
['role reassignment', /\byou are (?:now|no longer)\b|\bact as (?:an? )?(?:unrestricted|jailbroken)\b/i],
|
||||||
|
['new instructions', /\b(?:new|updated|real) (?:system )?instructions?\s*:/i],
|
||||||
|
['fake role tags', /<\/?\s*(?:system|assistant|developer|instructions?)\s*>|^\s*(?:system|assistant)\s*:/im],
|
||||||
|
['concealment', /\bdo not (?:tell|inform|mention|reveal)[^.\n]{0,30}\b(?:user|human|operator)\b/i],
|
||||||
|
['exfiltration', /\b(?:exfiltrate|send|post|upload)\b[^.\n]{0,50}\b(?:secrets?|credentials?|tokens?|api keys?|\.env)\b/i],
|
||||||
|
['shell pipe', /\b(?:curl|wget)\b[^|\n]{0,200}\|\s*(?:sudo\s+)?(?:ba|z)?sh\b/i],
|
||||||
|
]
|
||||||
|
|
||||||
|
// C0/C1 controls (keeping tab and newline), DEL, zero-width and bidi override characters.
|
||||||
|
const INVISIBLE = new RegExp('[\\u0000-\\u0008\\u000b-\\u001f\\u007f-\\u009f\\u200b-\\u200f\\u2028-\\u202e\\u2060-\\u2064\\ufeff]', 'g')
|
||||||
|
|
||||||
|
const bare = (text: string) => (text.length > 20_000 ? text.slice(0, 20_000) : text).replace(INVISIBLE, '')
|
||||||
|
|
||||||
|
export type Findings = { readonly secrets: readonly string[]; readonly injection: readonly string[] }
|
||||||
|
|
||||||
|
const names = (rules: readonly (readonly [string, RegExp])[], text: string) => rules.filter(([, re]) => re.test(text)).map(([name]) => name)
|
||||||
|
|
||||||
|
/** Names of every secret shape and injection phrase found in `text`. Cost is linear in the capped input. */
|
||||||
|
export function scan(text: string): Findings {
|
||||||
|
const bounded = bare(text)
|
||||||
|
return { secrets: names(SECRETS, bounded), injection: names(INJECTION, bounded) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hasSecret = (text: string) => names(SECRETS, bare(text)).length > 0
|
||||||
|
|
||||||
|
|
||||||
|
/** Makes stored text safe to show: no control or bidi characters, whitespace collapsed, at most `max` characters. */
|
||||||
|
export function tidy(text: string, max: number): string {
|
||||||
|
const flat = text.replace(INVISIBLE, '').replace(/\s+/g, ' ').trim()
|
||||||
|
return flat.length > max ? `${flat.slice(0, Math.max(0, max - 1))}…` : flat
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/** Counters the mod keeps for the session and writes to `.claude-flow/iot-mod/status.json` for the console. */
|
||||||
|
export type Stats = {
|
||||||
|
blocked: number
|
||||||
|
scans: number
|
||||||
|
commands: number
|
||||||
|
lastDenied?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const newStats = (): Stats => ({ blocked: 0, scans: 0, commands: 0 })
|
||||||
|
|
||||||
|
export const STATUS_PATH = '.claude-flow/iot-mod/status.json'
|
||||||
|
|
||||||
|
/** The file's text: mode flags plus the counters; `version` lets the console refuse a shape it does not know. */
|
||||||
|
export function statusText(stats: Stats, mode: Record<string, boolean>, nowMs: number): string {
|
||||||
|
return `${JSON.stringify({ version: 1, updatedMs: nowMs, ...mode, ...stats }, null, 2)}\n`
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { ToolInfo } from 'claude-code'
|
||||||
|
|
||||||
|
/** `mcp__<server>__<tool>` into its two halves; tool names never hold a double underscore. */
|
||||||
|
export function splitName(name: string): { server: string; tool: string } | undefined {
|
||||||
|
if (!name.startsWith('mcp__')) return undefined
|
||||||
|
const at = name.lastIndexOf('__')
|
||||||
|
return at > 5 ? { server: name.slice(5, at), tool: name.slice(at + 2) } : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The bare tool name: `mcp__srv__memory_store` and `memory_store` both give `memory_store`. */
|
||||||
|
export const bare = (name: string) => splitName(name)?.tool ?? name
|
||||||
|
|
||||||
|
/** A connected MCP tool whose bare name is `suffix`, split into what `$.mcp.call` takes. */
|
||||||
|
export function findTool(tools: readonly ToolInfo[], suffix: string): { server: string; tool: string } | undefined {
|
||||||
|
const hit = tools.find(t => t.mcp && bare(t.name) === suffix)
|
||||||
|
return hit && splitName(hit.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every string in a tool's input, to a bounded depth and size: what the guard reads. */
|
||||||
|
export function textsOf(input: unknown, budget = { left: 20_000 }, depth = 0): string[] {
|
||||||
|
if (budget.left <= 0 || depth > 6) return []
|
||||||
|
if (typeof input === 'string') {
|
||||||
|
budget.left -= input.length
|
||||||
|
return [input]
|
||||||
|
}
|
||||||
|
if (Array.isArray(input)) return input.slice(0, 200).flatMap(v => textsOf(v, budget, depth + 1))
|
||||||
|
if (typeof input === 'object' && input !== null) return Object.values(input).slice(0, 200).flatMap(v => textsOf(v, budget, depth + 1))
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tool input's `namespace` field when it is a string, else ''. */
|
||||||
|
export const namespaceOf = (input: unknown): string => {
|
||||||
|
const ns = (input as { namespace?: unknown } | null)?.namespace
|
||||||
|
return typeof ns === 'string' ? ns : ''
|
||||||
|
}
|
||||||
@@ -7,9 +7,9 @@ step() { printf "→ %s ... " "$1"; }
|
|||||||
ok() { printf "PASS\n"; PASS=$((PASS+1)); }
|
ok() { printf "PASS\n"; PASS=$((PASS+1)); }
|
||||||
bad() { printf "FAIL: %s\n" "$1"; FAIL=$((FAIL+1)); }
|
bad() { printf "FAIL: %s\n" "$1"; FAIL=$((FAIL+1)); }
|
||||||
|
|
||||||
step "1. plugin.json declares 0.2.1 with new keywords"
|
step "1. plugin.json declares 0.3.0 with new keywords"
|
||||||
v=$(grep -E '"version"' "$ROOT/.claude-plugin/plugin.json" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)
|
v=$(grep -E '"version"' "$ROOT/.claude-plugin/plugin.json" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)
|
||||||
if [[ "$v" != "0.2.1" ]]; then bad "expected 0.2.1, got '$v'"; else
|
if [[ "$v" != "0.3.0" ]]; then bad "expected 0.3.0, got '$v'"; else
|
||||||
miss=""
|
miss=""
|
||||||
for k in mcp cognitum-seed 5-tier-trust; do
|
for k in mcp cognitum-seed 5-tier-trust; do
|
||||||
grep -q "\"$k\"" "$ROOT/.claude-plugin/plugin.json" || miss="$miss $k"
|
grep -q "\"$k\"" "$ROOT/.claude-plugin/plugin.json" || miss="$miss $k"
|
||||||
@@ -91,5 +91,26 @@ ADR="$ROOT/docs/adrs/0001-iot-cognitum-contract.md"
|
|||||||
step "12. REFERENCE.md exists and is non-empty"
|
step "12. REFERENCE.md exists and is non-empty"
|
||||||
[[ -s "$ROOT/REFERENCE.md" ]] && ok || bad "REFERENCE.md missing or empty"
|
[[ -s "$ROOT/REFERENCE.md" ]] && ok || bad "REFERENCE.md missing or empty"
|
||||||
|
|
||||||
|
|
||||||
|
# M1. The mod (ADR-445 pattern): hooks module registered, files within the 500-line rule
|
||||||
|
step "M1. mod: hooks.json names register.ts, every hook file is present and under 500 lines"
|
||||||
|
mod_ok=1
|
||||||
|
grep -q '"./register.ts"' "$ROOT/hooks/hooks.json" || mod_ok=0
|
||||||
|
for f in options screen tools guard command status register; do
|
||||||
|
[[ -f "$ROOT/hooks/$f.ts" ]] || mod_ok=0
|
||||||
|
[[ $(wc -l < "$ROOT/hooks/$f.ts" 2>/dev/null || echo 9999) -le 500 ]] || mod_ok=0
|
||||||
|
done
|
||||||
|
[[ $mod_ok -eq 1 ]] && ok || bad "mod hooks incomplete or too long"
|
||||||
|
|
||||||
|
step "M2. mod: userConfig defaults are the safe ones (guard confirmDestructive all on)"
|
||||||
|
node -e '
|
||||||
|
const c = require(process.argv[1]).userConfig || {}
|
||||||
|
const need = process.argv.slice(2)
|
||||||
|
process.exit(need.every(k => c[k] && c[k].default === "on") ? 0 : 1)
|
||||||
|
' "$ROOT/.claude-plugin/plugin.json" guard confirmDestructive && ok || bad "userConfig defaults wrong"
|
||||||
|
|
||||||
|
step "M3. mod: hooks never touch the network or spawn a process"
|
||||||
|
if grep -nE '\$\.(http|process)\.|child_process|fetch\(' "$ROOT"/hooks/*.ts >/dev/null; then bad "network or process call in hooks"; else ok; fi
|
||||||
|
|
||||||
printf "\n%s passed, %s failed\n" "$PASS" "$FAIL"
|
printf "\n%s passed, %s failed\n" "$PASS" "$FAIL"
|
||||||
[[ $FAIL -eq 0 ]] || exit 1
|
[[ $FAIL -eq 0 ]] || exit 1
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import type { On } from 'claude-code'
|
||||||
|
import { describe, expect, test, tier } from 'claude-code/testing'
|
||||||
|
|
||||||
|
tier('user')
|
||||||
|
|
||||||
|
const ROOT = '/work'
|
||||||
|
const START = { surface: 'terminal', isInteractive: true, cwd: ROOT } as const
|
||||||
|
const slash = (args: string) => ({ command: 'iot-mod', args, origin: { kind: 'composer' }, presentation: { isFullscreen: false, columns: 100 } }) as const
|
||||||
|
const SECRET = `ghp_${'a1B2'.repeat(10)}`
|
||||||
|
const STATUS = `${ROOT}/.claude-flow/iot-mod/status.json`
|
||||||
|
|
||||||
|
type Calls = { server: string; tool: string; args: unknown }[]
|
||||||
|
|
||||||
|
/** The world beneath the mod: a project, a file map, the connected tools, and an MCP that answers every call with `reply`. */
|
||||||
|
function world(on: On, tools: readonly string[] = [], reply = '') {
|
||||||
|
const files = new Map<string, string>()
|
||||||
|
const calls: Calls = []
|
||||||
|
on('session.start', ($, e) => ({ cwd: e.cwd }))
|
||||||
|
on('session.root', () => ({ value: ROOT }))
|
||||||
|
on('command.register', ($, e) => ({ value: { command: e.name } }))
|
||||||
|
on('fs.write', ($, e) => (files.set(e.path, e.text), { value: undefined }))
|
||||||
|
on('tool.list', () => ({ value: tools.map(name => ({ name, description: '', mcp: name.startsWith('mcp__') })) }))
|
||||||
|
on('clock.now', () => ({ value: 1_700_000_000_000 }))
|
||||||
|
on('mcp.call', ($, e) => (calls.push({ server: e.server, tool: e.tool, args: e.args }), { value: { content: [{ type: 'text', text: reply }], isError: false } }))
|
||||||
|
return { files, calls, status: () => JSON.parse(files.get(STATUS) ?? '{}') as Record<string, unknown> }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs a tool call through the mod; a denial comes back as its reason text, an allowed call as `ALLOWED`. */
|
||||||
|
async function attempt($: { tool: { call: (i: never) => Promise<unknown> } }, input: Record<string, unknown>): Promise<string> {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(await $.tool.call(input as never))
|
||||||
|
} catch (e) {
|
||||||
|
return String(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CORE = 'mcp__plugin_ruflo-core_ruflo__'
|
||||||
|
|
||||||
|
describe('status file and defaults', () => {
|
||||||
|
test('session start registers /iot-mod and writes status.json (version 1, guard on)', async ($, on) => {
|
||||||
|
const w = world(on)
|
||||||
|
await $.session.start(START)
|
||||||
|
expect(w.status()).toMatchObject({ version: 1, guard: true, blocked: 0, commands: 0 })
|
||||||
|
expect(typeof w.status().updatedMs).toBe('number')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('status, an unknown verb and help answer locally', async ($, on) => {
|
||||||
|
world(on)
|
||||||
|
await $.session.start(START)
|
||||||
|
expect((await $.command.run(slash('status'))).text).toContain('guard on')
|
||||||
|
expect((await $.command.run(slash('what is this'))).text).toContain('/iot-mod status')
|
||||||
|
expect((await $.command.run(slash(''))).text).toContain('/iot-mod scan <text>')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('scan names the secret shape and never echoes the value', async ($, on) => {
|
||||||
|
world(on)
|
||||||
|
await $.session.start(START)
|
||||||
|
const hit = (await $.command.run(slash(`scan key ${SECRET}`))).text ?? ''
|
||||||
|
expect(hit).toContain('github token')
|
||||||
|
expect(hit).not.toContain('ghp_')
|
||||||
|
expect((await $.command.run(slash('scan plain words'))).text).toContain('No secret shape')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('guard', () => {
|
||||||
|
const bash = (command: string) => ({ tool: 'Bash', command })
|
||||||
|
|
||||||
|
test('refuses a secret in a device record, passes clean, leaves other namespaces alone', async ($, on) => {
|
||||||
|
const w = world(on)
|
||||||
|
on('tool.call', () => ({ result: 'stored' }))
|
||||||
|
await $.session.start(START)
|
||||||
|
const denied = await attempt($, { tool: `${CORE}memory_store`, key: 'k', value: `token ${SECRET}`, namespace: 'iot-devices' })
|
||||||
|
expect(denied).toContain('secret')
|
||||||
|
expect(denied).not.toContain('ghp_')
|
||||||
|
expect(await attempt($, { tool: `${CORE}memory_store`, key: 'k', value: 'Registered at 10.0.0.4', namespace: 'iot-devices' })).toContain('stored')
|
||||||
|
expect(await attempt($, { tool: `${CORE}memory_store`, key: 'k', value: SECRET, namespace: 'scratch' })).toContain('stored')
|
||||||
|
expect(w.status()).toMatchObject({ blocked: 1 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('fleet delete and device removal need a go-ahead; list and rollback do not', async ($, on) => {
|
||||||
|
world(on)
|
||||||
|
on('tool.call', () => ({ result: 'ran' }))
|
||||||
|
await $.session.start(START)
|
||||||
|
const cli = 'npx -y -p @claude-flow/plugin-iot-cognitum@latest cognitum-iot'
|
||||||
|
expect(await attempt($, bash(`${cli} fleet delete f-1`))).toContain('COGNITUM_IOT_CONFIRM=1')
|
||||||
|
expect(await attempt($, bash(`${cli} device revoke d-9`))).toContain('COGNITUM_IOT_CONFIRM=1')
|
||||||
|
expect(await attempt($, bash(`COGNITUM_IOT_CONFIRM=1 ${cli} fleet delete f-1`))).toContain('ran')
|
||||||
|
expect(await attempt($, bash(`${cli} fleet delete f-1 --confirm`))).toContain('ran')
|
||||||
|
expect(await attempt($, bash(`${cli} fleet list`))).toContain('ran')
|
||||||
|
expect(await attempt($, bash(`${cli} firmware rollback r-3`))).toContain('ran')
|
||||||
|
expect(await attempt($, bash('ls -la'))).toContain('ran')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a secret on a cognitum-iot command line is refused without being echoed', async ($, on) => {
|
||||||
|
world(on)
|
||||||
|
on('tool.call', () => ({ result: 'ran' }))
|
||||||
|
await $.session.start(START)
|
||||||
|
const denied = await attempt($, bash(`cognitum-iot device register --endpoint http://x --token ${SECRET}`))
|
||||||
|
expect(denied).toContain('command line')
|
||||||
|
expect(denied).not.toContain('ghp_')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('confirmDestructive: off lets a delete through', { options: { confirmDestructive: 'off' } }, async ($, on) => {
|
||||||
|
world(on)
|
||||||
|
on('tool.call', () => ({ result: 'ran' }))
|
||||||
|
await $.session.start(START)
|
||||||
|
expect(await attempt($, bash('cognitum-iot fleet delete f-1'))).toContain('ran')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('guard: off lets everything through', { options: { guard: 'off' } }, async ($, on) => {
|
||||||
|
world(on)
|
||||||
|
on('tool.call', () => ({ result: 'ran' }))
|
||||||
|
await $.session.start(START)
|
||||||
|
expect(await attempt($, bash('cognitum-iot fleet delete f-1'))).toContain('ran')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('/iot-mod devices', () => {
|
||||||
|
test('reads the iot-devices namespace through the connected memory tool', async ($, on) => {
|
||||||
|
const w = world(on, [`${CORE}memory_list`], 'seed-3 at 10.0.0.4')
|
||||||
|
await $.session.start(START)
|
||||||
|
expect((await $.command.run(slash('devices'))).text).toContain('seed-3')
|
||||||
|
expect(w.calls[0]).toMatchObject({ tool: 'memory_list', args: { namespace: 'iot-devices' } })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('says plainly when no memory tool is connected', async ($, on) => {
|
||||||
|
world(on)
|
||||||
|
await $.session.start(START)
|
||||||
|
expect((await $.command.run(slash('devices'))).text).toContain('No memory tool is connected')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"//": "Self-contained on purpose: bundlers (vite/esbuild in the root CI vitest) load this file for every module they transform, so it must not extend a file that only exists after Claude Code (>= 2.1.287) has loaded the plugin. Claude Code writes its declarations to .claude-plugin/types/ (gitignored) on load or validate; tsc -p . then reads them through typeRoots.",
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["es2023"],
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"jsx": "react",
|
||||||
|
"jsxFactory": "h",
|
||||||
|
"jsxFragmentFactory": "Fragment",
|
||||||
|
"typeRoots": ["./.claude-plugin/types"],
|
||||||
|
"types": ["claude-code", "claude-code-tools", "claude-code-mcp"]
|
||||||
|
},
|
||||||
|
"include": ["hooks", "tests"]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user