Claude Code plugin for per-agent tool visibility — hide and refuse subagents, skills, MCP and built-in tools per loop

Claude Code plugin for per-agent tool visibility — hide and refuse subagents, skills, MCP and built-in tools per loop.
Add a visibility field to an agent, skill or command's frontmatter: visible-to: { include: [...], exclude: [...] } names the loops (agent types, main for the main chat) that may see and invoke the item, and an agent's agents, skills, mcp and tools blocks limit what its own loop sees. Rules for what you do not own (the main chat, built-in agents, plugin agents and skills, MCP servers, built-in tools) go in tool-visibility-controller.json in ~/.claude/ or a project's .claude/. Each loop's agent listing, skill listing, deferred-tools notice and MCP instructions lose what it may not use, and a spawn, skill or tool call it may not make is refused with a message naming the rule. Everything is read once per session; any error is logged and leaves the listing and the call as they were.
Install with /plugin install tool-visibility-controller --marketplace rezzminator/tool-visibility-controller (where --marketplace is available). Requires function hooks (CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1). Limits: a tool whose schema sits in the prompt's tool list cannot be hidden per loop (its call is refused), and a sub-agent receives the agent listing after its first tool call. Remove it with claude plugin uninstall tool-visibility-controller@tool-visibility-controller. Full documentation: https://github.com/rezzminator/tool-visibility-controller
Built and maintained with Professor.
hooks/tool-visibility-controller.ts 260 lines1import type { EngineInterface, On, Register } from 'claude-code';
2import { filterDeferredTools, filterEntries, filterMcpInstructions } from '../src/listings.ts';
3import {
4 ancestorDirs,
5 buildPolicy,
6 decide,
7 denyText,
8 hasRules,
9 isEmpty,
10 MAIN,
11 mcpServer,
12 mergeScopes,
13 normalizeServer,
14 readScopeFile,
15 SCOPE_FILE,
16 type Owned,
17 type Policy,
18 type ScopeFile,
19} from '../src/policy.ts';
20import { readDefinition, SETS, type SetName } from '../src/visibility.ts';
21
22// Thin adapter: every decision lives in src/. A failure is logged with its
23// context, and the listing, the call or the spawn goes on as it was.
24
25/** The attachments that list what a loop can use. */
26const LISTINGS = new Set(['agent_listing_delta', 'skill_listing', 'mcp_instructions_delta', 'deferred_tools_delta']);
27
28/** How deep command subdirectories are read (`a:b:c`). */
29const COMMAND_DEPTH = 3;
30
31type Cache = { policy?: Promise<Policy> };
32type Entry = { name: string; kind: string; isLink?: boolean };
33type Read = { name: string; owned: Owned | null };
34
35function message(error: unknown): string {
36 return error instanceof Error ? error.message : String(error);
37}
38
39function claudeDir(dir: string): string {
40 return `${dir === '/' ? '' : dir}/.claude`;
41}
42
43async function entriesOf($: EngineInterface, dir: string): Promise<Entry[]> {
44 if (!(await $.fs.exists(dir))) return [];
45 try {
46 return await $.fs.list(dir);
47 } catch (error) {
48 $.ui.log(`tool-visibility-controller: listing ${dir} failed: ${message(error)}; the definitions in it are not read`);
49 return [];
50 }
51}
52
53// One definition file. Unreadable, it is logged and still stands for its name
54// with no rule, so it overrides a farther definition as Claude Code's does.
55async function readOwned($: EngineInterface, path: string, set: 'agents' | 'skills', fallback: string, byPath: boolean): Promise<Read> {
56 let text: string;
57 try {
58 text = await $.fs.read(path);
59 } catch (error) {
60 $.ui.log(`tool-visibility-controller: reading ${path} failed: ${message(error)}; ${fallback} has no visibility rule`);
61 return { name: fallback, owned: null };
62 }
63 const definition = readDefinition(text, fallback);
64 const name = byPath ? fallback : definition.name;
65 for (const problem of definition.problems) $.ui.log(`tool-visibility-controller: ${path}: ${problem}; that block is ignored`);
66 const visibility = definition.visibility;
67 if (visibility === null) return { name, owned: null };
68 if (set !== 'agents') {
69 for (const viewed of SETS) {
70 if (visibility.sets[viewed] !== undefined) {
71 $.ui.log(`tool-visibility-controller: ${path}: visibility.${viewed} applies only in agent files; it is ignored here`);
72 }
73 }
74 }
75 return { name, owned: { set, name, path, visibility } };
76}
77
78async function readAgents($: EngineInterface, dir: string): Promise<Read[]> {
79 const read: Read[] = [];
80 for (const entry of await entriesOf($, dir)) {
81 if (!entry.name.endsWith('.md') || entry.kind === 'dir') continue;
82 read.push(await readOwned($, `${dir}/${entry.name}`, 'agents', entry.name.slice(0, -3), false));
83 }
84 return read;
85}
86
87async function readSkills($: EngineInterface, dir: string): Promise<Read[]> {
88 const read: Read[] = [];
89 for (const entry of await entriesOf($, dir)) {
90 if (entry.kind === 'file') continue;
91 const path = `${dir}/${entry.name}/SKILL.md`;
92 if (await $.fs.exists(path)) read.push(await readOwned($, path, 'skills', entry.name, false));
93 }
94 return read;
95}
96
97// Commands by file, a subdirectory's named `dir:name` as Claude Code lists them.
98async function readCommands($: EngineInterface, dir: string, prefix: string, depth: number): Promise<Read[]> {
99 const read: Read[] = [];
100 for (const entry of await entriesOf($, dir)) {
101 if (entry.name.endsWith('.md') && entry.kind !== 'dir') {
102 read.push(await readOwned($, `${dir}/${entry.name}`, 'skills', `${prefix}${entry.name.slice(0, -3)}`, true));
103 } else if (depth < COMMAND_DEPTH && (entry.kind === 'dir' || entry.isLink === true)) {
104 read.push(...(await readCommands($, `${dir}/${entry.name}`, `${prefix}${entry.name}:`, depth + 1)));
105 }
106 }
107 return read;
108}
109
110async function readScope($: EngineInterface, path: string): Promise<ScopeFile | null> {
111 if (!(await $.fs.exists(path))) return null;
112 let text: string;
113 try {
114 text = await $.fs.read(path);
115 } catch (error) {
116 $.ui.log(`tool-visibility-controller: reading ${path} failed: ${message(error)}; the file is ignored`);
117 return null;
118 }
119 const { scope, problems } = readScopeFile(text);
120 for (const problem of problems) $.ui.log(`tool-visibility-controller: ${path}: ${problem}; ${scope === null ? 'the file is ignored' : 'that block is ignored'}`);
121 return scope;
122}
123
124// The user tier ($CLAUDE_CONFIG_DIR, else ~/.claude), then each project tier
125// from below the home directory (else the filesystem root) down to the session
126// root: the nearest definition of a name wins, and a nearer scope file's key
127// replaces a farther one's.
128async function loadPolicy($: EngineInterface): Promise<Policy> {
129 const config = await $.env.get('CLAUDE_CONFIG_DIR');
130 const home = await $.env.get('HOME');
131 const tiers: string[] = [];
132 if (config !== undefined && config !== '') tiers.push(config);
133 else if (home !== undefined && home !== '') tiers.push(`${home}/.claude`);
134 else $.ui.log('tool-visibility-controller: neither CLAUDE_CONFIG_DIR nor HOME is set; user definitions and scope file are not read');
135 for (const dir of ancestorDirs(await $.session.root(), home)) tiers.push(claudeDir(dir));
136 const agents = new Map<string, Owned | null>();
137 const skills = new Map<string, Owned | null>();
138 const scopes: Array<{ path: string; scope: ScopeFile }> = [];
139 for (const tier of tiers) {
140 for (const { name, owned } of await readAgents($, `${tier}/agents`)) agents.set(name, owned);
141 for (const { name, owned } of await readCommands($, `${tier}/commands`, '', 0)) skills.set(name, owned);
142 for (const { name, owned } of await readSkills($, `${tier}/skills`)) skills.set(name, owned);
143 const path = `${tier}/${SCOPE_FILE}`;
144 const scope = await readScope($, path);
145 if (scope !== null) scopes.push({ path, scope });
146 }
147 const owned = [...agents.values(), ...skills.values()].filter((item): item is Owned => item !== null);
148 return buildPolicy(owned, mergeScopes(scopes));
149}
150
151// Read once per session; a load that failed is retried at the next use.
152async function policyOf($: EngineInterface, cache: Cache): Promise<Policy> {
153 cache.policy ??= loadPolicy($);
154 try {
155 return await cache.policy;
156 } catch (error) {
157 cache.policy = undefined;
158 throw error;
159 }
160}
161
162// The loop's name: MAIN for the main chat, else its agent type in the
163// session's agent list; undefined when no row names the id.
164async function loopType($: EngineInterface, agentId: string | undefined): Promise<string | undefined> {
165 if (agentId === undefined) return MAIN;
166 return (await $.agent.list()).find((agent) => agent.id === agentId)?.type;
167}
168
169function hider(policy: Policy, set: SetName, loop: string): (name: string) => boolean {
170 return (name) => !decide(policy, set, loop, name).allowed;
171}
172
173async function rewritten($: EngineInterface, cache: Cache, type: string, text: string, agentId: string | undefined): Promise<string | null> {
174 try {
175 const policy = await policyOf($, cache);
176 if (isEmpty(policy)) return text;
177 const loop = await loopType($, agentId);
178 if (loop === undefined) {
179 $.ui.log(`tool-visibility-controller: loop ${String(agentId)} resolves to no agent type; its ${type} is left whole`);
180 return text;
181 }
182 if (type === 'agent_listing_delta') return hasRules(policy, 'agents') ? filterEntries(text, hider(policy, 'agents', loop), 'agents') : text;
183 if (type === 'skill_listing') return hasRules(policy, 'skills') ? filterEntries(text, hider(policy, 'skills', loop), 'skills') : text;
184 if (type === 'deferred_tools_delta') {
185 if (!hasRules(policy, 'mcp') && !hasRules(policy, 'tools')) return text;
186 const mcp = hider(policy, 'mcp', loop);
187 const tools = hider(policy, 'tools', loop);
188 return filterDeferredTools(text, (tool) => (mcpServer(tool) === undefined ? tools(tool) : mcp(tool)));
189 }
190 if (!hasRules(policy, 'mcp')) return text;
191 const names = (await $.tool.list()).map((tool) => tool.name);
192 const mcp = hider(policy, 'mcp', loop);
193 return filterMcpInstructions(text, (heading) => {
194 const server = normalizeServer(heading);
195 const own = names.filter((name) => name.startsWith(`mcp__${server}__`));
196 return own.length > 0 && own.every(mcp);
197 });
198 } catch (error) {
199 $.ui.log(`tool-visibility-controller: rewriting the ${type} of loop ${agentId ?? MAIN} failed: ${message(error)}; it is left whole`);
200 return text;
201 }
202}
203
204async function refusal($: EngineInterface, cache: Cache, targets: ReadonlyArray<[SetName, string]>, agentId: string | undefined): Promise<string | undefined> {
205 const what = targets.map(([, item]) => item).join(' / ');
206 try {
207 const policy = await policyOf($, cache);
208 const relevant = targets.filter(([set]) => hasRules(policy, set));
209 if (relevant.length === 0) return undefined;
210 const loop = await loopType($, agentId);
211 if (loop === undefined) {
212 $.ui.log(`tool-visibility-controller: ${what} from loop ${String(agentId)}, which resolves to no agent type, goes through`);
213 return undefined;
214 }
215 for (const [set, item] of relevant) {
216 const verdict = decide(policy, set, loop, item);
217 if (!verdict.allowed) return denyText(set, item, loop, verdict);
218 }
219 return undefined;
220 } catch (error) {
221 $.ui.log(`tool-visibility-controller: checking ${what} from loop ${agentId ?? MAIN} failed: ${message(error)}; it goes through`);
222 return undefined;
223 }
224}
225
226/** What a tool call invokes: the tool itself, and for the Skill tool the skill or command it names. */
227function callTargets(tool: string, skill: unknown): Array<[SetName, string]> {
228 const targets: Array<[SetName, string]> = [[mcpServer(tool) === undefined ? 'tools' : 'mcp', tool]];
229 if (tool === 'Skill' && typeof skill === 'string' && skill.trim() !== '') targets.push(['skills', skill.trim().replace(/^\//, '')]);
230 return targets;
231}
232
233export const register: Register = (on: On) => {
234 const cache: Cache = {};
235
236 on('session.start', async ($, e, next) => {
237 cache.policy = undefined;
238 return next(e);
239 });
240
241 on('prompt.attachment', async ($, e, next) => {
242 const result = await next(e);
243 if (!LISTINGS.has(e.type) || result.text === null) return result;
244 const text = await rewritten($, cache, e.type, result.text, e.agentId);
245 return text === result.text ? result : { ...result, text };
246 });
247
248 // A hook that throws or overruns its budget lets the spawn through.
249 on('agent.spawn', async ($, e, next) => {
250 const deny = await refusal($, cache, [['agents', e.subagentType]], e.parentAgentId);
251 return deny === undefined ? next(e) : { deny };
252 }).catch(($, e, next) => next(e));
253
254 // A hook that throws or overruns its budget lets the call through.
255 on('tool.call', async ($, e, next) => {
256 const deny = await refusal($, cache, callTargets(e.tool, (e as { skill?: unknown }).skill), e.agentId);
257 return deny === undefined ? next(e) : { deny };
258 }).catch(($, e, next) => next(e));
259};
260src/listings.ts 114 lines1// The listing rewrites, pure: each removes what a loop may not see and keeps
2// every other byte, so the same input always gives the same output and the
3// prompt cache holds. Each answers null when nothing it listed is left.
4
5const TOOLS_SUFFIX = /\(Tools: [^()]*\)\s*$/;
6
7/** The name an entry line lists: `- name: description`, `- plugin:name: description`, or a bare `- name`. */
8function entryName(line: string): string | undefined {
9 return /^- (\S+?)(?::\s|:$|$)/.exec(line)?.[1];
10}
11
12/**
13 * The agent or skill listing without the entries `hidden` names. An entry is
14 * its `- name` line and the description lines that follow it, a bullet that
15 * names no entry included: an agent's up to its `(Tools: ...)` suffix, a
16 * skill's up to a blank line or the next entry.
17 */
18export function filterEntries(text: string, hidden: (name: string) => boolean, kind: 'agents' | 'skills'): string | null {
19 const lines = text.split('\n');
20 const kept: string[] = [];
21 let removed = 0;
22 let remaining = 0;
23 for (let i = 0; i < lines.length; i += 1) {
24 const line = lines[i] ?? '';
25 const name = entryName(line);
26 if (name === undefined) {
27 kept.push(line);
28 continue;
29 }
30 if (!hidden(name)) {
31 remaining += 1;
32 kept.push(line);
33 continue;
34 }
35 removed += 1;
36 const spans = kind === 'skills' || (line.startsWith(`- ${name}:`) && !TOOLS_SUFFIX.test(line));
37 if (!spans) continue;
38 if (kind === 'agents') {
39 // An agent's description ends at its Tools suffix, blank lines and all, when one comes before the next entry.
40 let end = i + 1;
41 while (end < lines.length && entryName(lines[end] ?? '') === undefined && !TOOLS_SUFFIX.test(lines[end] ?? '')) end += 1;
42 if (end < lines.length && entryName(lines[end] ?? '') === undefined) {
43 i = end;
44 continue;
45 }
46 }
47 while (i + 1 < lines.length) {
48 const next = lines[i + 1] ?? '';
49 if (next.trim() === '' || entryName(next) !== undefined) break;
50 i += 1;
51 if (kind === 'agents' && TOOLS_SUFFIX.test(next)) break;
52 }
53 }
54 if (removed === 0) return text;
55 return remaining === 0 ? null : kept.join('\n');
56}
57
58/**
59 * The MCP server instructions without the `## <server>` blocks `hidden` names
60 * (the heading as written, the server's configured name).
61 */
62export function filterMcpInstructions(text: string, hidden: (server: string) => boolean): string | null {
63 const lines = text.split('\n');
64 const kept: string[] = [];
65 let removed = 0;
66 let remaining = 0;
67 let dropping = false;
68 let lastDropped = false;
69 for (const line of lines) {
70 const heading = /^## (.+)$/.exec(line)?.[1];
71 if (heading !== undefined) {
72 dropping = hidden(heading);
73 if (dropping) removed += 1;
74 else remaining += 1;
75 }
76 if (!dropping) kept.push(line);
77 lastDropped = dropping;
78 }
79 if (removed === 0) return text;
80 if (remaining === 0) return null;
81 if (lastDropped) {
82 while (kept.length > 0 && kept.at(-1) === '') kept.pop();
83 if (text.endsWith('\n')) kept.push('');
84 }
85 return kept.join('\n');
86}
87
88const TOOL_NAME = /^[A-Za-z0-9_.:-]+$/;
89
90/**
91 * The deferred-tools notice without the tool names `hidden` names. A list is a
92 * paragraph whose first line ends with `:`; one about MCP servers lists server
93 * names and is kept as it is. A list left with no name is dropped whole.
94 */
95export function filterDeferredTools(text: string, hidden: (tool: string) => boolean): string | null {
96 const paragraphs = text.split('\n\n');
97 const kept: string[] = [];
98 let removed = 0;
99 for (const paragraph of paragraphs) {
100 const [header, ...items] = paragraph.split('\n');
101 const toolList = header !== undefined && header.endsWith(':') && !/MCP servers?\b/.test(header);
102 if (!toolList || items.length === 0) {
103 kept.push(paragraph);
104 continue;
105 }
106 const left = items.filter((item) => !(TOOL_NAME.test(item) && hidden(item)));
107 removed += items.length - left.length;
108 if (left.length === items.length) kept.push(paragraph);
109 else if (left.length > 0) kept.push([header, ...left].join('\n'));
110 }
111 if (removed === 0) return text;
112 return kept.some((paragraph) => paragraph.trim() !== '') ? kept.join('\n\n') : null;
113}
114src/policy.ts 297 lines1// Who may see and invoke what, pure: name matching, the rules from definitions
2// and scope files, the verdict with the rule that gave it, and the refusal text.
3
4import { SETS, VISIBLE_TO, type Filter, type SetName, type Visibility } from './visibility.ts';
5
6/** The loop name of the main chat, in rules and as a caller. */
7export const MAIN = 'main';
8
9/** The scope file's name, in the user config directory and in each project `.claude/`. */
10export const SCOPE_FILE = 'tool-visibility-controller.json';
11
12/** One rule: its filter, the block that states it, and the file it is in. */
13export type Rule = { block: string; path: string; filter: Filter };
14
15type ItemRule = { pattern: string; exact: boolean; rule: Rule };
16
17/** Every rule in force: per set, the item rules (who may see an item) and, per loop, its viewer rules (what it sees). */
18export type Policy = { items: Record<SetName, ItemRule[]>; loops: Map<string, Partial<Record<SetName, Rule[]>>> };
19
20/** Allowed, or refused by one rule on the item's side (`visible-to`) or the loop's side (a set block). */
21export type Verdict = { allowed: true } | { allowed: false; side: 'item' | 'viewer'; rule: Rule };
22
23/** A scope file as read: viewer blocks per loop, and a `visible-to` per item pattern. */
24export type ScopeFile = { loops: Record<string, Partial<Record<SetName, Filter>>>; items: Record<SetName, Record<string, Filter>> };
25
26/** Scope files merged key by key, the nearest file's key winning. */
27export type Merged = { loops: Map<string, Map<SetName, Rule>>; items: Map<SetName, Map<string, Rule>> };
28
29/** A definition this plugin reads: an agent, or a skill or command, with its `visibility`. */
30export type Owned = { set: 'agents' | 'skills'; name: string; path: string; visibility: Visibility };
31
32const compiled = new Map<string, RegExp>();
33
34/** Whether `value` matches `pattern`, exactly or with `*` standing for any run of characters. */
35export function globMatch(pattern: string, value: string): boolean {
36 if (!pattern.includes('*')) return pattern === value;
37 let re = compiled.get(pattern);
38 if (re === undefined) {
39 re = new RegExp(`^${pattern.split('*').map((part) => part.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*')}$`);
40 compiled.set(pattern, re);
41 }
42 return re.test(value);
43}
44
45/** The server of an MCP tool name `mcp__<server>__<tool>`; undefined for any other tool. */
46export function mcpServer(name: string): string | undefined {
47 return /^mcp__(.+?)__(.+)$/.exec(name)?.[1];
48}
49
50/** A configured server name as Claude Code writes it into tool names: every other character becomes `_`. */
51export function normalizeServer(name: string): string {
52 return name.replace(/[^A-Za-z0-9_*-]/g, '_');
53}
54
55/**
56 * Whether an item pattern matches an item name of `set`. MCP: `server`,
57 * `server/tool` or a full `mcp__server__tool`, `*` anywhere. Skills: the full
58 * name (`plugin:name`) or the bare name after the plugin. Else the name.
59 */
60export function itemMatches(set: SetName, pattern: string, name: string): boolean {
61 if (set === 'mcp') {
62 if (pattern.startsWith('mcp__')) return globMatch(pattern, name);
63 if (mcpServer(name) === undefined) return false;
64 const slash = pattern.indexOf('/');
65 const server = normalizeServer(slash < 0 ? pattern : pattern.slice(0, slash));
66 // A server name may itself hold `__`: try every split of server and tool.
67 for (let at = name.indexOf('__', 5); at > 5 && at + 2 < name.length; at = name.indexOf('__', at + 1)) {
68 if (globMatch(server, name.slice(5, at)) && (slash < 0 || globMatch(pattern.slice(slash + 1), name.slice(at + 2)))) return true;
69 }
70 return false;
71 }
72 if (globMatch(pattern, name)) return true;
73 return set === 'skills' && name.includes(':') && globMatch(pattern, name.slice(name.lastIndexOf(':') + 1));
74}
75
76function admits(filter: Filter, matches: (pattern: string) => boolean): boolean {
77 return (filter.include === undefined || filter.include.some(matches)) && !(filter.exclude ?? []).some(matches);
78}
79
80/** Whether `loop` may see and invoke `item` of `set`; refused, the rule that refused it. */
81export function decide(policy: Policy, set: SetName, loop: string, item: string): Verdict {
82 for (const { pattern, exact, rule } of policy.items[set]) {
83 const applies = exact ? pattern === item : itemMatches(set, pattern, item);
84 if (applies && !admits(rule.filter, (p) => globMatch(p, loop))) return { allowed: false, side: 'item', rule };
85 }
86 for (const rule of policy.loops.get(loop)?.[set] ?? []) {
87 if (!admits(rule.filter, (p) => itemMatches(set, p, item))) return { allowed: false, side: 'viewer', rule };
88 }
89 return { allowed: true };
90}
91
92/** No rule at all: nothing to filter or refuse. */
93export function isEmpty(policy: Policy): boolean {
94 return SETS.every((set) => policy.items[set].length === 0) && policy.loops.size === 0;
95}
96
97/** Whether any rule speaks about `set`. */
98export function hasRules(policy: Policy, set: SetName): boolean {
99 return policy.items[set].length > 0 || [...policy.loops.values()].some((blocks) => (blocks[set]?.length ?? 0) > 0);
100}
101
102const NOUN: Record<SetName, string> = { agents: 'Agent type', skills: 'Skill', mcp: 'MCP tool', tools: 'Tool' };
103const INSTEAD: Record<SetName, string> = {
104 agents: 'choose another agent type',
105 skills: 'do the task without this skill',
106 mcp: 'use another tool',
107 tools: 'use another tool',
108};
109
110function who(loop: string): string {
111 return loop === MAIN ? 'the main chat' : loop;
112}
113
114function joined(names: readonly string[], word: string): string {
115 return names.length < 2 ? (names[0] ?? '') : `${names.slice(0, -1).join(', ')} ${word} ${names.at(-1)}`;
116}
117
118/** The refusal the model reads: the item, the caller, the rule with its file, and what to do instead. */
119export function denyText(set: SetName, item: string, loop: string, verdict: Verdict): string {
120 if (verdict.allowed) return '';
121 const { rule, side } = verdict;
122 const caller = loop === MAIN ? 'the main chat' : `a ${loop} agent`;
123 const matches = side === 'item' ? (p: string) => globMatch(p, loop) : (p: string) => itemMatches(set, p, item);
124 const include = rule.filter.include;
125 const why =
126 include !== undefined && !include.some(matches)
127 ? include.length === 0
128 ? 'admits nothing'
129 : `admits only ${joined(include, 'and')}`
130 : `excludes ${(rule.filter.exclude ?? []).find(matches) ?? item}`;
131 const delegates = side === 'item' && include !== undefined ? include.filter((p) => !p.includes('*')).map(who) : [];
132 const instead = delegates.length > 0 ? `Delegate the task to ${joined(delegates, 'or')}, or ${INSTEAD[set]}.` : `${INSTEAD[set][0]?.toUpperCase()}${INSTEAD[set].slice(1)}.`;
133 return `${NOUN[set]} ${item} is not available to ${caller}: ${rule.block} in ${rule.path} ${why}. ${instead}`;
134}
135
136function isObject(value: unknown): value is Record<string, unknown> {
137 return typeof value === 'object' && value !== null && !Array.isArray(value);
138}
139
140function readFilter(value: unknown, path: string, problems: string[]): Filter | undefined {
141 if (!isObject(value)) {
142 problems.push(`${path} is not an object of include and exclude`);
143 return undefined;
144 }
145 const filter: { include?: string[]; exclude?: string[] } = {};
146 for (const [key, list] of Object.entries(value)) {
147 if (key !== 'include' && key !== 'exclude') {
148 problems.push(`${path} has an unknown key ${JSON.stringify(key)}; use include or exclude`);
149 return undefined;
150 }
151 if (!Array.isArray(list) || list.some((item) => typeof item !== 'string' || item.trim() === '')) {
152 problems.push(`${path}.${key} is not a list of names`);
153 return undefined;
154 }
155 filter[key] = list.map((item: string) => item.trim());
156 }
157 return filter;
158}
159
160function isSet(key: string): key is SetName {
161 return (SETS as readonly string[]).includes(key);
162}
163
164/**
165 * Reads a scope file's text. Invalid JSON reads as null; a malformed block is
166 * named in `problems` and left out, the other blocks kept.
167 */
168export function readScopeFile(text: string): { scope: ScopeFile | null; problems: string[] } {
169 const problems: string[] = [];
170 let json: unknown;
171 try {
172 json = JSON.parse(text);
173 } catch (error) {
174 return { scope: null, problems: [`it is not valid JSON (${error instanceof Error ? error.message : String(error)})`] };
175 }
176 if (!isObject(json)) return { scope: null, problems: ['its top level is not an object of loops and items'] };
177 const scope: ScopeFile = { loops: {}, items: { agents: {}, skills: {}, mcp: {}, tools: {} } };
178 for (const [key, value] of Object.entries(json)) {
179 if (key === 'loops') {
180 if (!isObject(value)) {
181 problems.push('loops is not an object of loop names');
182 continue;
183 }
184 for (const [loop, blocks] of Object.entries(value)) {
185 if (!isObject(blocks)) {
186 problems.push(`loops.${loop} is not an object of agents, skills, mcp and tools`);
187 continue;
188 }
189 const read: Partial<Record<SetName, Filter>> = {};
190 for (const [set, block] of Object.entries(blocks)) {
191 if (!isSet(set)) {
192 problems.push(`loops.${loop} has an unknown key ${JSON.stringify(set)}; use agents, skills, mcp or tools`);
193 continue;
194 }
195 const filter = readFilter(block, `loops.${loop}.${set}`, problems);
196 if (filter !== undefined) read[set] = filter;
197 }
198 if (Object.keys(read).length > 0) scope.loops[loop] = read;
199 }
200 } else if (key === 'items') {
201 if (!isObject(value)) {
202 problems.push('items is not an object of agents, skills, mcp and tools');
203 continue;
204 }
205 for (const [set, patterns] of Object.entries(value)) {
206 if (!isSet(set)) {
207 problems.push(`items has an unknown key ${JSON.stringify(set)}; use agents, skills, mcp or tools`);
208 continue;
209 }
210 if (!isObject(patterns)) {
211 problems.push(`items.${set} is not an object of item patterns`);
212 continue;
213 }
214 for (const [pattern, entry] of Object.entries(patterns)) {
215 const path = `items.${set}.${pattern}`;
216 if (!isObject(entry)) {
217 problems.push(`${path} is not an object holding visible-to`);
218 continue;
219 }
220 const unknown = Object.keys(entry).find((k) => k !== VISIBLE_TO);
221 if (unknown !== undefined) {
222 problems.push(`${path} has an unknown key ${JSON.stringify(unknown)}; use visible-to`);
223 continue;
224 }
225 if (!(VISIBLE_TO in entry)) continue;
226 const filter = readFilter(entry[VISIBLE_TO], `${path}.${VISIBLE_TO}`, problems);
227 if (filter !== undefined) scope.items[set][pattern] = filter;
228 }
229 }
230 } else {
231 problems.push(`it has an unknown key ${JSON.stringify(key)}; use loops or items`);
232 }
233 }
234 return { scope, problems };
235}
236
237/** Scope files given lowest precedence first (user, then project from the root down): a nearer file's key replaces a farther one's. */
238export function mergeScopes(files: ReadonlyArray<{ path: string; scope: ScopeFile }>): Merged {
239 const merged: Merged = { loops: new Map(), items: new Map() };
240 for (const { path, scope } of files) {
241 for (const [loop, blocks] of Object.entries(scope.loops)) {
242 const target = merged.loops.get(loop) ?? new Map<SetName, Rule>();
243 merged.loops.set(loop, target);
244 for (const set of SETS) {
245 const filter = blocks[set];
246 if (filter !== undefined) target.set(set, { block: `loops.${loop}.${set}`, path, filter });
247 }
248 }
249 for (const set of SETS) {
250 const target = merged.items.get(set) ?? new Map<string, Rule>();
251 merged.items.set(set, target);
252 for (const [pattern, filter] of Object.entries(scope.items[set] ?? {})) {
253 target.set(pattern, { block: `items.${set}.${pattern}.${VISIBLE_TO}`, path, filter });
254 }
255 }
256 }
257 return merged;
258}
259
260/**
261 * Every rule in force: each definition's `visible-to` (exact name) and each
262 * agent's own set blocks, then the merged scope files. All apply together.
263 */
264export function buildPolicy(owned: ReadonlyArray<Owned>, merged: Merged): Policy {
265 const policy: Policy = { items: { agents: [], skills: [], mcp: [], tools: [] }, loops: new Map() };
266 const viewer = (loop: string, set: SetName, rule: Rule) => {
267 const blocks = policy.loops.get(loop) ?? {};
268 (blocks[set] ??= []).push(rule);
269 policy.loops.set(loop, blocks);
270 };
271 for (const { set, name, path, visibility } of owned) {
272 if (visibility.visibleTo !== undefined) {
273 policy.items[set].push({ pattern: name, exact: true, rule: { block: `visibility.${VISIBLE_TO}`, path, filter: visibility.visibleTo } });
274 }
275 if (set !== 'agents') continue;
276 for (const viewed of SETS) {
277 const filter = visibility.sets[viewed];
278 if (filter !== undefined) viewer(name, viewed, { block: `visibility.${viewed}`, path, filter });
279 }
280 }
281 for (const [set, patterns] of merged.items) for (const [pattern, rule] of patterns) policy.items[set].push({ pattern, exact: false, rule });
282 for (const [loop, blocks] of merged.loops) for (const [set, rule] of blocks) viewer(loop, set, rule);
283 return policy;
284}
285
286/**
287 * The project directories of a session at `root`, farthest first: `root` and
288 * each directory above it, stopping below `home` (the user tier, never a
289 * project) as Claude Code does, else at the filesystem root.
290 */
291export function ancestorDirs(root: string, home?: string): string[] {
292 const parts = root.split('/').filter((part) => part !== '');
293 const dirs = ['/', ...parts.map((_, i) => `/${parts.slice(0, i + 1).join('/')}`)];
294 const stop = home === undefined ? -1 : dirs.indexOf(`/${home.split('/').filter((part) => part !== '').join('/')}`);
295 return stop < 0 ? dirs : dirs.slice(stop + 1);
296}
297src/visibility.ts 318 lines1// Reading the `visibility` field of an agent, skill or command definition's
2// YAML frontmatter, pure. A YAML subset suffices: block and flow mappings,
3// block and flow lists, plain and quoted scalars, comments.
4
5/** A filter over names: `include` keeps only matches, `exclude` drops matches; both, include minus exclude. */
6export type Filter = { include?: readonly string[]; exclude?: readonly string[] };
7
8/** The four sets a loop sees and invokes. */
9export const SETS = ['agents', 'skills', 'mcp', 'tools'] as const;
10export type SetName = (typeof SETS)[number];
11
12/** The `visibility` field: who may see the item (`visible-to`), and what the agent's own loop sees, per set. */
13export type Visibility = { visibleTo?: Filter; sets: Partial<Record<SetName, Filter>> };
14
15/** One definition: its name, its field (null when absent or unreadable), and every problem found in it. */
16export type Definition = { name: string; visibility: Visibility | null; problems: string[] };
17
18/** The frontmatter key and its block names. */
19export const FIELD = 'visibility';
20export const VISIBLE_TO = 'visible-to';
21
22type Value = string | Value[] | ValueMap | null | Broken;
23type ValueMap = Map<string, Value>;
24
25/** A value that could not be read, kept in place of a block so its siblings stand. */
26class Broken {
27 constructor(
28 readonly path: string[],
29 readonly message: string,
30 ) {}
31}
32
33class Malformed extends Error {
34 readonly path: string[] = [];
35}
36
37function stripComment(line: string): string {
38 let quote = '';
39 for (let i = 0; i < line.length; i += 1) {
40 const c = line[i];
41 if (quote !== '') {
42 if (c === quote) quote = '';
43 } else if (c === '"' || c === "'") {
44 quote = c;
45 } else if (c === '#' && (i === 0 || /\s/.test(line[i - 1] ?? ''))) {
46 return line.slice(0, i).trimEnd();
47 }
48 }
49 return line.trimEnd();
50}
51
52function indentOf(line: string): number {
53 return line.length - line.trimStart().length;
54}
55
56function unquote(raw: string): string {
57 const item = raw.trim();
58 if (item.length >= 2 && (item[0] === '"' || item[0] === "'") && item.at(-1) === item[0]) return item.slice(1, -1);
59 return item;
60}
61
62// A flow value (`[a, b]`, `{ k: v }`, or a scalar) read from `text` at `at`.
63function readFlow(text: string, at: { i: number }): Value {
64 const skip = () => {
65 while (at.i < text.length && /\s/.test(text[at.i] ?? '')) at.i += 1;
66 };
67 skip();
68 const c = text[at.i];
69 if (c === '[') {
70 at.i += 1;
71 const items: Value[] = [];
72 skip();
73 if (text[at.i] === ']') {
74 at.i += 1;
75 return items;
76 }
77 for (;;) {
78 skip();
79 if (text[at.i] === ',' || text[at.i] === ']') throw new Malformed('a list has an empty item');
80 items.push(readFlow(text, at));
81 skip();
82 if (text[at.i] === ',') {
83 at.i += 1;
84 skip();
85 if (text[at.i] !== ']') continue;
86 }
87 if (text[at.i] === ']') {
88 at.i += 1;
89 return items;
90 }
91 throw new Malformed('a list is not closed with ]');
92 }
93 }
94 if (c === '{') {
95 at.i += 1;
96 const map: ValueMap = new Map();
97 skip();
98 if (text[at.i] === '}') {
99 at.i += 1;
100 return map;
101 }
102 for (;;) {
103 skip();
104 const colon = text.indexOf(':', at.i);
105 if (colon < 0) throw new Malformed('a mapping entry has no ":"');
106 const key = unquote(text.slice(at.i, colon));
107 if (key === '' || /[[\]{},]/.test(key)) throw new Malformed(`${JSON.stringify(key)} is not a key`);
108 if (map.has(key)) throw new Malformed(`${JSON.stringify(key)} is given twice`);
109 at.i = colon + 1;
110 map.set(key, readFlow(text, at));
111 skip();
112 if (text[at.i] === ',') {
113 at.i += 1;
114 skip();
115 if (text[at.i] !== '}') continue;
116 }
117 if (text[at.i] === '}') {
118 at.i += 1;
119 return map;
120 }
121 throw new Malformed('a mapping is not closed with }');
122 }
123 }
124 if (c === '"' || c === "'") {
125 const end = text.indexOf(c, at.i + 1);
126 if (end < 0) throw new Malformed('a quoted name is not closed');
127 const value = text.slice(at.i + 1, end);
128 at.i = end + 1;
129 return value;
130 }
131 const start = at.i;
132 while (at.i < text.length && !/[,\]}]/.test(text[at.i] ?? '')) at.i += 1;
133 return text.slice(start, at.i).trim();
134}
135
136function parseInline(value: string): Value {
137 const at = { i: 0 };
138 const parsed = readFlow(value, at);
139 if (value.slice(at.i).trim() !== '') {
140 throw new Malformed(value.trimStart().startsWith('[') ? 'a list is not closed with ]' : `${JSON.stringify(value.trim())} has text after its value`);
141 }
142 return parsed;
143}
144
145// A block (mapping or list) from `lines`, every one indented at least as the first.
146// With `isolate`, a key whose value cannot be read holds a Broken in its place.
147function parseBlock(lines: readonly string[], isolate = false): Value {
148 const rows = lines.filter((line) => line.trim() !== '');
149 const first = rows[0];
150 if (first === undefined) return null;
151 const base = indentOf(first);
152 if (first.trimStart().startsWith('- ') || first.trim() === '-') {
153 const items: Value[] = [];
154 for (const row of rows) {
155 if (indentOf(row) !== base) throw new Malformed('a list item is nested');
156 const item = /^\s*-\s*(.*)$/.exec(row)?.[1];
157 if (item === undefined) throw new Malformed('a list mixes items and keys');
158 if (item === '') throw new Malformed('a list has an empty item');
159 items.push(parseInline(item));
160 }
161 return items;
162 }
163 const map: ValueMap = new Map();
164 for (let i = 0; i < rows.length; i += 1) {
165 const row = rows[i] ?? '';
166 if (indentOf(row) !== base) throw new Malformed('a line is indented deeper than its key');
167 const entry = /^\s*("[^"]*"|'[^']*'|[^:\s][^:]*?)\s*:(?:\s+(.*))?$/.exec(row);
168 if (entry === null) throw new Malformed(`${JSON.stringify(row.trim())} is not a key: value line`);
169 const key = unquote(entry[1] ?? '');
170 const inline = entry[2]?.trim() ?? '';
171 const twice = map.has(key);
172 // A flow collection may wrap onto deeper lines: on the key's line, or below it.
173 const wraps = /^[[{]/.test(inline);
174 const child: string[] = [];
175 while ((inline === '' || wraps) && i + 1 < rows.length) {
176 const next = rows[i + 1] ?? '';
177 const deeper = indentOf(next) > base;
178 const sameIndentList = inline === '' && indentOf(next) === base && /^\s*-(\s|$)/.test(next);
179 if (!deeper && !sameIndentList) break;
180 child.push(next);
181 i += 1;
182 }
183 const flow = wraps || /^[[{]/.test(child[0]?.trim() ?? '');
184 try {
185 if (twice) throw new Malformed('it is given twice');
186 map.set(
187 key,
188 flow
189 ? parseInline([inline, ...child.map((line) => line.trim())].join(' '))
190 : inline !== ''
191 ? parseInline(inline)
192 : child.length === 0
193 ? null
194 : parseBlock(child),
195 );
196 } catch (error) {
197 if (!(error instanceof Malformed)) throw error;
198 error.path.unshift(key);
199 if (!isolate) throw error;
200 map.set(key, new Broken(error.path, error.message));
201 }
202 }
203 return map;
204}
205
206function isMap(value: Value): value is ValueMap {
207 return value instanceof Map;
208}
209
210/** A name in a list: non-empty, on one line, without flow punctuation. */
211function isName(value: Value): value is string {
212 return typeof value === 'string' && value.trim() !== '' && !/[[\]{},\n]/.test(value);
213}
214
215// A filter block at `path`, or the reason it is unusable.
216function readFilter(value: Value, path: string, problems: string[]): Filter | undefined {
217 if (value instanceof Broken) {
218 problems.push(`${FIELD}.${value.path.join('.')}: ${value.message}`);
219 return undefined;
220 }
221 if (value === null) {
222 problems.push(`${path} has no value`);
223 return undefined;
224 }
225 if (!isMap(value)) {
226 problems.push(`${path} is not a mapping of include and exclude`);
227 return undefined;
228 }
229 const filter: { include?: string[]; exclude?: string[] } = {};
230 for (const [key, list] of value) {
231 if (key !== 'include' && key !== 'exclude') {
232 problems.push(`${path} has an unknown key ${JSON.stringify(key)}; use include or exclude`);
233 return undefined;
234 }
235 if (!Array.isArray(list)) {
236 problems.push(`${path}.${key} is not a list; write [name, ...]`);
237 return undefined;
238 }
239 const bad = list.find((item) => !isName(item));
240 if (bad !== undefined) {
241 problems.push(`${path}.${key} holds ${JSON.stringify(bad)}, which is not a name`);
242 return undefined;
243 }
244 filter[key] = list.map((item) => (item as string).trim());
245 }
246 return filter;
247}
248
249/** Reads the `visibility` field's value, its problems reported by block path, each bad block left out. */
250export function readVisibility(value: Value | undefined, problems: string[]): Visibility | null {
251 if (value === undefined) return null;
252 if (value === null) {
253 problems.push(`${FIELD} has no value`);
254 return null;
255 }
256 if (value instanceof Broken) {
257 problems.push(`${FIELD}.${value.path.join('.')}: ${value.message}`);
258 return null;
259 }
260 if (!isMap(value)) {
261 problems.push(`${FIELD} is not a mapping of visible-to, agents, skills, mcp and tools`);
262 return null;
263 }
264 const visibility: Visibility = { sets: {} };
265 for (const [key, block] of value) {
266 const path = `${FIELD}.${key}`;
267 if (key === VISIBLE_TO) {
268 const filter = readFilter(block, path, problems);
269 if (filter !== undefined) visibility.visibleTo = filter;
270 } else if ((SETS as readonly string[]).includes(key)) {
271 const filter = readFilter(block, path, problems);
272 if (filter !== undefined) visibility.sets[key as SetName] = filter;
273 } else {
274 problems.push(`${FIELD} has an unknown key ${JSON.stringify(key)}; use visible-to, agents, skills, mcp or tools`);
275 }
276 }
277 return visibility;
278}
279
280/**
281 * Reads one definition's name (frontmatter `name`, else `fallback`) and its
282 * `visibility` field from the YAML frontmatter. A malformed block is named in
283 * `problems` and left out; the other blocks stand.
284 */
285export function readDefinition(text: string, fallback: string): Definition {
286 const lines = text.replace(/^/, '').split(/\r?\n/);
287 if (lines[0]?.trim() !== '---') return { name: fallback, visibility: null, problems: [] };
288 const end = lines.findIndex((line, i) => i > 0 && line.trim() === '---');
289 const front = lines.slice(1, end < 0 ? lines.length : end).map(stripComment);
290 const nameLine = front.find((line) => /^name:/.test(line));
291 const name = nameLine === undefined ? '' : unquote(nameLine.slice('name:'.length));
292 const at = front.findIndex((line) => line.startsWith(`${FIELD}:`));
293 const problems: string[] = [];
294 if (at < 0) return { name: name === '' ? fallback : name, visibility: null, problems };
295 const inline = (front[at] ?? '').slice(FIELD.length + 1).trim();
296 const block: string[] = [];
297 for (const line of front.slice(at + 1)) {
298 if (line.trim() !== '' && indentOf(line) === 0) break;
299 block.push(line);
300 }
301 const rows = block.filter((line) => line.trim() !== '');
302 let value: Value;
303 try {
304 value = /^[[{]/.test(inline || (rows[0]?.trim() ?? ''))
305 ? parseInline([inline, ...rows.map((line) => line.trim())].join(' '))
306 : inline !== ''
307 ? parseInline(inline)
308 : rows.length > 0
309 ? parseBlock(block, true)
310 : null;
311 } catch (error) {
312 if (!(error instanceof Malformed)) throw error;
313 problems.push([FIELD, ...error.path].join('.') + `: ${error.message}`);
314 return { name: name === '' ? fallback : name, visibility: null, problems };
315 }
316 return { name: name === '' ? fallback : name, visibility: readVisibility(value, problems), problems };
317}
318