@claude-flow/cli 3.51.1 → 3.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/helpers/.helpers-version +1 -1
- package/.claude/helpers/helpers.manifest.json +2 -2
- package/.claude/helpers/router.cjs +0 -0
- package/README.md +19 -2
- package/catalog-manifest.json +2 -2
- package/dist/src/commands/guidance.js +43 -2
- package/dist/src/guidance/mod-projection.d.ts +120 -0
- package/dist/src/guidance/mod-projection.js +155 -0
- package/dist/src/guidance/mod-sources.d.ts +22 -0
- package/dist/src/guidance/mod-sources.js +123 -0
- package/dist/src/init/helpers-generator.js +75 -4
- package/package.json +1 -1
- package/plugins/ruflo-metaharness/.claude-plugin/plugin.json +20 -4
- package/plugins/ruflo-metaharness/README.md +12 -0
- package/plugins/ruflo-metaharness/hooks/guard.ts +27 -0
- package/plugins/ruflo-metaharness/hooks/hooks.json +4 -0
- package/plugins/ruflo-metaharness/hooks/options.ts +14 -0
- package/plugins/ruflo-metaharness/hooks/register.ts +108 -0
- package/plugins/ruflo-metaharness/hooks/screen.ts +260 -0
- package/plugins/ruflo-metaharness/scripts/bench-parse-mcp-scan.mjs +13 -4
- package/plugins/ruflo-metaharness/scripts/smoke.sh +29 -7
- package/plugins/ruflo-metaharness/tests/register.test.ts +117 -0
- package/plugins/ruflo-metaharness/tests/textsof.test.ts +22 -0
- package/plugins/ruflo-metaharness/tsconfig.json +3 -0
|
@@ -433,6 +433,75 @@ if (command && commands[command]) {
|
|
|
433
433
|
module.exports = commands;
|
|
434
434
|
`;
|
|
435
435
|
}
|
|
436
|
+
// Literal-word root guard mirrored in the mod and both shipped classic helpers.
|
|
437
|
+
const ROOT_DELETE_CHECK_SOURCE = String.raw `function hasRootDelete(command, depth = 0) {
|
|
438
|
+
let word = '', quote = '', started = false, redirect = false
|
|
439
|
+
let inRm = false, optionsEnded = false, recursive = false, force = false, root = false
|
|
440
|
+
const isRoot = (operand) => {
|
|
441
|
+
if (!operand.startsWith('/')) return false
|
|
442
|
+
const parts = []
|
|
443
|
+
for (const part of operand.split('/')) {
|
|
444
|
+
if (!part || part === '.') continue
|
|
445
|
+
if (part === '..') parts.pop()
|
|
446
|
+
else parts.push(part)
|
|
447
|
+
}
|
|
448
|
+
return parts.length === 0 || /[*?\[]/.test(parts[0])
|
|
449
|
+
}
|
|
450
|
+
const finishWord = () => {
|
|
451
|
+
if (!started) return false
|
|
452
|
+
// Literal shell strings (e.g. sh -c 'rm -rf /') also carried the old guard.
|
|
453
|
+
// Bound rescanning to four levels; beyond that retain its conservative check.
|
|
454
|
+
if (word.includes('rm') && /[\s;&|()]/.test(word)) {
|
|
455
|
+
if (depth < 4 ? hasRootDelete(word, depth + 1) : word.includes('rm -rf /')) return true
|
|
456
|
+
}
|
|
457
|
+
if (!inRm) inRm = word === 'rm' || word.endsWith('/rm')
|
|
458
|
+
else if (!optionsEnded && word === '--') optionsEnded = true
|
|
459
|
+
else if (!optionsEnded && word.startsWith('-')) {
|
|
460
|
+
recursive = recursive || word === '--recursive' || /^-[a-z]*r[a-z]*$/.test(word)
|
|
461
|
+
force = force || word === '--force' || /^-[a-z]*f[a-z]*$/.test(word)
|
|
462
|
+
} else root = root || isRoot(word)
|
|
463
|
+
word = ''; started = false
|
|
464
|
+
return inRm && recursive && force && root
|
|
465
|
+
}
|
|
466
|
+
const finishCommand = () => {
|
|
467
|
+
const denied = inRm && recursive && force && root
|
|
468
|
+
inRm = optionsEnded = recursive = force = root = false
|
|
469
|
+
return denied
|
|
470
|
+
}
|
|
471
|
+
for (let i = 0; i < command.length; i++) {
|
|
472
|
+
const char = command[i]
|
|
473
|
+
const redirectionAmpersand = char === '&' && (redirect || command[i + 1] === '>')
|
|
474
|
+
redirect = false
|
|
475
|
+
if (quote) {
|
|
476
|
+
if (char === quote) quote = ''
|
|
477
|
+
else if (quote === '"' && char === '\\' && i + 1 < command.length &&
|
|
478
|
+
(command[i + 1] === '"' || command[i + 1] === '\\' || command[i + 1] === '$' ||
|
|
479
|
+
command.charCodeAt(i + 1) === 96 || command[i + 1] === '\n')) {
|
|
480
|
+
const next = command[++i]
|
|
481
|
+
if (next !== '\n') word += next
|
|
482
|
+
} else word += char
|
|
483
|
+
continue
|
|
484
|
+
}
|
|
485
|
+
if (char === '\\' && i + 1 < command.length) {
|
|
486
|
+
const next = command[++i]
|
|
487
|
+
if (next !== '\n') { word += next; started = true }
|
|
488
|
+
} else if (char === '"' || char === "'") {
|
|
489
|
+
quote = char; started = true
|
|
490
|
+
} else if (char === '#' && !started) {
|
|
491
|
+
while (i < command.length && command[i] !== '\n') i++
|
|
492
|
+
if (finishCommand()) return true
|
|
493
|
+
} else if (char === ' ' || char === '\t' || char === '\r' || char === '\n' ||
|
|
494
|
+
';|&()<>'.includes(char)) {
|
|
495
|
+
if (finishWord()) return true
|
|
496
|
+
// Redirections separate words, but later operands still belong to rm.
|
|
497
|
+
redirect = char === '<' || char === '>'
|
|
498
|
+
if (!redirectionAmpersand && (char === '\n' || ';|&()'.includes(char)) && finishCommand()) return true
|
|
499
|
+
} else {
|
|
500
|
+
word += char; started = true
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return finishWord() || finishCommand()
|
|
504
|
+
}`;
|
|
436
505
|
/**
|
|
437
506
|
* Generate hook-handler.cjs (cross-platform hook dispatcher)
|
|
438
507
|
* This is the inline fallback when file copy from the package fails.
|
|
@@ -569,13 +638,14 @@ export function generateHookHandler() {
|
|
|
569
638
|
' }',
|
|
570
639
|
' if (router && router.routeTask) {',
|
|
571
640
|
' const result = router.routeTask(prompt);',
|
|
641
|
+
" const row = (text) => '| ' + text.substring(0, 60).padEnd(60) + ' |';",
|
|
572
642
|
' var output = [];',
|
|
573
643
|
" output.push('[INFO] Routing task: ' + (prompt.substring(0, 80) || '(no prompt)'));",
|
|
574
644
|
" output.push('');",
|
|
575
645
|
" output.push('+------------------- Primary Recommendation -------------------+');",
|
|
576
|
-
" output.push('
|
|
577
|
-
" output.push('
|
|
578
|
-
" output.push('
|
|
646
|
+
" output.push(row('Agent: ' + result.agent));",
|
|
647
|
+
" output.push(row('Confidence: ' + (result.confidence * 100).toFixed(1) + '%'));",
|
|
648
|
+
" output.push(row('Reason: ' + (result.reason || '')));",
|
|
579
649
|
" output.push('+--------------------------------------------------------------+');",
|
|
580
650
|
" console.log(output.join('\\n'));",
|
|
581
651
|
' } else {',
|
|
@@ -637,8 +707,9 @@ export function generateHookHandler() {
|
|
|
637
707
|
" 'pre-bash': () => {",
|
|
638
708
|
" var cmd = String(hookInput.command || toolInputObj.command || prompt || '').toLowerCase();",
|
|
639
709
|
" var dangerous = ['rm -rf /', 'format c:', 'del /s /q c:\\\\', ':(){:|:&};:'];",
|
|
710
|
+
ROOT_DELETE_CHECK_SOURCE.split('\n').map((line) => ' ' + line).join('\n'),
|
|
640
711
|
' for (var i = 0; i < dangerous.length; i++) {',
|
|
641
|
-
|
|
712
|
+
" if (dangerous[i] === 'rm -rf /' ? hasRootDelete(cmd) : cmd.includes(dangerous[i])) {",
|
|
642
713
|
" console.error('[BLOCKED] Dangerous command detected: ' + dangerous[i]);",
|
|
643
714
|
' // Claude Code PreToolUse: exit 2 blocks execution; exit 1 is non-blocking.',
|
|
644
715
|
' process.exit(2);',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@claude-flow/cli",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.52.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Ruflo CLI - Enterprise AI agent orchestration with 60+ specialized agents, swarm coordination, MCP server, self-learning hooks, and vector memory for Claude Code",
|
|
6
6
|
"main": "dist/src/index.js",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ruflo-metaharness",
|
|
3
|
-
"description": "MetaHarness integration for ruflo — surfaces score/genome/mint/mcp-scan/threat-model via skills; pairs with @metaharness/router (ADR-148/149) for cost-optimal model routing; honors ADR-150's architectural constraint that MetaHarness remains an optional augmentation, never a required runtime dependency",
|
|
4
|
-
"version": "0.
|
|
3
|
+
"description": "MetaHarness integration for ruflo — surfaces score/genome/mint/mcp-scan/threat-model via skills; pairs with @metaharness/router (ADR-148/149) for cost-optimal model routing; honors ADR-150's architectural constraint that MetaHarness remains an optional augmentation, never a required runtime dependency. As a mod (ADR-445): confirms flywheel promotion, keeps secrets out of inputs, /metaharness-mod, and a status file the console shows",
|
|
4
|
+
"version": "0.2.3",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "ruvnet",
|
|
7
7
|
"url": "https://github.com/ruvnet"
|
|
@@ -27,6 +27,22 @@
|
|
|
27
27
|
"namespace-routing",
|
|
28
28
|
"verified",
|
|
29
29
|
"ci",
|
|
30
|
-
"phase-1-mvp"
|
|
31
|
-
|
|
30
|
+
"phase-1-mvp",
|
|
31
|
+
"mod",
|
|
32
|
+
"function-hooks",
|
|
33
|
+
"guard"
|
|
34
|
+
],
|
|
35
|
+
"userConfig": {
|
|
36
|
+
"guard": {
|
|
37
|
+
"type": "string",
|
|
38
|
+
"title": "Guard",
|
|
39
|
+
"description": "Refuse tool calls that are clearly harmful in this plugin's domain (see README, As a mod).",
|
|
40
|
+
"required": false,
|
|
41
|
+
"default": "on",
|
|
42
|
+
"options": [
|
|
43
|
+
"on",
|
|
44
|
+
"off"
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
}
|
|
32
48
|
}
|
|
@@ -70,3 +70,15 @@ This means:
|
|
|
70
70
|
- [Research dossier](https://gist.github.com/ruvnet/19d166ff9acf368c9da4172d91ac9113) — full graded-evidence sourcing
|
|
71
71
|
- [Upstream](https://github.com/ruvnet/agent-harness-generator) — `metaharness` source
|
|
72
72
|
- ADR-148/149 — `@metaharness/router` cost-optimal routing (sibling integration)
|
|
73
|
+
|
|
74
|
+
## As a mod
|
|
75
|
+
|
|
76
|
+
This plugin ships a function-hook mod (ADR-445, `hooks/register.ts`). It makes no network call, spawns no process and makes no model call.
|
|
77
|
+
|
|
78
|
+
- **Guard** (tighten-only, on by default): Refuses any `metaharness_*` input that holds a key, token or password (MetaHarness writes its inputs to audit memory), and `metaharness_flywheel` `promote` / `evidence-reset` unless the call carries `confirm: true`. A refusal never echoes the secret.
|
|
79
|
+
- **`/metaharness-mod`**: `status`, `recent`, `tools` (which of this plugin's tools are connected) and `scan <text>` (would the guard refuse it). Answered locally.
|
|
80
|
+
- **Status file**: `.claude-flow/metaharness-mod/status.json` (`{version: 1, updatedMs, guard, calls, total, blocked, recent}`, counters only, never tool input), written at session start and after every call to this plugin's tools.
|
|
81
|
+
|
|
82
|
+
Options (`userConfig`): `guard` (on).
|
|
83
|
+
|
|
84
|
+
Test it: `claude plugin validate plugins/ruflo-metaharness`, `claude plugin test plugins/ruflo-metaharness`, `bash plugins/ruflo-metaharness/scripts/smoke.sh`.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { hasSecret } from './screen'
|
|
2
|
+
import type { ModOptions } from './options'
|
|
3
|
+
import { textsOf } from './screen'
|
|
4
|
+
export { textsOf }
|
|
5
|
+
|
|
6
|
+
/** `mcp__<server>__<tool>` into its tool half; tool names never hold a double underscore. */
|
|
7
|
+
export function shortName(name: string): string {
|
|
8
|
+
const at = name.lastIndexOf('__')
|
|
9
|
+
return name.startsWith('mcp__') && at > 5 ? name.slice(at + 2) : name
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const field = (input: unknown, key: string): unknown => (typeof input === 'object' && input !== null ? (input as Record<string, unknown>)[key] : undefined)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
/** The tools this plugin owns: every `metaharness_*` tool. */
|
|
16
|
+
export const isOwn = (name: string, _input?: unknown): boolean => name.startsWith('metaharness_')
|
|
17
|
+
|
|
18
|
+
/** The reason a call is refused, or undefined when it may go. Never names or echoes a secret. */
|
|
19
|
+
export function verdict(name: string, input: unknown, _opts: ModOptions, _calls: Record<string, number>): string | undefined {
|
|
20
|
+
if (textsOf(input).some(hasSecret)) {
|
|
21
|
+
return 'ruflo-metaharness: this input holds what looks like a secret (a key, token or password). MetaHarness records its inputs in audit memory; pass a path or a reference instead.'
|
|
22
|
+
}
|
|
23
|
+
if (name === 'metaharness_flywheel' && ['promote', 'evidence-reset'].includes(String(field(input, 'operation'))) && field(input, 'confirm') !== true) {
|
|
24
|
+
return 'ruflo-metaharness: promoting a receipt or resetting evidence changes the active harness. Ask the user, then retry with confirm: true.'
|
|
25
|
+
}
|
|
26
|
+
return undefined
|
|
27
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { PluginOptions } from 'claude-code'
|
|
2
|
+
|
|
3
|
+
/** The plugin's `userConfig`, validated: a bad value is the default (guard on). */
|
|
4
|
+
export type ModOptions = { readonly guard: boolean; readonly extra: Readonly<Record<string, unknown>> }
|
|
5
|
+
|
|
6
|
+
// BEGIN SHARED FLAG (generated by scripts/sync-mod-screen.mjs; edit plugins/ruflo-agentdb/hooks/options.ts)
|
|
7
|
+
export const flag = (value: unknown, fallback: boolean) =>
|
|
8
|
+
value === true || value === 'true' || value === 'on' ? true : value === false || value === 'false' || value === 'off' ? false : fallback
|
|
9
|
+
// END SHARED FLAG
|
|
10
|
+
|
|
11
|
+
export function readOptions(options: PluginOptions | undefined): ModOptions {
|
|
12
|
+
const o = options ?? {}
|
|
13
|
+
return { guard: flag(o.guard, true), extra: {} }
|
|
14
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { Hook, Register } from 'claude-code'
|
|
2
|
+
|
|
3
|
+
import { isOwn, shortName, verdict } from './guard'
|
|
4
|
+
import { readOptions, type ModOptions } from './options'
|
|
5
|
+
import { scan } from './screen'
|
|
6
|
+
|
|
7
|
+
const COMMAND = 'metaharness-mod'
|
|
8
|
+
const STATUS_PATH = '.claude-flow/metaharness-mod/status.json'
|
|
9
|
+
const RECENT = 5
|
|
10
|
+
|
|
11
|
+
type Dollar = Parameters<Hook<'session.start'>>[0]
|
|
12
|
+
|
|
13
|
+
/** Everything one session of the mod keeps: its settings, per-tool counters, what was refused and the project root. */
|
|
14
|
+
type Session = {
|
|
15
|
+
readonly opts: ModOptions
|
|
16
|
+
readonly calls: Record<string, number>
|
|
17
|
+
blocked: number
|
|
18
|
+
readonly recent: string[]
|
|
19
|
+
root?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const total = (s: Session) => Object.values(s.calls).reduce((a, b) => a + b, 0)
|
|
23
|
+
|
|
24
|
+
function note(s: Session, line: string): void {
|
|
25
|
+
s.recent.push(line)
|
|
26
|
+
if (s.recent.length > RECENT) s.recent.splice(0, s.recent.length - RECENT)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The status file the console reads; `version` lets it refuse a shape it does not know. Counters only, never tool input. */
|
|
30
|
+
async function flush($: Dollar, s: Session): Promise<void> {
|
|
31
|
+
if (s.root === undefined) return
|
|
32
|
+
const body = { version: 1, updatedMs: await $.clock.now(), guard: s.opts.guard, ...s.opts.extra, calls: s.calls, total: total(s), blocked: s.blocked, recent: s.recent }
|
|
33
|
+
try {
|
|
34
|
+
await $.fs.write(`${s.root}/${STATUS_PATH}`, `${JSON.stringify(body, null, 2)}\n`)
|
|
35
|
+
} catch {
|
|
36
|
+
/* the status file is a courtesy */
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The connected tools this plugin owns, by short name. */
|
|
41
|
+
async function connected($: Dollar): Promise<string[]> {
|
|
42
|
+
const tools = await $.tool.list()
|
|
43
|
+
return tools.filter(t => t.mcp && isOwn(shortName(t.name))).map(t => shortName(t.name))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const HELP = ['/metaharness-mod status', '/metaharness-mod scan <text>', '/metaharness-mod recent', '/metaharness-mod tools'].join('\n')
|
|
47
|
+
|
|
48
|
+
async function answer($: Dollar, s: Session, args: string): Promise<string> {
|
|
49
|
+
const [verb = '', ...rest] = args.trim().split(/\s+/)
|
|
50
|
+
const arg = rest.join(' ')
|
|
51
|
+
if (verb === '' || verb === 'help') return HELP
|
|
52
|
+
if (verb === 'status') {
|
|
53
|
+
const per = Object.entries(s.calls).map(([k, v]) => `${k} ${v}`).join(' · ')
|
|
54
|
+
return [`guard ${s.opts.guard ? 'on' : 'off'}${Object.entries(s.opts.extra).map(([k, v]) => ` · ${k} ${String(v)}`).join('')}`, `${total(s)} call${total(s) === 1 ? '' : 's'}${per ? ` (${per})` : ''} · refused ${s.blocked}`].join('\n')
|
|
55
|
+
}
|
|
56
|
+
if (verb === 'recent') return s.recent.length ? s.recent.map(r => `- ${r}`).join('\n') : 'Nothing has gone through this session.'
|
|
57
|
+
if (verb === 'tools') {
|
|
58
|
+
const names = await connected($)
|
|
59
|
+
return names.length ? names.join('\n') : 'None of this plugin\'s tools are connected. Connect the ruflo MCP server and try again.'
|
|
60
|
+
}
|
|
61
|
+
if (verb === 'scan') {
|
|
62
|
+
if (arg === '') return 'usage: /metaharness-mod scan <text>'
|
|
63
|
+
const found = scan(arg)
|
|
64
|
+
return found.secrets.length ? `The guard would refuse that (${found.secrets.join(', ')}).` : 'Nothing found: the guard would let that through.'
|
|
65
|
+
}
|
|
66
|
+
return `Unknown: ${verb}\n${HELP}`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* MetaHarness as a mod (ADR-445): a tighten-only guard on this plugin's own tools, a status file the console reads, and `/metaharness-mod`.
|
|
71
|
+
* No network, no process, no model call.
|
|
72
|
+
*/
|
|
73
|
+
export const register: Register = (on, options) => {
|
|
74
|
+
const s: Session = { opts: readOptions(options), calls: {}, blocked: 0, recent: [] }
|
|
75
|
+
|
|
76
|
+
on('session.start', async ($, e, next) => {
|
|
77
|
+
const result = await next(e)
|
|
78
|
+
s.root = (await $.session.root()) as string | undefined
|
|
79
|
+
try {
|
|
80
|
+
await $.command.register({ name: COMMAND, description: 'MetaHarness mod: status, scan <text>, recent, tools' })
|
|
81
|
+
} catch {
|
|
82
|
+
/* a name taken by another plugin must not stop the mod */
|
|
83
|
+
}
|
|
84
|
+
await flush($, s)
|
|
85
|
+
return result
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
on('tool.call', async ($, e, next) => {
|
|
89
|
+
const name = shortName(e.tool)
|
|
90
|
+
if (!isOwn(name, e)) return next(e)
|
|
91
|
+
if (s.opts.guard) {
|
|
92
|
+
const reason = verdict(name, e, s.opts, s.calls)
|
|
93
|
+
if (reason !== undefined) {
|
|
94
|
+
s.blocked++
|
|
95
|
+
note(s, `refused ${name}`)
|
|
96
|
+
await flush($, s)
|
|
97
|
+
return { deny: reason }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
s.calls[name] = (s.calls[name] ?? 0) + 1
|
|
101
|
+
note(s, name)
|
|
102
|
+
await flush($, s)
|
|
103
|
+
return next(e)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
/** `/metaharness-mod`: the plugin's own commands are prompt commands, which no hook can answer. */
|
|
107
|
+
on('command.run', { command: COMMAND }, async ($, e) => ({ text: await answer($, s, typeof e.args === 'string' ? e.args : '') }))
|
|
108
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure text screening for the AgentDB mod (ADR-445). Two jobs: find secrets (so none is stored) and find prompt-injection phrasing (so
|
|
3
|
+
* retrieved memory cannot instruct the model). Findings are NAMES only: the matched text is never returned, logged or counted by value.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// BEGIN SHARED SCREEN (generated from plugins/ruflo-agentdb/hooks/screen.ts by scripts/sync-mod-screen.mjs; do not edit in a copy)
|
|
7
|
+
export type Rules = readonly (readonly [string, RegExp])[]
|
|
8
|
+
|
|
9
|
+
/** The secret shapes every mod screens for. A plugin adds its own after these, outside the markers. */
|
|
10
|
+
export const COMMON_SECRETS: Rules = [
|
|
11
|
+
['private key', /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
|
|
12
|
+
['aws access key', /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/],
|
|
13
|
+
['github token', /\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{40,})\b/],
|
|
14
|
+
['slack token', /\bxox[abprs]-[A-Za-z0-9-]{10,}/],
|
|
15
|
+
['slack webhook', /\bhooks\.slack\.com\/services\/T[A-Z0-9]{6,}\/B[A-Z0-9]{6,}\/[A-Za-z0-9]{16,}/],
|
|
16
|
+
['google api key', /\bAIza[0-9A-Za-z_-]{35}\b/],
|
|
17
|
+
['anthropic or openai key', /\bsk-(?:(?:ant|proj|svcacct|admin)-[A-Za-z0-9_-]{20,}|(?=[A-Za-z]{0,40}\d)[A-Za-z0-9]{32,})/],
|
|
18
|
+
['stripe key', /\b[rs]k_live_[A-Za-z0-9]{16,}/],
|
|
19
|
+
['npm token', /\bnpm_[A-Za-z0-9]{36}\b/],
|
|
20
|
+
['huggingface token', /\bhf_[A-Za-z0-9]{30,}\b/],
|
|
21
|
+
['sendgrid key', /\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/],
|
|
22
|
+
['twilio key', /\bSK[0-9a-f]{32}\b/],
|
|
23
|
+
['jwt', /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/],
|
|
24
|
+
['bearer token', /\bBearer\s+([A-Za-z0-9._~+/=-]{24,})/],
|
|
25
|
+
['database url with credentials', /\b[a-z][a-z0-9+.-]{1,20}:\/\/[^\s:@/]+:[^\s@/]{3,}@[^\s/]+/i],
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
export const INJECTION: Rules = [
|
|
29
|
+
['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],
|
|
30
|
+
['role reassignment', /\byou are (?:now|no longer)\b|\bact as (?:an? )?(?:unrestricted|jailbroken)\b/i],
|
|
31
|
+
['new instructions', /\b(?:new|updated|real) (?:system )?instructions?\s*:/i],
|
|
32
|
+
['fake role tags', /<\/?\s*(?:system|assistant|developer|instructions?)\s*>|^\s*(?:system|assistant)\s*:/im],
|
|
33
|
+
['concealment', /\bdo not (?:tell|inform|mention|reveal)[^.\n]{0,30}\b(?:user|human|operator)\b/i],
|
|
34
|
+
['exfiltration', /\b(?:exfiltrate|send|post|upload)\b[^.\n]{0,50}\b(?:secrets?|credentials?|tokens?|api keys?|\.env)\b/i],
|
|
35
|
+
['shell pipe', /\b(?:curl|wget)\b[^|\n]{0,200}\|\s*(?:sudo\s+)?(?:ba|z)?sh\b/i],
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
// C0/C1 controls (keeping tab and newline), DEL, soft hyphen, combining grapheme joiner, Arabic letter mark, Hangul and Mongolian fillers/separators,
|
|
39
|
+
// zero-width, bidi (overrides and isolates) and invisible-format characters, variation selectors; built with escapes, never raw.
|
|
40
|
+
const INVISIBLE = new RegExp(
|
|
41
|
+
'[\\u0000-\\u0008\\u000b-\\u001f\\u007f-\\u009f\\u00ad\\u034f\\u061c\\u115f\\u1160\\u17b4\\u17b5\\u180b-\\u180e\\u200b-\\u200f\\u2028-\\u202e\\u2060-\\u206f\\u3164\\ufe00-\\ufe0f\\ufeff\\uffa0\\ufff9-\\ufffb]',
|
|
42
|
+
'g',
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
/** Longest input scanned in one pass; a longer one keeps its head and tail halves. One regex pass per rule, so cost stays linear. */
|
|
46
|
+
const MAX_SCAN = 200_000
|
|
47
|
+
|
|
48
|
+
/** Input bounded to MAX_SCAN characters with invisible characters removed, so none can hide a secret or a phrase. */
|
|
49
|
+
export const bare = (text: string) =>
|
|
50
|
+
(text.length > MAX_SCAN ? text.slice(0, MAX_SCAN / 2) + '\n' + text.slice(-MAX_SCAN / 2) : text).replace(INVISIBLE, '')
|
|
51
|
+
|
|
52
|
+
// A value is a secret CANDIDATE only when it is not a reference (env var, call, identifier path, placeholder, secret-manager path) and its
|
|
53
|
+
// shape is random enough: at least two character classes, one of them a digit or symbol, and Shannon entropy of at least 2.5 bits per character.
|
|
54
|
+
const PLACEHOLDER = /placeholder|your[-_ ]|example|changeme|change[-_]?me|redacted|dummy|replace[-_]?me|insert[-_]|\*{3,}|x{5,}|\.{3}|^(?:none|null|undefined|true|false)$/i
|
|
55
|
+
const REFERENCE =
|
|
56
|
+
/^(?:\$(?:\{[^}]*\}|\(|[A-Za-z_]\w*$)|%[^%]*%$|<[^>]*>$|\{\{|process\.env|os\.environ|env[.[]|import\.meta|System\.getenv|secrets?\.|vault:|op:\/\/|ref\+|arn:|projects\/[^/]+\/secrets\/|gcp:|kms:|aws:|file:)/i
|
|
57
|
+
const CALL = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*\(|^[A-Za-z_$][\w$]*\[/
|
|
58
|
+
const IDENT_PATH = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/
|
|
59
|
+
const UUID = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i
|
|
60
|
+
const NAME_LIKE = /^[a-z][a-z0-9]*(?:[-_./][a-z0-9]+){2,}$/
|
|
61
|
+
|
|
62
|
+
function entropy(v: string): number {
|
|
63
|
+
const counts = new Map<string, number>()
|
|
64
|
+
for (const ch of v) counts.set(ch, (counts.get(ch) ?? 0) + 1)
|
|
65
|
+
let h = 0
|
|
66
|
+
for (const n of counts.values()) h -= (n / v.length) * Math.log2(n / v.length)
|
|
67
|
+
return h
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** True when `v` is a name, call, path or placeholder rather than a literal credential. */
|
|
71
|
+
function isReference(v: string): boolean {
|
|
72
|
+
if (PLACEHOLDER.test(v) || REFERENCE.test(v) || CALL.test(v) || IDENT_PATH.test(v) || URL_NO_CREDS.test(v)) return true
|
|
73
|
+
return NAME_LIKE.test(v) && v.replace(/\D/g, '').length / v.length < 0.15
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function plausibleSecret(v: string): boolean {
|
|
77
|
+
if (v.length < 8 || v.length > 256 || /\s/.test(v) || UUID.test(v) || isReference(v)) return false
|
|
78
|
+
const symbol = /[^A-Za-z0-9]/.test(v)
|
|
79
|
+
const digit = /\d/.test(v)
|
|
80
|
+
const classes = [/[a-z]/.test(v), /[A-Z]/.test(v), digit, symbol].filter(Boolean).length
|
|
81
|
+
return classes >= 2 && (digit || symbol) && entropy(v) >= 2.5
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Under a secret-named key a literal this long is a secret even with one character class or a UUID shape, unless it is a clear reference. */
|
|
85
|
+
const KEYED_MIN = 20
|
|
86
|
+
const PLACEHOLDER_WORD = /(?:^|[^a-z])(?:your|placeholder|changeme|change[-_]?me|example|redacted|dummy|replace[-_]?me|insert)(?:[^a-z]|$)|\*{3,}|x{5,}|\.{3}|^(?:none|null|undefined|true|false)$/i
|
|
87
|
+
const URL_NO_CREDS = /^[a-z][a-z0-9+.-]{1,20}:\/\/[^\s@]*$/i
|
|
88
|
+
|
|
89
|
+
/** Three or more lowercase hyphen-separated words (no hex or digit-only run of 8+, few digits), such as my-k8s-secret-name-for-database. */
|
|
90
|
+
function hyphenName(v: string): boolean {
|
|
91
|
+
const parts = v.split('-')
|
|
92
|
+
return parts.length >= 3 && parts.every(p => /^[a-z0-9]{2,}$/.test(p) && !/^[0-9a-f]{8,}$/.test(p)) && v.replace(/\D/g, '').length / v.length < 0.15
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function keyedSecret(v: string): boolean {
|
|
96
|
+
if (v.length < KEYED_MIN || v.length > 256 || /\s/.test(v)) return false
|
|
97
|
+
return !(PLACEHOLDER_WORD.test(v) || REFERENCE.test(v) || CALL.test(v) || IDENT_PATH.test(v) || URL_NO_CREDS.test(v) || (!UUID.test(v) && hyphenName(v)))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const KEY_NAME = /(?:api[_-]?key|secret|token|passw(?:or)?d|passwd|pwd|credential|private[_-]?key|auth(?!or))s?[A-Za-z0-9_-]{0,40}["']?\s*[:=]\s*/gi
|
|
101
|
+
const QUOTED = /(["'\x60])((?:(?!\1)[^\n]){1,256})\1/y
|
|
102
|
+
const BARE_VALUE = /[^\s"'\x60,;]{1,256}/y
|
|
103
|
+
const QUERY_VALUE = /[^\s"'\x60,;&]{1,256}/y
|
|
104
|
+
|
|
105
|
+
/** A secret-named key assigned a literal value: env style, JSON, YAML, code. Values that are calls, references or placeholders do not count. */
|
|
106
|
+
function assignmentSecret(text: string): boolean {
|
|
107
|
+
let valueEnd = 0
|
|
108
|
+
for (const m of text.matchAll(KEY_NAME)) {
|
|
109
|
+
if (m.index < valueEnd) continue // a key-looking word inside the previous value, such as secretsmanager in an ARN
|
|
110
|
+
const at = m.index + m[0].length
|
|
111
|
+
let back = m.index
|
|
112
|
+
while (back > 0 && m.index - back < 64 && /[A-Za-z0-9_.-]/.test(text.charAt(back - 1))) back--
|
|
113
|
+
const re = /["'\x60]/.test(text.charAt(at)) ? QUOTED : /[?&]/.test(text.charAt(back - 1)) ? QUERY_VALUE : BARE_VALUE
|
|
114
|
+
re.lastIndex = at
|
|
115
|
+
const hit = re.exec(text)
|
|
116
|
+
const v = hit && (hit[2] ?? hit[0])
|
|
117
|
+
valueEnd = hit ? at + hit[0].length : at
|
|
118
|
+
if (v && (plausibleSecret(v) || keyedSecret(v))) return true
|
|
119
|
+
}
|
|
120
|
+
return false
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A password in a URL's userinfo that is not a placeholder such as user:password or ${DB_PASSWORD}. */
|
|
124
|
+
function urlCredential(url: string): boolean {
|
|
125
|
+
const pass = /^[^:]+:\/\/[^\s:@/]+:([^\s@/]+)@/.exec(url)?.[1]
|
|
126
|
+
if (!pass || /\$\{|\{\{|%\(|%s/.test(pass)) return false
|
|
127
|
+
return !/^(?:password|passwd|pass|pwd|secret|changeme|dbpassword|db_password|\$\w*|<.*>|\{.*\}|\*+|x+)$/i.test(pass) && !PLACEHOLDER.test(pass)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const CHECKS: Readonly<Record<string, (m: RegExpMatchArray) => boolean>> = {
|
|
131
|
+
'bearer token': m => !isReference(m[1] ?? ''),
|
|
132
|
+
'database url with credentials': m => urlCredential(m[0]),
|
|
133
|
+
'database url with password': m => urlCredential(m[0]),
|
|
134
|
+
}
|
|
135
|
+
const globals = new WeakMap<RegExp, RegExp>()
|
|
136
|
+
|
|
137
|
+
function matches(name: string, re: RegExp, text: string): boolean {
|
|
138
|
+
if (name === 'key assignment') return assignmentSecret(text)
|
|
139
|
+
const check = CHECKS[name]
|
|
140
|
+
if (!check) return re.test(text)
|
|
141
|
+
let g = globals.get(re)
|
|
142
|
+
if (!g) globals.set(re, (g = new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g')))
|
|
143
|
+
for (const m of text.matchAll(g)) if (check(m)) return true
|
|
144
|
+
return false
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The text textsOf appends when it had to drop input (a node, character or per-string budget ran out). It is never matched against a rule:
|
|
149
|
+
* `names` reports it as a finding of its own, so every guard that asks "is there a secret in these texts" refuses what it could not read in full.
|
|
150
|
+
*/
|
|
151
|
+
export const TRUNCATED = 'ruflo-screen: input exceeded the screening budget'
|
|
152
|
+
export const TRUNCATED_NAME = 'input too large to screen'
|
|
153
|
+
|
|
154
|
+
/** Names of the rules that match `text` (already bare'd). A rule named 'key assignment' is judged by assignmentSecret, whatever its regex. */
|
|
155
|
+
export const names = (rules: Rules, text: string) => text === TRUNCATED ? [TRUNCATED_NAME] : rules.filter(([name, re]) => matches(name, re, text)).map(([name]) => name)
|
|
156
|
+
|
|
157
|
+
export type Findings = { readonly secrets: readonly string[]; readonly injection: readonly string[] }
|
|
158
|
+
|
|
159
|
+
/** Names of every secret shape in `secrets` and every injection phrase found in `text`. Cost is linear in the capped input. */
|
|
160
|
+
export function screenWith(secrets: Rules, text: string): Findings {
|
|
161
|
+
const bounded = bare(text)
|
|
162
|
+
return { secrets: names(secrets, bounded), injection: names(INJECTION, bounded) }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export const hasSecretIn = (secrets: Rules, text: string) => names(secrets, bare(text)).length > 0
|
|
166
|
+
|
|
167
|
+
/** Makes stored text safe to show: no control or bidi characters, whitespace collapsed, at most `max` characters. */
|
|
168
|
+
export function tidy(text: string, max: number): string {
|
|
169
|
+
const flat = text.replace(INVISIBLE, '').replace(/\s+/g, ' ').trim()
|
|
170
|
+
return flat.length > max ? `${flat.slice(0, Math.max(0, max - 1))}…` : flat
|
|
171
|
+
}
|
|
172
|
+
/** Bounds for textsOf: nodes visited, characters returned, the longest string read in full, the size of one returned chunk, chunk overlap. */
|
|
173
|
+
export type TextLimits = { readonly nodes?: number; readonly chars?: number; readonly perString?: number }
|
|
174
|
+
const NODES = 20_000
|
|
175
|
+
const CHARS = 2_000_000
|
|
176
|
+
const PER_STRING = 1_500_000
|
|
177
|
+
const OVERLAP = 2_048
|
|
178
|
+
const BARE_KEY_MIN = 8
|
|
179
|
+
|
|
180
|
+
/** A string as texts the screen can read whole: one text up to MAX_SCAN, else overlapping MAX_SCAN windows so a secret anywhere is inside one. */
|
|
181
|
+
function windows(text: string, out: string[]): void {
|
|
182
|
+
if (text.length <= MAX_SCAN) {
|
|
183
|
+
out.push(text)
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
for (let at = 0; ; at += MAX_SCAN - OVERLAP) {
|
|
187
|
+
out.push(text.slice(at, at + MAX_SCAN))
|
|
188
|
+
if (at + MAX_SCAN >= text.length) return
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Every string in a tool input, for the screen to read: iterative (no recursion, so nesting 5000 deep cannot overflow the stack) and
|
|
194
|
+
* breadth-first (siblings before depth, so a long list cannot hide a nested value). A string under an object key comes back as `key=value`,
|
|
195
|
+
* so a secret-named key is judged with its value; a key whose value is not a string is returned bare. Strings longer than the screen window
|
|
196
|
+
* come back as overlapping windows; one over `perString` keeps its head and tail. Work is bounded by `nodes` slots and `chars` characters.
|
|
197
|
+
* Anything dropped (slots or characters ran out, or a string lost its middle) is reported by a final TRUNCATED text, which `names` and
|
|
198
|
+
* `hasSecretIn` count as a finding, so the screen fails closed instead of passing what it did not read.
|
|
199
|
+
*/
|
|
200
|
+
export function textsOf(input: unknown, limits: TextLimits = {}): string[] {
|
|
201
|
+
const out: string[] = []
|
|
202
|
+
let slots = limits.nodes ?? NODES
|
|
203
|
+
let chars = limits.chars ?? CHARS
|
|
204
|
+
const perString = limits.perString ?? PER_STRING
|
|
205
|
+
let truncated = false
|
|
206
|
+
const take = (text: string): void => {
|
|
207
|
+
if (chars <= 0) {
|
|
208
|
+
truncated = true
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
if (text.length <= Math.min(perString, chars)) {
|
|
212
|
+
chars -= text.length
|
|
213
|
+
windows(text, out)
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
truncated = true
|
|
217
|
+
const half = Math.floor(Math.min(perString, chars) / 2)
|
|
218
|
+
chars -= 2 * half
|
|
219
|
+
windows(text.slice(0, half), out)
|
|
220
|
+
windows(text.slice(-half), out)
|
|
221
|
+
}
|
|
222
|
+
const queue: unknown[] = [input]
|
|
223
|
+
let head = 0
|
|
224
|
+
for (; head < queue.length && chars > 0; head++) {
|
|
225
|
+
const node = queue[head]
|
|
226
|
+
if (typeof node === 'string') take(node)
|
|
227
|
+
else if (Array.isArray(node)) {
|
|
228
|
+
let i = 0
|
|
229
|
+
for (; i < node.length && slots > 0; i++, slots--) if (i in node) queue.push(node[i])
|
|
230
|
+
if (i < node.length) truncated = true
|
|
231
|
+
} else if (typeof node === 'object' && node !== null) {
|
|
232
|
+
for (const k in node) {
|
|
233
|
+
if (slots-- <= 0) {
|
|
234
|
+
truncated = true
|
|
235
|
+
break
|
|
236
|
+
}
|
|
237
|
+
if (!Object.prototype.hasOwnProperty.call(node, k)) continue
|
|
238
|
+
const v = (node as Record<string, unknown>)[k]
|
|
239
|
+
if (typeof v === 'string') queue.push(k + '=' + v)
|
|
240
|
+
else {
|
|
241
|
+
if (k.length >= BARE_KEY_MIN) take(k)
|
|
242
|
+
queue.push(v)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (queue.length > head) truncated = true
|
|
248
|
+
if (truncated) out.push(TRUNCATED)
|
|
249
|
+
return out
|
|
250
|
+
}
|
|
251
|
+
// END SHARED SCREEN
|
|
252
|
+
|
|
253
|
+
const SECRETS: Rules = [
|
|
254
|
+
...COMMON_SECRETS,
|
|
255
|
+
['key assignment', /\b(?:api[_-]?key|secret|token|passw(?:or)?d|credential)s?["']?\s*[:=]\s*["']?[A-Za-z0-9/+=_.-]{16,}/i],
|
|
256
|
+
]
|
|
257
|
+
|
|
258
|
+
export const scan = (text: string): Findings => screenWith(SECRETS, text)
|
|
259
|
+
|
|
260
|
+
export const hasSecret = (text: string) => hasSecretIn(SECRETS, text)
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
// node scripts/bench-parse-mcp-scan.mjs # default 100k iters
|
|
19
19
|
// node scripts/bench-parse-mcp-scan.mjs --iters 1000000
|
|
20
20
|
// node scripts/bench-parse-mcp-scan.mjs --format json
|
|
21
|
-
// node scripts/bench-parse-mcp-scan.mjs --max-mean-us 5 # CI gate
|
|
21
|
+
// node scripts/bench-parse-mcp-scan.mjs --max-mean-us 5 # CI gate (on the mean)
|
|
22
|
+
// node scripts/bench-parse-mcp-scan.mjs --max-mean-us 5 --gate-stat p50 # same ceiling on the median: robust to a loaded runner
|
|
22
23
|
//
|
|
23
24
|
// EXIT CODES
|
|
24
25
|
// 0 ok (or threshold satisfied)
|
|
@@ -33,15 +34,21 @@ guardCliArgs(import.meta.url, {
|
|
|
33
34
|
'--iters': 'value',
|
|
34
35
|
'--format': 'value',
|
|
35
36
|
'--max-mean-us': 'value',
|
|
37
|
+
'--gate-stat': 'value',
|
|
36
38
|
});
|
|
37
39
|
|
|
38
40
|
const ARGS = (() => {
|
|
39
|
-
const a = { iters: 100_000, format: 'table', maxMeanUs: null };
|
|
41
|
+
const a = { iters: 100_000, format: 'table', maxMeanUs: null, gateStat: 'mean' };
|
|
40
42
|
for (let i = 2; i < process.argv.length; i++) {
|
|
41
43
|
const v = process.argv[i];
|
|
42
44
|
if (v === '--iters') a.iters = parseInt(process.argv[++i], 10);
|
|
43
45
|
else if (v === '--format') a.format = process.argv[++i];
|
|
44
46
|
else if (v === '--max-mean-us') a.maxMeanUs = parseFloat(process.argv[++i]);
|
|
47
|
+
else if (v === '--gate-stat') a.gateStat = process.argv[++i];
|
|
48
|
+
}
|
|
49
|
+
if (a.gateStat !== 'mean' && a.gateStat !== 'p50') {
|
|
50
|
+
console.error(`bench-parse-mcp-scan: --gate-stat must be mean or p50; got ${a.gateStat}`);
|
|
51
|
+
process.exit(2);
|
|
45
52
|
}
|
|
46
53
|
return a;
|
|
47
54
|
})();
|
|
@@ -113,9 +120,11 @@ const results = [
|
|
|
113
120
|
let gate = { triggered: false, reasons: [] };
|
|
114
121
|
if (ARGS.maxMeanUs != null) {
|
|
115
122
|
for (const r of results) {
|
|
116
|
-
|
|
123
|
+
// p50 is the typical-call cost: one scheduler pause on a loaded CI runner moves the mean (iter 86 gate flaked on it) but not the median.
|
|
124
|
+
const value = ARGS.gateStat === 'p50' ? r.p50Ms * 1000 : r.meanUs;
|
|
125
|
+
if (value > ARGS.maxMeanUs) {
|
|
117
126
|
gate.triggered = true;
|
|
118
|
-
gate.reasons.push(`${r.label}:
|
|
127
|
+
gate.reasons.push(`${r.label}: ${ARGS.gateStat} ${value.toFixed(3)}μs > ceiling ${ARGS.maxMeanUs}μs`);
|
|
119
128
|
}
|
|
120
129
|
}
|
|
121
130
|
}
|