Code

/**
 * mcp/skills/surveyApp.ts — survey_app
 *
 * Coarse-grained read of one application: whether it is installed, whether its
 * bundle is intact, and what system permissions it holds.
 *
 * Why coarse
 * ----------
 * `software-reinstall` opens with all three unconditionally, because none of
 * them alone distinguishes the cases it has to tell apart — an app that was
 * never installed, one whose binary is corrupt, and one that is fine but has
 * been denied a permission it needs. Three plan steps, three executor
 * iterations, one conclusion.
 *
 * The fine-grained tools remain registered and are NOT deprecated.
 *
 * What it refuses to decide
 * -------------------------
 * Whether to reinstall. `signatureValid: false` means the bundle is corrupt and
 * non-destructive fixes are pointless; a denied permission means the opposite.
 * Which of those the user is actually reporting is the skill's call, and there
 * is a user gate in the workflow for exactly that choice.
 *
 * Ordering note: integrity and permissions both need the app to exist, so this
 * looks it up first and skips them when it does not — reporting `installed:
 * false` rather than two spurious not-found results.
 */
 
import { run as listInstalledApps }   from "./listInstalledApps";
import { run as checkAppIntegrity }   from "./checkAppIntegrity";
import {
  run as checkAppPermissions,
  meta as permissionsMeta,
} from "./checkAppPermissions";
 
// -- Meta ---------------------------------------------------------------------
 
export const meta = {
  name: "survey_app",
  description:
    "Reads one application's state in one call: whether it is installed, whether " +
    "its code signature and Gatekeeper approval are intact, and which system " +
    "permissions it has been granted or denied. Read-only. Use at the start of an " +
    "application-repair workflow instead of list_installed_apps, " +
    "check_app_integrity and check_app_permissions separately.",
  riskLevel:       "low",
  destructive:     false,
  requiresConsent: false,
  supportsDryRun:  false,
  affectedScope:   ["user"],
  auditRequired:   false,
  outputKeys: [
    "appName",
    "installed",
    "matches",
    "bundleState",
    "integrity",
    "permissions",
  ],
  // Borrowed so the app-name validation cannot drift from the tools called.
  schema: { appName: permissionsMeta.schema.appName },
} as const;
 
// -- Types --------------------------------------------------------------------
 
export interface SurveyAppResult {
  appName: string;
  /** True when the catalogue lookup matched at least one installed app. */
  installed: boolean;
  /**
   * Every catalogue match, raw. More than one means the name was ambiguous —
   * act on none of them until the user has picked, exactly as a duplicate MDM
   * serial is treated.
   */
  matches: unknown[];
  /**
   * The one question the workflow actually asks of the two fields above:
   * is there a bundle, and can it be trusted?
   *
   *   "absent"     — nothing installed. Install from scratch.
   *   "corrupt"    — the seal is broken. Resets cannot help; reinstall.
   *   "intact"     — verified good.
   *   "unverified" — present, but the check did not complete. NOT evidence of
   *                  corruption, and must never be treated as "corrupt".
   *
   * This lives here rather than in prose because every skill reading
   * `signatureValid` had to re-derive it against `installed`, and getting it
   * wrong is expensive in one direction only: `software-reinstall` read a
   * null as corruption on 2026-09-04 and reached `uninstall_app` on a healthy
   * app. One right answer, computed once.
   */
  bundleState: "absent" | "corrupt" | "intact" | "unverified";
  /**
   * Code signature and Gatekeeper state. Null when the app is not installed —
   * absence, not a failed check.
   */
  integrity: unknown | null;
  /** Granted / denied system permissions. Null when the app is not installed. */
  permissions: unknown | null;
}
 
/**
 * Collapse `installed` + `signatureValid` into the state the caller needs.
 *
 * Exported for tests.
 */
export function deriveBundleState(
  installed: boolean,
  signatureValid: unknown,
): "absent" | "corrupt" | "intact" | "unverified" {
  if (!installed)              return "absent";
  if (signatureValid === true) return "intact";
  if (signatureValid === false) return "corrupt";
  return "unverified";
}
 
/**
 * The bundle path of the single unambiguous match, or null.
 *
 * Deliberately null on more than one match: an ambiguous name must not have one
 * arbitrary candidate's path silently attached to it, for the same reason a
 * duplicate MDM serial is refused rather than guessed. Integrity then falls back
 * to its own by-name lookup and the caller still sees every match.
 *
 * Exported for tests.
 */
export function bundlePath(matches: unknown[]): string | null {
  if (matches.length !== 1) return null;
  const p = (matches[0] as { path?: unknown } | null)?.path;
  return typeof p === "string" && p.length > 0 ? p : null;
}
 
// -- Implementation -----------------------------------------------------------
 
export async function run(
  { appName }: { appName: string },
): Promise<SurveyAppResult> {
  const listed = await listInstalledApps({ filter: appName });
  const matches = (listed.apps ?? []) as unknown[];
 
  if (matches.length === 0) {
    return {
      appName, installed: false, matches,
      bundleState: "absent",
      integrity: null, permissions: null,
    };
  }
 
  // The listing has already resolved the real bundle — hand its path to the
  // integrity probe rather than letting it search again by name.
  //
  // checkAppIntegrity's own lookup is an exact `<name>.app` match
  // (findAppDarwin), so any app whose bundle directory differs from the name a
  // user types comes back `found: false` while the listing above says
  // `installed: true` — a result that contradicts itself. Observed 2026-09-04:
  // "Zoom" installs to /Applications/zoom.us.app, integrity reported not-found,
  // `signatureValid` landed `null`, and software-reinstall read that as "the
  // binary is not intact", skipped its whole non-destructive branch and called
  // `uninstall_app` on a healthy install. Only an unavailable helper stopped it.
  const resolvedPath = bundlePath(matches);
 
  // Both need the app present; run them together once that is established.
  const [integrity, permissions] = await Promise.all([
    checkAppIntegrity(resolvedPath ? { appName, appPath: resolvedPath } : { appName }),
    checkAppPermissions({ appName }),
  ]);
 
  const signatureValid = (integrity as { signatureValid?: unknown } | null)?.signatureValid;
 
  return {
    appName, installed: true, matches,
    bundleState: deriveBundleState(true, signatureValid),
    integrity, permissions,
  };
}