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:
ruv committed 2026-10-04 15:35:31 -04:00
1 parent bd3daee854
commit 1ca7d0e788
51 files changed
+1896 -21

No files matched your search

+30 -3
View File
@@ -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"
]
}
}
} }
+17
View File
@@ -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)
+40
View File
@@ -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}`
}
+29
View File
@@ -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
}
+4
View File
@@ -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"]
}
+14
View File
@@ -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 })
+75
View File
@@ -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 }
})
}
+50
View File
@@ -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
}
+16
View File
@@ -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`
}
+35
View File
@@ -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 : ''
}
+23 -2
View File
@@ -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
+127
View File
@@ -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')
})
})
+19
View File
@@ -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
View File
@@ -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"
]
}
}
} }
+17
View File
@@ -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}`
}
+33
View File
@@ -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`
}
+35
View File
@@ -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 : ''
}
+25 -4
View File
@@ -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')
})
})
+19
View File
@@ -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"
]
}
}
} }
+17
View File
@@ -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}`
}
+35
View File
@@ -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`
}
+35
View File
@@ -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 : ''
}
+23 -2
View File
@@ -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')
})
})
+19
View File
@@ -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"]
}