feat(#767): inject disallowedTools deny-list into Claude read-only agents (#1081)

Install-time injection of a disallowedTools deny-list into Claude copies of read-only verifier/auditor agents (mirrors the #443 effort injection); source agents stay runtime-neutral so Gemini/Qwen/Hermes are unaffected. Closes #767.
This commit is contained in:
Tom Boucher
2026-06-11 22:25:43 -04:00
committed by GitHub
parent ce0ec808fb
commit e644819d59
4 changed files with 290 additions and 0 deletions

View File

@@ -1209,6 +1209,52 @@ function injectEffortFrontmatter(content, effortValue) {
return `${before}effort: ${effortValue}${eol}${after}`;
}
/**
* #767 — Inject `disallowedTools: <value>` into the YAML frontmatter of a Claude .md agent.
* Mirrors injectEffortFrontmatter: idempotent (skips if disallowedTools: already present),
* inserts immediately before the closing `---`. Claude-only — never call for other runtimes,
* which break on unknown frontmatter keys.
*/
function injectDisallowedToolsFrontmatter(content, disallowedValue) {
// Detect the dominant EOL from the first line (the opening `---`).
// If the very first `---` is followed by \r\n, treat the whole file as CRLF.
const eol = /^---\r\n/.test(content) ? '\r\n' : '\n';
// Build a frontmatter-matching regex that tolerates an optional \r before
// each \n, so we handle both LF and CRLF files without needing to normalise
// the whole content.
const fmRe = /^---\r?\n([\s\S]*?)^---\r?$/m;
const match = fmRe.exec(content);
if (!match) return content; // no YAML frontmatter — leave unchanged
// Idempotency guard: don't insert a second disallowedTools: line.
const fmBody = match[1]; // content between the two `---` lines
if (/^disallowedTools:/m.test(fmBody)) return content;
// Locate the exact position of the closing `---` line so we can insert
// before it using a simple string splice.
const openLen = 3 + eol.length; // "---" + eol
const closingStart = match.index + openLen + fmBody.length;
const before = content.slice(0, closingStart);
const after = content.slice(closingStart);
return `${before}disallowedTools: ${disallowedValue}${eol}${after}`;
}
// #767 — Read-only verifier/auditor agents get a Claude-Code disallowedTools deny-list.
// Group A (pure read-only) deny Write,Edit,MultiEdit. Group B report-writers Write one
// output file so they deny only Edit,MultiEdit. gsd-nyquist-auditor is intentionally
// excluded (it legitimately uses Write AND Edit to create/patch test files).
const READONLY_AGENT_DISALLOWED_TOOLS = {
'gsd-plan-checker': 'Write, Edit, MultiEdit',
'gsd-integration-checker': 'Write, Edit, MultiEdit',
'gsd-ui-checker': 'Write, Edit, MultiEdit',
'gsd-verifier': 'Edit, MultiEdit',
'gsd-doc-verifier': 'Edit, MultiEdit',
'gsd-eval-auditor': 'Edit, MultiEdit',
'gsd-ui-auditor': 'Edit, MultiEdit',
};
/**
* #2517 — Read a single GSD config file (defaults.json or per-project
* config.json) into a plain object, returning null on missing/empty files
@@ -10181,6 +10227,8 @@ function install(isGlobal, runtime = 'claude', options = {}) {
const _universalEffort = resolveInstallTimeEffort(_effortCfg, _agentName);
const _renderedEffort = _getGsdEffortCatalog().renderEffortForRuntime('claude', _universalEffort).value;
content = injectEffortFrontmatter(content, _renderedEffort);
const _disallowedTools = READONLY_AGENT_DISALLOWED_TOOLS[_agentName];
if (_disallowedTools) content = injectDisallowedToolsFrontmatter(content, _disallowedTools);
}
// #3677 — normalize retired `/gsd:<cmd>` colon refs in the agent body
// to the canonical hyphen form `/gsd-<cmd>` for hyphen-`name:`