Skip to content

Commit ea1d4ce

Browse files
feat(loop-audit): score least-privilege, stall detection, and escalation signals (#233)
loop-audit already scores worktree isolation, budget, and maker/checker, but the multi-agent safety rubric has three HIGH-severity checks it never surfaced: tool/MCP scope (least privilege), stall / no-progress detection, and an explicit human-escalation path. This adds them as a governance signal group. - toolScope: allowed-tools in SKILL.md frontmatter, or least-privilege / tool scope wording in LOOP.md / safety docs / loop-constraints.md - stallDetection: loop-context skill, a ledger file, or circuit-breaker / max-attempts / no-progress wording - escalation: escalate / hand-off / human-in-the-loop / exit code 2 wording Each contributes 3 points and emits an ok/warn finding with a recommendation. Hard L2/L3 gates are unchanged, so existing scores only move when a project actually documents these practices. Adds 3 tests; README and CLI help updated; version 1.5.3 to 1.6.0. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 696d5c4 commit ea1d4ce

8 files changed

Lines changed: 293 additions & 3 deletions

File tree

‎tools/loop-audit/README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ npm publish --access public
7070
| loop-run-log.md | Append-only run history |
7171
| LOOP.md budget section | Cadence limits documented in config |
7272
| loop-budget skill | Runtime budget guard |
73+
| Least-privilege tool scope | `allowed-tools` in SKILL.md, or documented tool/MCP scopes (agents get only what their role needs) |
74+
| Stall / no-progress detection | loop-context circuit breaker, a ledger, or a documented max-attempts / no-progress rule |
75+
| Human-escalation path | LOOP.md / safety docs define when to stop and hand off to a human |
7376
| **loopActivity (v1.4)** | **Dynamic proof**: "Last run" timestamps in state, loop-related git commits, scheduled workflows, run logs |
7477

7578
L3 requires verifier + state + cost observability (budget + run log + LOOP.md budget) **and** proven loop activity (not just files on disk).

‎tools/loop-audit/dist/auditor.d.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ export interface LoopSignals {
4949
loopMdBudget: boolean;
5050
budgetSkill: boolean;
5151
};
52+
governance: {
53+
toolScope: boolean;
54+
stallDetection: boolean;
55+
escalation: boolean;
56+
};
5257
constraints: {
5358
present: boolean;
5459
hasConstraintsSkill: boolean;

‎tools/loop-audit/dist/auditor.js‎

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ const SCORE_WEIGHTS = {
3131
runLog: 3,
3232
loopMdBudget: 2,
3333
budgetSkill: 2,
34+
toolScope: 3,
35+
stallDetection: 3,
36+
escalation: 3,
3437
constraintsFile: 4,
3538
constraintsSkill: 2,
3639
loopActivity: 6,
@@ -58,6 +61,36 @@ const SAFETY_FILES = ['safety.md', 'docs/safety.md', 'SECURITY.md'];
5861
const MCP_FILES = ['.mcp.json', 'mcp.json', '.mcp/config.json'];
5962
const WORKTREE_HINTS = ['worktree', 'worktrees', 'git worktree'];
6063
const BUDGET_HINTS = [/budget/i, /max tokens/i, /token cap/i, /kill switch/i, /loop-pause-all/i];
64+
// Governance signals (multi-agent safety rubric): least-privilege tool scope,
65+
// stall / no-progress detection, and an explicit human-escalation path.
66+
const TOOL_SCOPE_HINTS = [
67+
/least[- ]privilege/i,
68+
/tool scope/i,
69+
/scoped tools?/i,
70+
/allow-?list/i,
71+
/read-only tools?/i,
72+
/permission scope/i,
73+
];
74+
const STALL_HINTS = [
75+
/loop-context/i,
76+
/circuit breaker/i,
77+
/max attempts/i,
78+
/no[- ]progress/i,
79+
/\bstall(ed|s|ing)?\b/i,
80+
/\bstuck\b/i,
81+
/same error/i,
82+
];
83+
const ESCALATION_HINTS = [
84+
/escalat/i,
85+
/hand[- ]?off/i,
86+
/human[- ]in[- ]the[- ]loop/i,
87+
/\bHITL\b/i,
88+
/human review/i,
89+
/needs? human/i,
90+
/stop and ask/i,
91+
/exit code 2/i,
92+
/\bexit 2\b/i,
93+
];
6194
async function fileExists(p) {
6295
try {
6396
await stat(p);
@@ -233,6 +266,12 @@ export function computeScore(signals) {
233266
score += w.loopMdBudget;
234267
if (signals.cost.budgetSkill)
235268
score += w.budgetSkill;
269+
if (signals.governance.toolScope)
270+
score += w.toolScope;
271+
if (signals.governance.stallDetection)
272+
score += w.stallDetection;
273+
if (signals.governance.escalation)
274+
score += w.escalation;
236275
if (signals.constraints.present)
237276
score += w.constraintsFile;
238277
if (signals.constraints.hasConstraintsSkill)
@@ -364,6 +403,60 @@ export async function auditProject(target) {
364403
break;
365404
}
366405
}
406+
// Governance corpus: docs where scope / stall / escalation rules are written.
407+
let governanceCorpus = loopMdContent;
408+
for (const f of ['docs/safety.md', 'safety.md', 'SECURITY.md', 'loop-constraints.md']) {
409+
const p = path.join(root, f);
410+
if (await fileExists(p)) {
411+
try {
412+
governanceCorpus += '\n' + (await readFile(p, 'utf8'));
413+
}
414+
catch { }
415+
}
416+
}
417+
// allowed-tools frontmatter in any SKILL.md is a concrete least-privilege signal.
418+
let skillDeclaresTools = false;
419+
const skillScanDirs = [
420+
path.join(root, '.grok', 'skills'),
421+
path.join(root, '.claude', 'skills'),
422+
path.join(root, '.codex', 'skills'),
423+
path.join(root, 'skills'),
424+
];
425+
for (const dir of skillScanDirs) {
426+
if (skillDeclaresTools)
427+
break;
428+
if (!(await fileExists(dir)))
429+
continue;
430+
try {
431+
const entries = await readdir(dir, { withFileTypes: true });
432+
for (const e of entries) {
433+
if (!e.isDirectory())
434+
continue;
435+
const sp = path.join(dir, e.name, 'SKILL.md');
436+
if (await fileExists(sp)) {
437+
const txt = await readFile(sp, 'utf8');
438+
if (/allowed-tools\s*:/i.test(txt)) {
439+
skillDeclaresTools = true;
440+
break;
441+
}
442+
}
443+
}
444+
}
445+
catch { }
446+
}
447+
const toolScope = skillDeclaresTools || TOOL_SCOPE_HINTS.some((re) => re.test(governanceCorpus));
448+
// loop-context is this repo's circuit breaker; its ledger is direct proof of stall handling.
449+
let ledgerPresent = false;
450+
try {
451+
const rootEntries = await readdir(root, { withFileTypes: true });
452+
ledgerPresent = rootEntries.some((e) => e.isFile() && /ledger/i.test(e.name));
453+
}
454+
catch { }
455+
const stallDetection = skillNames.includes('loop-context') ||
456+
skillNames.includes('loop-guard') ||
457+
ledgerPresent ||
458+
STALL_HINTS.some((re) => re.test(governanceCorpus));
459+
const escalation = ESCALATION_HINTS.some((re) => re.test(governanceCorpus));
367460
const signals = {
368461
stateFile: { present: statePaths.length > 0, paths: statePaths },
369462
loopConfig: { present: loopMd, path: loopMd ? 'LOOP.md' : undefined },
@@ -380,6 +473,7 @@ export async function auditProject(target) {
380473
worktreeEvidence: { present: worktreeEvidence },
381474
registry: { present: registryPresent },
382475
cost: { budgetDoc, runLog, loopMdBudget, budgetSkill },
476+
governance: { toolScope, stallDetection, escalation },
383477
loopActivity,
384478
};
385479
if (!signals.stateFile.present) {
@@ -481,6 +575,27 @@ export async function auditProject(target) {
481575
else {
482576
findings.push({ level: 'ok', message: 'loop-budget skill present.' });
483577
}
578+
if (!signals.governance.toolScope) {
579+
findings.push({ level: 'warn', message: 'No least-privilege tool scope — skills/agents may hold broader tool or MCP access than their role needs.' });
580+
recommendations.push('Declare allowed-tools in SKILL.md frontmatter, or document tool/MCP scopes in docs/safety.md (least privilege per role)');
581+
}
582+
else {
583+
findings.push({ level: 'ok', message: 'Tool/MCP scope constrained (least-privilege signal present).' });
584+
}
585+
if (!signals.governance.stallDetection) {
586+
findings.push({ level: 'warn', message: 'No stall / no-progress detection — a stuck loop can repeat the same failing action instead of escalating.' });
587+
recommendations.push('Add loop-context (circuit breaker) or a max-attempts / no-progress rule in LOOP.md that escalates instead of looping');
588+
}
589+
else {
590+
findings.push({ level: 'ok', message: 'Stall / no-progress detection present (circuit breaker or documented rule).' });
591+
}
592+
if (!signals.governance.escalation) {
593+
findings.push({ level: 'warn', message: 'No explicit human-escalation path — the loop has no defined hand-off when stuck or out of scope.' });
594+
recommendations.push('Document an escalation path in LOOP.md (when to stop and hand to a human; e.g. loop-context exit code 2)');
595+
}
596+
else {
597+
findings.push({ level: 'ok', message: 'Human-escalation path documented.' });
598+
}
484599
if (!signals.loopActivity.present) {
485600
findings.push({ level: 'warn', message: 'No evidence of actual loop runs detected (no "Last run" entries in state, loop-related git activity, or scheduled workflows yet).' });
486601
recommendations.push('Run one loop (report-only), update + commit STATE.md (or pattern state). This turns structure into proven usage.');

‎tools/loop-audit/dist/cli.js‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ const suggest = args.includes('--suggest') || args.includes('--fix');
1010
const badge = args.includes('--badge');
1111
const help = args.includes('--help') || args.includes('-h');
1212
if (help) {
13-
console.log(`loop-audit — Loop Readiness Score CLI (v1.4+)
13+
console.log(`loop-audit — Loop Readiness Score CLI (v1.6+)
1414
1515
Usage:
1616
loop-audit [path] [options]
@@ -22,6 +22,9 @@ Options:
2222
--badge Markdown README badge (Loop Ready level + score)
2323
--help, -h This help
2424
25+
New in v1.6:
26+
• Governance signals: least-privilege tool scope, stall / no-progress detection, human-escalation path
27+
2528
New in v1.4:
2629
• Dynamic "loop activity" detection (git history, "Last run" in STATE, scheduled workflows)
2730
• Higher L3 bar requires proven usage, not just files

‎tools/loop-audit/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@cobusgreyling/loop-audit",
3-
"version": "1.5.3",
3+
"version": "1.6.0",
44
"description": "Loop engineering readiness auditor. Compute Loop Readiness Score (L0-L3), detect activity, and get actionable suggestions. npx @cobusgreyling/loop-audit . --suggest",
55
"type": "module",
66
"bin": {

‎tools/loop-audit/src/auditor.ts‎

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ export interface LoopSignals {
2222
loopMdBudget: boolean;
2323
budgetSkill: boolean;
2424
};
25+
governance: {
26+
toolScope: boolean;
27+
stallDetection: boolean;
28+
escalation: boolean;
29+
};
2530
constraints: { present: boolean; hasConstraintsSkill: boolean };
2631
loopActivity: { present: boolean; evidence: string[] };
2732
}
@@ -72,6 +77,9 @@ const SCORE_WEIGHTS = {
7277
runLog: 3,
7378
loopMdBudget: 2,
7479
budgetSkill: 2,
80+
toolScope: 3,
81+
stallDetection: 3,
82+
escalation: 3,
7583
constraintsFile: 4,
7684
constraintsSkill: 2,
7785
loopActivity: 6,
@@ -103,6 +111,37 @@ const MCP_FILES = ['.mcp.json', 'mcp.json', '.mcp/config.json'];
103111
const WORKTREE_HINTS = ['worktree', 'worktrees', 'git worktree'];
104112
const BUDGET_HINTS = [/budget/i, /max tokens/i, /token cap/i, /kill switch/i, /loop-pause-all/i];
105113

114+
// Governance signals (multi-agent safety rubric): least-privilege tool scope,
115+
// stall / no-progress detection, and an explicit human-escalation path.
116+
const TOOL_SCOPE_HINTS = [
117+
/least[- ]privilege/i,
118+
/tool scope/i,
119+
/scoped tools?/i,
120+
/allow-?list/i,
121+
/read-only tools?/i,
122+
/permission scope/i,
123+
];
124+
const STALL_HINTS = [
125+
/loop-context/i,
126+
/circuit breaker/i,
127+
/max attempts/i,
128+
/no[- ]progress/i,
129+
/\bstall(ed|s|ing)?\b/i,
130+
/\bstuck\b/i,
131+
/same error/i,
132+
];
133+
const ESCALATION_HINTS = [
134+
/escalat/i,
135+
/hand[- ]?off/i,
136+
/human[- ]in[- ]the[- ]loop/i,
137+
/\bHITL\b/i,
138+
/human review/i,
139+
/needs? human/i,
140+
/stop and ask/i,
141+
/exit code 2/i,
142+
/\bexit 2\b/i,
143+
];
144+
106145
async function fileExists(p: string): Promise<boolean> {
107146
try {
108147
await stat(p);
@@ -259,6 +298,9 @@ export function computeScore(signals: LoopSignals): { score: number; level: 'L0'
259298
if (signals.cost.runLog) score += w.runLog;
260299
if (signals.cost.loopMdBudget) score += w.loopMdBudget;
261300
if (signals.cost.budgetSkill) score += w.budgetSkill;
301+
if (signals.governance.toolScope) score += w.toolScope;
302+
if (signals.governance.stallDetection) score += w.stallDetection;
303+
if (signals.governance.escalation) score += w.escalation;
262304
if (signals.constraints.present) score += w.constraintsFile;
263305
if (signals.constraints.hasConstraintsSkill) score += w.constraintsSkill;
264306
if (signals.loopActivity.present) score += w.loopActivity;
@@ -397,6 +439,60 @@ export async function auditProject(target: string): Promise<AuditResult> {
397439
}
398440
}
399441

442+
// Governance corpus: docs where scope / stall / escalation rules are written.
443+
let governanceCorpus = loopMdContent;
444+
for (const f of ['docs/safety.md', 'safety.md', 'SECURITY.md', 'loop-constraints.md']) {
445+
const p = path.join(root, f);
446+
if (await fileExists(p)) {
447+
try {
448+
governanceCorpus += '\n' + (await readFile(p, 'utf8'));
449+
} catch {}
450+
}
451+
}
452+
453+
// allowed-tools frontmatter in any SKILL.md is a concrete least-privilege signal.
454+
let skillDeclaresTools = false;
455+
const skillScanDirs = [
456+
path.join(root, '.grok', 'skills'),
457+
path.join(root, '.claude', 'skills'),
458+
path.join(root, '.codex', 'skills'),
459+
path.join(root, 'skills'),
460+
];
461+
for (const dir of skillScanDirs) {
462+
if (skillDeclaresTools) break;
463+
if (!(await fileExists(dir))) continue;
464+
try {
465+
const entries = await readdir(dir, { withFileTypes: true });
466+
for (const e of entries) {
467+
if (!e.isDirectory()) continue;
468+
const sp = path.join(dir, e.name, 'SKILL.md');
469+
if (await fileExists(sp)) {
470+
const txt = await readFile(sp, 'utf8');
471+
if (/allowed-tools\s*:/i.test(txt)) {
472+
skillDeclaresTools = true;
473+
break;
474+
}
475+
}
476+
}
477+
} catch {}
478+
}
479+
480+
const toolScope = skillDeclaresTools || TOOL_SCOPE_HINTS.some((re) => re.test(governanceCorpus));
481+
482+
// loop-context is this repo's circuit breaker; its ledger is direct proof of stall handling.
483+
let ledgerPresent = false;
484+
try {
485+
const rootEntries = await readdir(root, { withFileTypes: true });
486+
ledgerPresent = rootEntries.some((e) => e.isFile() && /ledger/i.test(e.name));
487+
} catch {}
488+
const stallDetection =
489+
skillNames.includes('loop-context') ||
490+
skillNames.includes('loop-guard') ||
491+
ledgerPresent ||
492+
STALL_HINTS.some((re) => re.test(governanceCorpus));
493+
494+
const escalation = ESCALATION_HINTS.some((re) => re.test(governanceCorpus));
495+
400496
const signals: LoopSignals = {
401497
stateFile: { present: statePaths.length > 0, paths: statePaths },
402498
loopConfig: { present: loopMd, path: loopMd ? 'LOOP.md' : undefined },
@@ -413,6 +509,7 @@ export async function auditProject(target: string): Promise<AuditResult> {
413509
worktreeEvidence: { present: worktreeEvidence },
414510
registry: { present: registryPresent },
415511
cost: { budgetDoc, runLog, loopMdBudget, budgetSkill },
512+
governance: { toolScope, stallDetection, escalation },
416513
loopActivity,
417514
};
418515

@@ -522,6 +619,27 @@ export async function auditProject(target: string): Promise<AuditResult> {
522619
findings.push({ level: 'ok', message: 'loop-budget skill present.' });
523620
}
524621

622+
if (!signals.governance.toolScope) {
623+
findings.push({ level: 'warn', message: 'No least-privilege tool scope — skills/agents may hold broader tool or MCP access than their role needs.' });
624+
recommendations.push('Declare allowed-tools in SKILL.md frontmatter, or document tool/MCP scopes in docs/safety.md (least privilege per role)');
625+
} else {
626+
findings.push({ level: 'ok', message: 'Tool/MCP scope constrained (least-privilege signal present).' });
627+
}
628+
629+
if (!signals.governance.stallDetection) {
630+
findings.push({ level: 'warn', message: 'No stall / no-progress detection — a stuck loop can repeat the same failing action instead of escalating.' });
631+
recommendations.push('Add loop-context (circuit breaker) or a max-attempts / no-progress rule in LOOP.md that escalates instead of looping');
632+
} else {
633+
findings.push({ level: 'ok', message: 'Stall / no-progress detection present (circuit breaker or documented rule).' });
634+
}
635+
636+
if (!signals.governance.escalation) {
637+
findings.push({ level: 'warn', message: 'No explicit human-escalation path — the loop has no defined hand-off when stuck or out of scope.' });
638+
recommendations.push('Document an escalation path in LOOP.md (when to stop and hand to a human; e.g. loop-context exit code 2)');
639+
} else {
640+
findings.push({ level: 'ok', message: 'Human-escalation path documented.' });
641+
}
642+
525643
if (!signals.loopActivity.present) {
526644
findings.push({ level: 'warn', message: 'No evidence of actual loop runs detected (no "Last run" entries in state, loop-related git activity, or scheduled workflows yet).' });
527645
recommendations.push('Run one loop (report-only), update + commit STATE.md (or pattern state). This turns structure into proven usage.');

‎tools/loop-audit/src/cli.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const badge = args.includes('--badge');
1212
const help = args.includes('--help') || args.includes('-h');
1313

1414
if (help) {
15-
console.log(`loop-audit — Loop Readiness Score CLI (v1.4+)
15+
console.log(`loop-audit — Loop Readiness Score CLI (v1.6+)
1616
1717
Usage:
1818
loop-audit [path] [options]
@@ -24,6 +24,9 @@ Options:
2424
--badge Markdown README badge (Loop Ready level + score)
2525
--help, -h This help
2626
27+
New in v1.6:
28+
• Governance signals: least-privilege tool scope, stall / no-progress detection, human-escalation path
29+
2730
New in v1.4:
2831
• Dynamic "loop activity" detection (git history, "Last run" in STATE, scheduled workflows)
2932
• Higher L3 bar requires proven usage, not just files

0 commit comments

Comments
 (0)