/** * @fileoverview Shared skill-identity contradiction-signal classification * @module @skillsmith/core/services/skill-identity-classification * @see SMI-6343 Wave 3 — tamper-check classification (AC#3) * * Three deterministic-or-best-effort "does this manifest entry's recorded * identity contradict what's actually installed" signals, plus a * "has this on-disk content been locally edited since install" check — * consumed by BOTH `packages/mcp-server/src/tools/outdated.ts` (via * `outdated.identity.ts`) and `packages/cli/src/commands/manage.update.ts`, * so the two packages cannot drift into two independently-maintained * implementations of the same contradiction logic (exactly the "sibling * implementation" bug class both Wave 1 and Wave 2 of SMI-6343 already hit). * * Deliberately decoupled from either caller's own `RegistrySkillInfo` shape * (mcp-server's `install.types.ts` and core's own `skill-installation.types.ts` * both have independently-evolved versions) — callers adapt their own * registry-lookup result into the minimal {@link RegistryLookupOutcome} shape * this module needs. * * Every function here is synchronous and filesystem-free by design: signal 3 * (path containment) is a pure string check against an already-resolved * expected root directory, not a `fs.realpath` call — the callers of this * module (`outdated.ts`, `manage.update.ts`) only ever reach the "outdated" * classification branch after already having successfully read the * installed skill's SKILL.md this same iteration, so "does installPath * exist" is already established by the time these signals run. */ import type { ContentComparisonOutcome } from './skill-content-comparison.js'; import type { ClientId } from '../install/paths.js'; /** Which of the three contradiction signals fired. */ export type IdentitySignal = 'owner-mismatch' | 'frontmatter-contradiction' | 'path-unresolved'; /** Why a classification could not be conclusively determined. */ export type IdentityInconclusiveReason = 'offline' | 'quota-exhausted' | 'network-error' | 'no-registry-record' | 'no-history'; /** The five-state classification `outdated.ts`/`manage.update.ts` report against a divergent entry. */ export type OutdatedClassificationState = 'current' | 'outdated' | 'local-drift' | 'identity-mismatch' | 'unknown'; /** Minimal registry-record shape signal 2 needs. */ export interface IdentityRegistryRecord { author?: string | null; name?: string | null; } /** * The outcome of a caller's OWN registry lookup for the entry's claimed * `id`, adapted into this module's minimal shape. * * `attempted: false` means the caller never even tried this run (offline, * or an earlier skill in the same batch already exhausted quota) — * `failureReason` in that case names WHY it wasn't attempted. `attempted: * true` with `record: null` means the lookup completed but found nothing * for this id (a distinct case from a failed lookup — see * `no-registry-record` below). */ export interface RegistryLookupOutcome { attempted: boolean; record: IdentityRegistryRecord | null; /** Populated when the lookup either wasn't attempted or didn't complete. */ failureReason?: 'offline' | 'quota-exhausted' | 'network-error' | null; } /** The subset of a manifest entry every signal needs. */ export interface ManifestEntryForIdentity { id: string; source: string; installPath: string; client?: ClientId; contentHash?: string; originalContentHash?: string; } /** Result of {@link classifyManifestEntryIdentity}. */ export interface IdentityClassificationResult { /** Which signal fired. Null when no signal fired (regardless of `inconclusive`). */ signal: IdentitySignal | null; /** True when signal 2 (frontmatter) could not be checked at all. */ inconclusive: boolean; /** Populated only when `inconclusive` is true. */ inconclusiveReason: IdentityInconclusiveReason | null; } /** Result of {@link classifyDivergentEntry}. */ export interface DivergentEntryClassification { state: Exclude; signal: IdentitySignal | null; inconclusiveReason: IdentityInconclusiveReason | null; } /** * Parse the owner segment out of a manifest `source` string. Real installs * write `source` as `'github:' + owner + '/' + repo` (`skill-installation. * service.ts`) — the `'github:'` prefix is optional here so a bare * `owner/repo` (or a future non-GitHub source shape) still parses. Returns * `null` for the `'unknown'` distrust sentinel, a raw URL, or anything else * with no parseable `owner/name` shape. */ export declare function parseOwnerFromSource(source: string | undefined | null): string | null; /** * Parse the owner segment out of a manifest entry's `id`. Returns `null` * for anything that isn't a clean `owner/name` pair — most importantly a * raw GitHub URL, which a direct-URL install records as `id` verbatim (see * {@link parseOwnerFromOwnerNamePair}). */ export declare function parseOwnerFromId(id: string | undefined | null): string | null; /** * Signal 1: `id` parses as `owner/name` and `source` names a DIFFERENT * owner. Deterministic, offline, zero network dependency. Catches the real * `linear` skill corruption (`source: "github:lobehub/lobehub"` vs * `id: "wrsmith108/linear"`). */ export declare function detectOwnerMismatch(entry: Pick): boolean; /** * Signal 3: `installPath` is absent, or resolves OUTSIDE `expectedRootDir` * (the claimed client's native install root, or a workspace-scoped * equivalent the caller resolves — see each caller's own scope handling). * * Deliberately a pure string containment check (`path.relative`), not a * filesystem call: both callers of this module only reach this signal after * already having successfully read the entry's SKILL.md this same * iteration, so filesystem existence is already established. This also * catches the exact shape of the SMI-6343 Wave 1 test-fixture leak (an * OS-temp-dir `installPath` written into the real manifest) via simple * string comparison, with no need to resolve symlinks. * * NOT a symlink-escape check (Wave 3 adversarial review, considered and * scoped out): a real `installPath` inside `expectedRootDir` that is * ITSELF a symlink pointing elsewhere is a local-filesystem-tampering * threat distinct from this signal's actual purpose (catching a manifest * entry whose *recorded* path doesn't match a legitimate install * location — a software-bug/registry-tamper shape, not local attacker * capability); an attacker with write access to plant such a symlink * inside the client root already has equivalent-or-greater capability to * edit SKILL.md content directly, making a `fs.realpath` upgrade here * (which would also force this whole synchronous, filesystem-free module * to become async) address a threat this signal was never meant to cover. * * The parent-segment check below (`rel === '..' || rel.startsWith('..' + * sep)`) is deliberately NOT a bare `rel.startsWith('..')`: a literal * directory name that happens to start with two dots (e.g. `..cache`) is a * legitimate contained child — `path.relative('/r', '/r/..cache')` returns * `'..cache'`, which a bare `startsWith('..')` would misclassify as an * escape (Wave 3 adversarial review finding). */ export declare function detectPathUnresolved(installPath: string | undefined | null, expectedRootDir: string): boolean; /** * Run all three contradiction signals against a manifest entry. Any one * conclusively firing means `signal` is non-null — order (1, 3, 2) checks * the two deterministic/offline signals first, only falling to the * network-dependent signal 2 when neither of those already answered the * question. */ export declare function classifyManifestEntryIdentity(params: { entry: ManifestEntryForIdentity; localContent: string | null; expectedRootDir: string; registryLookup: RegistryLookupOutcome; }): IdentityClassificationResult; /** * Has the on-disk content changed since the manifest's own recorded * install/update-time hash? This is what distinguishes a benign local edit * (`local-drift`) from a genuine registry version bump (`outdated`) once * no identity signal has fired. * * When the manifest never recorded a comparable hash at all (a legacy or * adopted entry, `contentHash`/`originalContentHash` both absent), this * deliberately defaults to `false` (no edit evidence) rather than `true` * (conservative) — absence of a recorded hash is a data-quality gap, not * positive evidence of tampering, and defaulting to `true` here would * misclassify the large population of legacy/adopted entries that never had * a hash recorded as `local-drift` even when they are genuinely just * outdated (SMI-6343 Wave 3 review finding). */ export declare function hasRecordedLocalEdit(entry: Pick, localHash: string | null): boolean; /** * Classify an entry the caller has ALREADY determined is divergent from the * registry (mcp-server: `compareSkillContentHashes(...).outcome === * 'outdated'`; CLI: `getSkillDiff()` found a version/content difference). * Never returns `'current'` — callers own that trivial case themselves, * since it never needs signal evaluation at all. */ export declare function classifyDivergentEntry(params: { entry: ManifestEntryForIdentity; localHash: string | null; localContent: string | null; expectedRootDir: string; registryLookup: RegistryLookupOutcome; }): DivergentEntryClassification; /** * Full 5-state classification given an already-computed content-comparison * outcome (from `compareSkillContentHashes`) plus the caller-derived reason * a plain `'unknown'` comparison outcome couldn't be resolved (offline, * quota-exhausted, network-error, or no-history — the caller already tracks * which applies; see `outdated.ts`'s `deriveUnknownReason`). */ export declare function classifyOutdatedState(params: { comparisonOutcome: ContentComparisonOutcome; unknownReasonWhenComparisonUnknown: IdentityInconclusiveReason; entry: ManifestEntryForIdentity; localHash: string | null; localContent: string | null; expectedRootDir: string; registryLookup: RegistryLookupOutcome; }): { state: OutdatedClassificationState; signal: IdentitySignal | null; inconclusiveReason: IdentityInconclusiveReason | null; }; //# sourceMappingURL=skill-identity-classification.d.ts.map