@claude-flow/cli 3.51.1 → 3.52.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/helpers/helpers.manifest.json +3 -3
- package/.claude/helpers/hook-handler.cjs +74 -4
- 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
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/** Explicit export only: bind the compiled snapshots to immutable Git blobs. */
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { constants } from 'node:fs';
|
|
4
|
+
import { lstat, open, realpath } from 'node:fs/promises';
|
|
5
|
+
import { devNull } from 'node:os';
|
|
6
|
+
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
import { promisify } from 'node:util';
|
|
9
|
+
import { safeGitArgv } from '@claude-flow/security/safe-git';
|
|
10
|
+
const run = promisify(execFile);
|
|
11
|
+
const OID = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
|
|
12
|
+
export const MAX_GUIDANCE_SOURCE_BYTES = 1024 * 1024;
|
|
13
|
+
/** Local reads only; no shell, Git hooks, filters, replacement refs or lazy fetch. */
|
|
14
|
+
async function git(repo, args) {
|
|
15
|
+
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.toUpperCase().startsWith('GIT_')));
|
|
16
|
+
Object.assign(env, { GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: devNull, GIT_NO_LAZY_FETCH: '1', GIT_ALLOW_PROTOCOL: '', GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0' });
|
|
17
|
+
const { stdout } = await run('git', safeGitArgv(repo, ['--no-replace-objects', '--literal-pathspecs', '-c', `core.hooksPath=${devNull}`, '-c', 'protocol.allow=never', ...args]), {
|
|
18
|
+
encoding: 'buffer', env, maxBuffer: 2 * MAX_GUIDANCE_SOURCE_BYTES, timeout: 10_000, windowsHide: true,
|
|
19
|
+
});
|
|
20
|
+
return stdout;
|
|
21
|
+
}
|
|
22
|
+
/** Older Git ignores GIT_NO_LAZY_FETCH. Refuse all promisor configuration
|
|
23
|
+
* before reading objects, including repositories whose objects happen to exist.
|
|
24
|
+
* Shallow clones with ordinary local objects remain supported. */
|
|
25
|
+
async function requireLocalObjects(repo) {
|
|
26
|
+
try {
|
|
27
|
+
await git(repo, ['config', '--get-regexp', '^(extensions\\.partialclone|remote\\..*\\.(promisor|partialclonefilter))$']);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
const result = error;
|
|
31
|
+
// git config returns 1 only for no matching entries. Other errors fail closed.
|
|
32
|
+
if (result.code === 1 && !result.signal && result.stdout?.length === 0 && result.stderr?.length === 0)
|
|
33
|
+
return;
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
throw new Error('Offline mod export does not support partial clones or promisor repositories; use a checkout with local objects');
|
|
37
|
+
}
|
|
38
|
+
async function sourcePath(path) {
|
|
39
|
+
const absolute = resolve(path);
|
|
40
|
+
if (/[\u0000-\u001f\u007f]/.test(absolute))
|
|
41
|
+
throw new Error('Unsupported guidance source path');
|
|
42
|
+
const stat = await lstat(absolute);
|
|
43
|
+
if (!stat.isFile() || stat.isSymbolicLink() || await realpath(absolute) !== absolute)
|
|
44
|
+
throw new Error('Guidance source must be a regular file without symlink components');
|
|
45
|
+
return absolute;
|
|
46
|
+
}
|
|
47
|
+
/** Descriptor based bounded read; never reread mutable bytes after verification. */
|
|
48
|
+
async function sourceBytes(path) {
|
|
49
|
+
const file = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|
|
50
|
+
try {
|
|
51
|
+
const stat = await file.stat();
|
|
52
|
+
if (!stat.isFile() || stat.size > MAX_GUIDANCE_SOURCE_BYTES)
|
|
53
|
+
throw new Error('Guidance source exceeds 1 MiB or is not a regular file');
|
|
54
|
+
const bytes = Buffer.alloc(MAX_GUIDANCE_SOURCE_BYTES + 1);
|
|
55
|
+
let size = 0;
|
|
56
|
+
while (size < bytes.length) {
|
|
57
|
+
const { bytesRead } = await file.read(bytes, size, bytes.length - size, null);
|
|
58
|
+
if (!bytesRead)
|
|
59
|
+
break;
|
|
60
|
+
size += bytesRead;
|
|
61
|
+
}
|
|
62
|
+
if (size > MAX_GUIDANCE_SOURCE_BYTES)
|
|
63
|
+
throw new Error('Guidance source exceeds 1 MiB');
|
|
64
|
+
return bytes.subarray(0, size);
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
await file.close();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** A commit label alone is insufficient. Both supplied files must match its blobs. */
|
|
71
|
+
export async function readModGuidanceSources(input) {
|
|
72
|
+
if (!OID.test(input.revision))
|
|
73
|
+
throw new Error('--revision must be a full immutable source commit SHA');
|
|
74
|
+
const rootPath = await sourcePath(input.rootPath);
|
|
75
|
+
let repo;
|
|
76
|
+
try {
|
|
77
|
+
repo = (await git(dirname(rootPath), ['rev-parse', '--show-toplevel'])).toString('utf8').replace(/\r?\n$/, '');
|
|
78
|
+
repo = await realpath(repo);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
throw new Error('Guidance source requires a local Git repository');
|
|
82
|
+
}
|
|
83
|
+
await requireLocalObjects(repo);
|
|
84
|
+
try {
|
|
85
|
+
if ((await git(repo, ['cat-file', '-t', input.revision])).toString('utf8').trim() !== 'commit')
|
|
86
|
+
throw new Error('not a commit');
|
|
87
|
+
if ((await git(repo, ['rev-parse', '--verify', `${input.revision}^{commit}`])).toString('utf8').trim() !== input.revision)
|
|
88
|
+
throw new Error('abbreviated commit');
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
throw new Error('Guidance source requires an existing full immutable commit');
|
|
92
|
+
}
|
|
93
|
+
const read = async (path) => {
|
|
94
|
+
const absolute = await sourcePath(path);
|
|
95
|
+
const rel = relative(repo, absolute);
|
|
96
|
+
if (!rel || isAbsolute(rel) || rel === '..' || rel.startsWith(`..${sep}`))
|
|
97
|
+
throw new Error('Guidance sources must belong to the same Git repository');
|
|
98
|
+
const sourceRepo = (await git(dirname(absolute), ['rev-parse', '--show-toplevel'])).toString('utf8').replace(/\r?\n$/, '');
|
|
99
|
+
if (await realpath(sourceRepo) !== repo)
|
|
100
|
+
throw new Error('Guidance sources must belong to the same Git repository');
|
|
101
|
+
await requireLocalObjects(repo);
|
|
102
|
+
const gitPath = rel.split(sep).join('/');
|
|
103
|
+
const tree = (await git(repo, ['ls-tree', '-z', '--full-tree', input.revision, '--', gitPath])).toString('utf8');
|
|
104
|
+
const match = /^(100644|100755) blob ([a-f0-9]{40}|[a-f0-9]{64})\t([^\0]+)\0$/.exec(tree);
|
|
105
|
+
if (!match || match[3] !== gitPath)
|
|
106
|
+
throw new Error('Guidance source must be a regular tracked blob in the supplied commit');
|
|
107
|
+
const blobId = match[2];
|
|
108
|
+
const blobSize = Number((await git(repo, ['cat-file', '-s', blobId])).toString('utf8').trim());
|
|
109
|
+
if (!Number.isSafeInteger(blobSize) || blobSize < 0 || blobSize > MAX_GUIDANCE_SOURCE_BYTES)
|
|
110
|
+
throw new Error('Committed guidance source exceeds 1 MiB');
|
|
111
|
+
const committed = await git(repo, ['cat-file', 'blob', blobId]);
|
|
112
|
+
const bytes = await sourceBytes(absolute);
|
|
113
|
+
if (!bytes.equals(committed))
|
|
114
|
+
throw new Error('Guidance source bytes differ from the supplied commit; commit the reviewed source before exporting');
|
|
115
|
+
// Preserve a BOM in the compiler snapshot; reject invalid UTF-8 rather than replacing bytes.
|
|
116
|
+
const content = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
117
|
+
return { content, binding: { path: gitPath, blobId, sha256: createHash('sha256').update(bytes).digest('hex'), byteLength: bytes.length } };
|
|
118
|
+
};
|
|
119
|
+
const root = await read(rootPath);
|
|
120
|
+
const local = input.localPath === undefined ? undefined : await read(input.localPath);
|
|
121
|
+
return { sourceRevision: input.revision, rootContent: root.content, localContent: local?.content, sources: { root: root.binding, ...(local ? { local: local.binding } : {}) } };
|
|
122
|
+
}
|
|
123
|
+
//# sourceMappingURL=mod-sources.js.map
|
|
@@ -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.1",
|
|
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
|
+
}
|