SLOPSHOPPER

tool-visibility-controller

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

newguardpromptagents
A shopper browsing a rack in a slop shop
README

tool-visibility-controller

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.

Source 4 files
hooks/tool-visibility-controller.ts 260 lines
1import 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};
260
src/listings.ts 114 lines
1// 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}
114
src/policy.ts 297 lines
1// 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}
297
src/visibility.ts 318 lines
1// 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