All files / src/configuration operator-projects.ts

100% Statements 80/80
100% Branches 57/57
100% Functions 17/17
100% Lines 70/70

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 14734x 34x 34x 34x 34x 34x 34x 34x                   34x         132x 132x   29x 5x 24x   103x   103x         1x   102x 102x       1x   101x               34x   1x   4x 6x   3x 3x     5x       2x       34x 36x 14x 14x 13x 12x 12x 4x     9x             34x   7x       22x 21x 21x 24x 23x 23x 21x 19x 18x   21x   23x   21x     26x       21x 21x 22x 17x 16x           2x 14x   16x 16x 16x 17x 13x 13x   16x 16x       13x   13x      
import { GITLAB_ALLOWED_PROJECT_IDS } from '../config';
import { extractProjectsFromArgs, ScopeEnforcer } from '../profiles/scope-enforcer';
import { canonicalProjectPath } from '../utils/api-path';
import { normalizeProjectId } from '../utils/projectIdentifier';
import { gitlab } from '../utils/gitlab-api';
import { mapBounded } from '../utils/bounded-map';
import { parseGitLabApiError } from '../utils/error-handler';
import { withScopePaths, type ReadResource } from './scope-ids';
import type { EffectivePolicy } from './policy';
 
interface AllowedProject {
  id: string;
  path: string;
  name: string;
}
 
/** A project identity from GitLab, using one canonical form for IDs, paths and the picker. */
export async function readAllowedProject(
  input: string,
  read: ReadResource,
): Promise<AllowedProject | undefined> {
  let result: unknown;
  try {
    result = await read(`projects/${normalizeProjectId(input)}`);
  } catch (error: unknown) {
    if (error instanceof Error && parseGitLabApiError(error.message)?.status === 404)
      return undefined;
    throw error;
  }
  const project = result as
    { id?: unknown; path_with_namespace?: unknown; name_with_namespace?: unknown } | undefined;
  if (
    (typeof project?.id !== 'number' && typeof project?.id !== 'string') ||
    typeof project.path_with_namespace !== 'string' ||
    typeof project.name_with_namespace !== 'string'
  ) {
    throw new TypeError('GitLab did not return a verifiable allowed project identity');
  }
  const id = String(project.id);
  if (
    !/^[1-9]\d*$/.test(id) ||
    (typeof project.id === 'number' && !Number.isSafeInteger(project.id))
  ) {
    throw new Error('GitLab returned an invalid allowed project ID');
  }
  return {
    id,
    path: canonicalProjectPath(project.path_with_namespace),
    name: project.name_with_namespace,
  };
}
 
/** The settings picker may leave the working scope, but never the operator's project list. */
export async function findOperatorProjectTargets(
  query: string,
  read: ReadResource = (path) => gitlab.get(path),
): Promise<Array<{ type: 'project'; path: string; name: string }>> {
  const projects = await mapBounded([...new Set(GITLAB_ALLOWED_PROJECT_IDS)], 5, (input) =>
    readAllowedProject(input, read),
  );
  const search = query.toLowerCase();
  return projects
    .filter(
      (project): project is AllowedProject =>
        project !== undefined &&
        (project.path.toLowerCase().includes(search) ||
          project.name.toLowerCase().includes(search)),
    )
    .map((project) => ({ type: 'project', path: project.path, name: project.name }));
}
 
/** Refuse a numeric project outside a numeric allowlist without any GitLab lookup. */
export function operatorProjectRejection(args: Record<string, unknown>): string | null {
  if (GITLAB_ALLOWED_PROJECT_IDS.length === 0) return null;
  const allowed = GITLAB_ALLOWED_PROJECT_IDS.map(canonicalProjectPath);
  if (!allowed.every((id) => /^\d+$/.test(id))) return null;
  for (const target of extractProjectsFromArgs(args)) {
    const project = canonicalProjectPath(target);
    if (/^\d+$/.test(project) && !allowed.includes(project)) {
      return `Project ${project} is not allowed by GITLAB_ALLOWED_PROJECT_IDS`;
    }
  }
  return null;
}
 
/**
 * A per-call intersection: operator projects cannot be widened by account or chat settings.
 * Identity lookups use the caller's token and instance, never a process-wide identity cache.
 */
export async function withOperatorProjects(
  configured: EffectivePolicy,
  read: ReadResource = (path) => gitlab.get(path),
  expired?: () => boolean,
  requestedTargets?: readonly string[],
): Promise<EffectivePolicy> {
  if (GITLAB_ALLOWED_PROJECT_IDS.length === 0) return configured;
  const reads = new Map<string, Promise<unknown>>();
  const once: ReadResource = (path) => {
    if (expired?.()) throw new Error('Tool call expired during operator project verification');
    let pending = reads.get(path);
    if (!pending) {
      pending = read(path).then((result) => {
        if (expired?.()) throw new Error('Tool call expired during operator project verification');
        return result;
      });
      reads.set(path, pending);
    }
    return pending;
  };
  const policy = await withScopePaths(configured, once);
  // A numeric operator list already supplies the complete ID set. An explicit call
  // needs metadata only for its targets; targetless feeds still need all project paths.
  const numericAllowed = GITLAB_ALLOWED_PROJECT_IDS.every((id) => /^[1-9]\d*$/.test(id))
    ? new Set(GITLAB_ALLOWED_PROJECT_IDS)
    : undefined;
  const inputs =
    numericAllowed && requestedTargets?.length ? requestedTargets : GITLAB_ALLOWED_PROJECT_IDS;
  const projects = await mapBounded([...new Set(inputs)], 5, async (input) => {
    const project = await readAllowedProject(input, once);
    if (project && numericAllowed && !numericAllowed.has(project.id)) return undefined;
    if (
      project &&
      policy.scopeEnforcer &&
      !policy.scopeEnforcer.listsId(project.id, 'project') &&
      !policy.scopeEnforcer.isAllowed(project.path)
    )
      return undefined;
    return project;
  });
  const selected: AllowedProject[] = [];
  const seen = new Set<string>();
  for (const project of projects) {
    if (!project || seen.has(project.id)) continue;
    seen.add(project.id);
    selected.push(project);
  }
  const scope = { projects: selected.map((project) => project.path) };
  return {
    ...policy,
    scope,
    scopeEnforcer: new ScopeEnforcer({
      projects: selected.flatMap((project) => [project.id, project.path]),
    }),
    operatorProjectIds: new Set(selected.map((project) => project.id)),
  };
}