All files / src/configuration policy.ts

100% Statements 71/71
100% Branches 81/81
100% Functions 9/9
100% Lines 62/62

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206              45x   45x 45x     45x                                                       45x                             45x 2908x       45x 260x       45x 124x         45x 64x           45x       939x               45x         500x 500x 26x   500x 500x 11x 11x 9x 9x 9x 9x       500x 500x 500x                                         45x 1401x 1223x 2x   1221x 1209x 6x     1203x 4x       1199x 1x   1198x             45x         1072x 1063x   1063x             45x         311x 311x 303x 283x 283x 3x             280x 45x 45x   12x 1x     268x 33x 33x   267x    
/**
 * The restrictions a caller's preset, account settings and session overrides put on
 * tool use. They narrow what the operator's configuration already filtered at the
 * registry; nothing here can bring back a tool or action the operator removed.
 */
 
import type { Preset, ScopeConfig } from '../profiles/types';
import { enforceArgsScope, ScopeEnforcer, ScopeViolationError } from '../profiles/scope-enforcer';
import type { AccountSettings, WorkingScope } from './types';
import { scopeCheckArgs, targetlessRestriction } from './scope-targets';
import { withoutSchemaActions } from '../utils/schema-utils';
 
/** A session's choice to work without the account's default scope. */
export const EVERYWHERE = 'everywhere';
 
/** Overrides of the current MCP session; they replace the account's choice for it. */
export interface SessionOverrides {
  preset?: string;
  readOnly?: boolean;
  scope?: WorkingScope | typeof EVERYWHERE;
}
 
export interface EffectivePolicy {
  /** Verified operator project identities for this call; an empty set permits no project. */
  operatorProjectIds?: ReadonlySet<string>;
  presetName?: string;
  readOnly: boolean;
  disabledGroups: ReadonlySet<string>;
  /** Preset whitelist; undefined allows every tool. */
  allowedTools?: ReadonlySet<string>;
  deniedTools?: RegExp;
  /** Tool name -> actions the preset denies. */
  deniedActions: ReadonlyMap<string, ReadonlySet<string>>;
  scope?: ScopeConfig;
  scopeEnforcer?: ScopeEnforcer;
}
 
/**
 * Tools that configure or diagnose the caller's own connection. Settings never restrict
 * them, and they answer while GitLab is unreachable: that is when they are needed.
 */
export const CONFIGURATION_TOOLS: ReadonlySet<string> = new Set([
  'manage_context',
  'get_settings',
  'update_settings',
  'check_connection',
  'open_settings_panel',
  // Choosing a new scope means searching outside the current one.
  'find_scope_targets',
]);
 
/**
 * Tools settings never restrict: the configuration tools, and the account identity the host
 * reads to know whose connection it is. Unlike the configuration tools, the identity needs
 * GitLab, so it does not answer while GitLab is unreachable.
 */
export function isExemptFromSettings(name: string): boolean {
  return CONFIGURATION_TOOLS.has(name) || name === 'get_profile';
}
 
/** The policy without its working scope: the limits checked without looking up targets. */
export function withoutScope(policy: EffectivePolicy): EffectivePolicy {
  return { ...policy, scope: undefined, scopeEnforcer: undefined };
}
 
/** The scope's first project or group, for reports; undefined when nothing is scoped. */
export function scopeLabel(scope: ScopeConfig | undefined): string | undefined {
  return (
    scope?.project ?? scope?.group ?? scope?.namespace ?? scope?.projects?.[0] ?? scope?.groups?.[0]
  );
}
 
export function scopeConfigOf(scope: WorkingScope): ScopeConfig {
  return scope.type === 'project'
    ? { project: scope.path }
    : { group: scope.path, includeSubgroups: scope.includeSubgroups };
}
 
/** The preset the caller works with: the session's choice, else the account's. */
export function selectedPreset(
  account: AccountSettings,
  session: SessionOverrides,
): string | undefined {
  return session.preset ?? account.preset;
}
 
/**
 * Combine the selected preset (already loaded), the account settings and the session
 * overrides. A session's own choice of preset, read-only mode or scope replaces the
 * account's; groups the account switched off stay off in every session.
 */
export function buildPolicy(
  preset: Preset | undefined,
  account: AccountSettings,
  session: SessionOverrides,
): EffectivePolicy {
  const disabledGroups = new Set(account.disabledToolGroups ?? []);
  for (const [feature, enabled] of Object.entries(preset?.features ?? {})) {
    if (enabled === false) disabledGroups.add(feature);
  }
  const deniedActions = new Map<string, Set<string>>();
  for (const entry of preset?.denied_actions ?? []) {
    const separator = entry.indexOf(':');
    if (separator <= 0) continue;
    const tool = entry.slice(0, separator);
    const actions = deniedActions.get(tool) ?? new Set<string>();
    actions.add(entry.slice(separator + 1));
    deniedActions.set(tool, actions);
  }
  // A preset's own scope belongs to the preset and still applies when the chat works
  // everywhere; only the account's default scope is dropped.
  const working = session.scope ?? account.scope;
  const scope = working && working !== EVERYWHERE ? scopeConfigOf(working) : preset?.scope;
  return {
    presetName: selectedPreset(account, session),
    readOnly: (session.readOnly ?? account.readOnly ?? false) || preset?.read_only === true,
    disabledGroups,
    allowedTools: preset?.allowed_tools?.length ? new Set(preset.allowed_tools) : undefined,
    deniedTools: preset?.denied_tools_regex ? new RegExp(preset.denied_tools_regex) : undefined,
    deniedActions,
    scope,
    scopeEnforcer: scope ? new ScopeEnforcer(scope) : undefined,
  };
}
 
/** What a tool is, as far as the policy is concerned. */
export interface ToolFacts {
  name: string;
  /** Tool group id; undefined for core and context tools. */
  group?: string;
  readOnly: boolean;
}
 
/** Why the policy hides a tool from the caller, or null when it may use it. */
export function toolRestriction(policy: EffectivePolicy, tool: ToolFacts): string | null {
  if (isExemptFromSettings(tool.name)) return null;
  if (policy.operatorProjectIds?.size === 0) {
    return 'no project is permitted by both GITLAB_ALLOWED_PROJECT_IDS and the working scope';
  }
  if (policy.readOnly && !tool.readOnly) return 'read-only mode is on';
  if (tool.group !== undefined && policy.disabledGroups.has(tool.group)) {
    return `the '${tool.group}' tool group is turned off`;
  }
  // A whitelist overrides the deny pattern: the tools it names are the preset's exceptions.
  if (policy.allowedTools) {
    return policy.allowedTools.has(tool.name)
      ? null
      : `preset '${policy.presetName}' does not include it`;
  }
  if (policy.deniedTools?.test(tool.name)) {
    return `preset '${policy.presetName}' excludes it`;
  }
  return null;
}
 
/**
 * The input schema tools/list shows the caller for a tool: without the actions the preset
 * denies, or undefined when the tool is hidden (restricted, or every action denied).
 */
export function listedToolSchema(
  policy: EffectivePolicy,
  tool: ToolFacts,
  inputSchema: Record<string, unknown>,
): Record<string, unknown> | undefined {
  if (toolRestriction(policy, tool) !== null) return undefined;
  const denied = isExemptFromSettings(tool.name) ? undefined : policy.deniedActions.get(tool.name);
  // Matched as the call check matches them: schema action names are lowercase.
  return denied ? withoutSchemaActions(tool.name, inputSchema, denied) : inputSchema;
}
 
/**
 * Why the policy refuses this call, or null when it may run: the tool restriction, an
 * action the preset denies, or a project or group outside the working scope.
 */
export function callRestriction(
  policy: EffectivePolicy,
  tool: ToolFacts,
  args: Record<string, unknown>,
): string | null {
  const restriction = toolRestriction(policy, tool);
  if (restriction) return restriction;
  if (isExemptFromSettings(tool.name)) return null;
  const action = typeof args.action === 'string' ? args.action : undefined;
  if (action !== undefined && policy.deniedActions.get(tool.name)?.has(action)) {
    return `preset '${policy.presetName}' denies the '${action}' action`;
  }
  // The working scope is a focus, not an access boundary: the user sets and clears it. Named
  // targets and destinations are checked here and targetless calls are narrowed or refused,
  // but an object addressed only by its global id (a work item id, a todo id) or a namespace
  // that could be a project or a group is not resolved through GitLab first. A hard boundary
  // is a project or group access token, which GitLab enforces on every call.
  if (policy.scopeEnforcer) {
    try {
      enforceArgsScope(policy.scopeEnforcer, scopeCheckArgs(tool.name, args));
    } catch (error: unknown) {
      if (error instanceof ScopeViolationError) return error.message;
      throw error;
    }
  }
  if (policy.scope) {
    const targetless = targetlessRestriction(tool.name, args, policy.scope);
    if (targetless) return targetless;
  }
  return null;
}