refactor(packages): dissolve ui/ and rename sdk/ to scaffold/

git mv per the regrouping RFC: the five human-collaboration seams and
tui join packages/interaction/, app-boot becomes packages/boot/, and
jsonrpc joins the renamed scaffold/ (formerly sdk/) as its server half
beside client/protocol/create-sdk/helper/scripts/telemetry, whose
folders drop the legacy sdk- prefix. Three new group README triplets
replace the ui/ and sdk/ ones; tsconfig references/paths/globs,
knip keys, vitest globs, gate scripts, catalogs, docs, and the
lockfile follow. Adds the four settled FIXME rename markers
(dsh-sdk-server, dsh-sdk-telemetry, dsh-sdk-helper, dsh-sdk-scripts).

The scaffold folders diverge from their npm names until those renames
land, so tsconfig.base.json maps the three affected names explicitly
beside the group wildcard. Also repairs two pre-existing stale-path
classes the strengthened sweep surfaced: docs/web-styling.md's retired
web-ui host package and type-model spec fixture-literal joins.

app-boot's three Loader-composition specs time out at the default 5s
under full-suite parallel load on this filesystem (pre-existing;
pass isolated with --testTimeout=30000); interaction/scaffold/boot
suites otherwise green (687 passed).
This commit is contained in:
Tianyi Cui
2026-07-30 03:13:49 +08:00
parent 7e445c3a67
commit 3fc35c91ff
351 changed files with 368 additions and 311 deletions
@@ -0,0 +1,93 @@
/**
* Per-harness-home anonymous telemetry id.
*
* The id is a random UUID persisted directly in the harness home resolved by
* {@link resolveDshHome} (`$DSH_HOME` > `~/.dsh`), and never derived from the
* git remote, repository URL, or any other identifying source (a derived id
* would make "anonymous" a fiction). The id is scoped to the harness home, not
* the machine: every command sharing one `$DSH_HOME` reuses the same id, so the
* default `~/.dsh` counts per-OS-user home directories, while a relocated
* `$DSH_HOME` moves the id with the rest of the harness data — the single-root
* convention this package shares, not a telemetry-specific policy.
*
* @module @deepseek-ai/dsh-telemetry/anonymous-id
*/
import { randomUUID } from 'node:crypto'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import type { Branded } from '@deepseek-ai/dsh-brand'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
/** A harness-home-scoped anonymous telemetry id (random UUID v4). */
export type AnonymousId = Branded<'AnonymousId'>
/** Default file, inside the harness home, storing the anonymous id. */
export const ANONYMOUS_ID_FILE_NAME = 'telemetry.json'
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
/** Ambient seams for locating and generating the id; every field has a default. */
export interface AnonymousIdOptions {
/** Environment consulted for `DSH_HOME`; defaults to `process.env`. */
env?: NodeJS.ProcessEnv
/** UUID generator; defaults to `crypto.randomUUID` (test seam). */
randomUUID?: () => string
}
/**
* Resolve the single-root harness home that stores the anonymous id.
* Delegates to {@link resolveDshHome} so telemetry shares the harness's one
* home-resolution policy (`DSH_HOME` > `~/.dsh`) instead of maintaining a
* second config-directory convention.
* @param options - environment seam.
* @returns absolute harness home path.
*/
export function globalConfigDir(options: AnonymousIdOptions = {}): string {
return resolveDshHome(undefined, options.env ?? process.env)
}
/** Read a valid persisted id from the store, or `undefined` when absent/corrupt. */
async function readPersistedId(file: string): Promise<AnonymousId | undefined> {
let text: string
try {
text = await readFile(file, 'utf8')
} catch {
// Absent or unreadable: the caller mints and persists a fresh id.
return undefined
}
let parsed: unknown
try {
parsed = JSON.parse(text)
} catch {
// Corrupt JSON: the caller overwrites the store with a fresh id.
return undefined
}
if (parsed !== null && typeof parsed === 'object') {
const value = (parsed as Record<string, unknown>).anonymousId
if (typeof value === 'string' && UUID_PATTERN.test(value)) return value as AnonymousId
}
return undefined
}
/**
* Return the harness home's anonymous id, creating and persisting one on first use.
* Persistence is best-effort: a write failure still returns a usable id for the
* current run so telemetry is never blocked by config-dir permissions.
* @param options - config-location and UUID-generation seams.
* @returns the stable per-harness-home anonymous id.
*/
export async function getOrCreateAnonymousId(options: AnonymousIdOptions = {}): Promise<AnonymousId> {
const file = join(globalConfigDir(options), ANONYMOUS_ID_FILE_NAME)
const existing = await readPersistedId(file)
if (existing !== undefined) return existing
const generate = options.randomUUID ?? randomUUID
const created = generate() as AnonymousId
try {
await mkdir(dirname(file), { recursive: true })
await writeFile(file, `${JSON.stringify({ anonymousId: created }, null, 2)}\n`, 'utf8')
} catch {
// Best-effort persistence: return the fresh id even when the store is unwritable.
}
return created
}
@@ -0,0 +1,125 @@
/**
* Consent resolution for dsh-sdk telemetry.
*
* Telemetry is OFF only when `cordis.yml` contains a telemetry entry that is
* explicitly `disabled`; every other file state reports (no `cordis.yml`, an
* enabled entry, or no telemetry entry at all). The resolver PARSES `cordis.yml`
* — it never boots a Cordis application — because several launcher commands
* (`build`, `create`) never boot Cordis at all. `DO_NOT_TRACK` and CI
* environment signals force a denial regardless of file state.
*
* @module @deepseek-ai/dsh-telemetry/consent-resolver
*/
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { parseDocument, type ScalarTag } from 'yaml'
/** Default `cordis.yml` entry name that carries telemetry consent. */
export const DEFAULT_TELEMETRY_PLUGIN_NAME = '@deepseek-ai/dsh-telemetry'
/**
* Passthrough for Cordis' `!!js` expression tag so parsing consent never fails
* on projects that inline JavaScript expressions; the resolver only reads plain
* `name`/`disabled` scalars and does not evaluate expressions.
*/
const JS_EXPRESSION_TAG: ScalarTag = {
tag: 'tag:yaml.org,2002:js',
resolve: value => value,
}
/** Why telemetry is or is not permitted for one command. */
export type ConsentReason =
| 'enabled'
| 'disabled'
| 'absent'
| 'no-config'
| 'do-not-track'
| 'ci'
| 'unreadable'
/** Resolved telemetry consent for one command invocation. */
export interface ConsentDecision {
/** Whether telemetry may be sent. */
allowed: boolean
/** The signal that determined {@link allowed}. */
reason: ConsentReason
}
/** Tuning for {@link ConsentResolver}; every field defaults to a documented value. */
export interface ConsentResolverOptions {
/** `cordis.yml` entry name whose enabled state carries consent. */
telemetryPluginName?: string
/** Environment used for `DO_NOT_TRACK`/CI checks; defaults to `process.env`. */
env?: NodeJS.ProcessEnv
/** Honor `DO_NOT_TRACK`/CI env signals as a hard opt-out. Defaults to `true`. */
honorEnvOptOut?: boolean
/** Consent when `cordis.yml` does not exist yet (first `create`). Defaults to `true` (telemetry is default-on). */
allowWhenNoConfig?: boolean
/** Consent when `cordis.yml` exists but has no telemetry entry. Defaults to `true` (report unless a present entry is disabled). */
allowWhenEntryAbsent?: boolean
}
/** Whether an environment variable is set to a non-empty, non-"0"/"false" value. */
function envEnabled(value: string | undefined): boolean {
if (value === undefined) return false
const normalized = value.trim().toLowerCase()
return normalized.length > 0 && normalized !== '0' && normalized !== 'false'
}
/** Read a `cordis.yml` entry's `name`/`disabled` scalars, tolerating `!!js` tags. */
function readTelemetryEntry(text: string, pluginName: string): { present: boolean; disabled: boolean } {
const document = parseDocument(text, { customTags: [JS_EXPRESSION_TAG] })
const contents: unknown = document.toJS({ maxAliasCount: -1 })
if (!Array.isArray(contents)) return { present: false, disabled: false }
for (const entry of contents) {
if (entry === null || typeof entry !== 'object') continue
const record = entry as Record<string, unknown>
if (record.name === pluginName) return { present: true, disabled: record.disabled === true }
}
return { present: false, disabled: false }
}
/** Resolve telemetry consent by parsing a project's `cordis.yml` and the environment. */
export class ConsentResolver {
readonly #pluginName: string
readonly #env: NodeJS.ProcessEnv
readonly #honorEnvOptOut: boolean
readonly #allowWhenNoConfig: boolean
readonly #allowWhenEntryAbsent: boolean
/** @param options - plugin name, environment, and default-decision knobs. */
constructor(options: ConsentResolverOptions = {}) {
this.#pluginName = options.telemetryPluginName ?? DEFAULT_TELEMETRY_PLUGIN_NAME
this.#env = options.env ?? process.env
this.#honorEnvOptOut = options.honorEnvOptOut ?? true
this.#allowWhenNoConfig = options.allowWhenNoConfig ?? true
this.#allowWhenEntryAbsent = options.allowWhenEntryAbsent ?? true
}
/**
* Resolve consent for a command run in the given project directory.
* @param projectDir - absolute or relative project root containing `cordis.yml`.
* @returns the consent decision and the signal that produced it.
*/
async resolve(projectDir: string): Promise<ConsentDecision> {
if (this.#honorEnvOptOut) {
if (envEnabled(this.#env.DO_NOT_TRACK)) return { allowed: false, reason: 'do-not-track' }
if (envEnabled(this.#env.CI)) return { allowed: false, reason: 'ci' }
}
let text: string
try {
text = await readFile(join(projectDir, 'cordis.yml'), 'utf8')
} catch (error) {
// Missing cordis.yml is the first-init (`create`) path; any other read
// fault is treated conservatively as its own reason.
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return { allowed: this.#allowWhenNoConfig, reason: 'no-config' }
}
return { allowed: false, reason: 'unreadable' }
}
const entry = readTelemetryEntry(text, this.#pluginName)
if (!entry.present) return { allowed: this.#allowWhenEntryAbsent, reason: 'absent' }
return entry.disabled ? { allowed: false, reason: 'disabled' } : { allowed: true, reason: 'enabled' }
}
}
+49
View File
@@ -0,0 +1,49 @@
/**
* Launcher-side telemetry for the dsh-sdk toolchain: secret redaction, consent
* resolution, anonymous id, payload assembly, and a fire-and-forget reporter.
*
* This package is a plain library the launcher imports around each command — it
* is NOT a Cordis plugin (several commands never boot Cordis). Wiring it into
* the launcher command dispatch and the helper feature catalog lives outside
* this package.
*
* FIXME: rename to `@deepseek-ai/dsh-sdk-telemetry` before the first tagged release —
* the current name collides with the `dsh-session-telemetry` family; this is
* launcher-side SDK telemetry ([regrouping Agent Note](../../../../.agents/notes/proposed/architecture/2026-07-29-package-regrouping.md)).
*
* @module @deepseek-ai/dsh-telemetry
*/
export {
DEFAULT_ENTROPY_THRESHOLD,
DEFAULT_MIN_TOKEN_LENGTH,
DEFAULT_REDACTION_PLACEHOLDER,
SecretRedactor,
keyLooksSecret,
} from './secret-redactor.ts'
export type { SecretRedactorOptions } from './secret-redactor.ts'
export {
ConsentResolver,
DEFAULT_TELEMETRY_PLUGIN_NAME,
} from './consent-resolver.ts'
export type {
ConsentDecision,
ConsentReason,
ConsentResolverOptions,
} from './consent-resolver.ts'
export {
ANONYMOUS_ID_FILE_NAME,
getOrCreateAnonymousId,
globalConfigDir,
} from './anonymous-id.ts'
export type { AnonymousId, AnonymousIdOptions } from './anonymous-id.ts'
export { buildTelemetryPayload } from './payload.ts'
export type { BuildTelemetryPayloadInput, TelemetryPayload } from './payload.ts'
export {
DEFAULT_FLUSH_TIMEOUT_MS,
DEFAULT_SEND_TIMEOUT_MS,
DSH_TELEMETRY_ENDPOINT,
TELEMETRY_SCHEMA_VERSION,
TelemetryReporter,
} from './reporter.ts'
export type { DeliveryOutcome, TelemetryReporterOptions } from './reporter.ts'
@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-telemetry`.
* @module @deepseek-ai/dsh-telemetry/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-telemetry'
/** Cordis companion plugin name. */
export const name = 'telemetry-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this SDK build-time package owns no live event stream or mutable data;
* generated output and consumer tests cover its contract.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */
@@ -0,0 +1,82 @@
/**
* Telemetry payload assembly.
*
* The payload carries the command lifecycle plus the FULL redacted content of
* the project `cordis.yml` and `package.json`. It NEVER reads or includes `.env`
* — secrets live only in `.env`, and the redactor is the backstop for any that
* leak into the two reported files. A file that does not exist (the first
* `create` run) simply omits its field, and `package.json` ships only when
* `cordis.yml` is present: without it the directory is not an SDK project, and
* its manifest belongs to whatever unrelated project the command ran in.
*
* @module @deepseek-ai/dsh-telemetry/payload
*/
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { SecretRedactor } from './secret-redactor.ts'
/** Project files whose full (redacted) content ships with the payload. */
const REPORTED_FILES = ['cordis.yml', 'package.json'] as const
/** One command's telemetry payload. */
export interface TelemetryPayload {
/** The dsh-sdk command that ran (`start`/`dev`/`build`/`config`/`create`). */
command: string
/** Wall-clock duration of the command in milliseconds. */
durationMs: number
/** Whether the command completed without error. */
success: boolean
/** Redacted full text of the project `cordis.yml`, absent when the file does not exist. */
cordisYmlContent?: string
/** Redacted full text of the project `package.json`, absent when it or `cordis.yml` does not exist. */
packageJsonContent?: string
}
/** Inputs for {@link buildTelemetryPayload}. */
export interface BuildTelemetryPayloadInput {
/** The dsh-sdk command that ran. */
command: string
/** Wall-clock duration of the command in milliseconds. */
durationMs: number
/** Whether the command completed without error. */
success: boolean
/** Project root whose `cordis.yml` and `package.json` are read. */
projectDir: string
/** Redactor applied to reported file content; defaults to a fresh {@link SecretRedactor}. */
redactor?: SecretRedactor
}
/** Read a project file's text, returning `undefined` when it cannot be read. */
async function readReportedFile(projectDir: string, name: string): Promise<string | undefined> {
try {
return await readFile(join(projectDir, name), 'utf8')
} catch {
// Missing/unreadable reported file: telemetry omits the field rather than fail.
return undefined
}
}
/**
* Assemble a redacted telemetry payload for one command invocation.
* @param input - command lifecycle facts, project directory, and optional redactor.
* @returns the payload with redacted `cordis.yml`/`package.json` content.
*/
export async function buildTelemetryPayload(input: BuildTelemetryPayloadInput): Promise<TelemetryPayload> {
const redactor = input.redactor ?? new SecretRedactor()
const [cordisYml, packageJson] = await Promise.all(
REPORTED_FILES.map(name => readReportedFile(input.projectDir, name)),
)
return {
command: input.command,
durationMs: input.durationMs,
success: input.success,
...cordisYml !== undefined ? { cordisYmlContent: redactor.redactText(cordisYml) } : {},
// package.json is an SDK-project manifest only alongside cordis.yml; a
// command run in an arbitrary directory must not upload that directory's
// unrelated manifest.
...cordisYml !== undefined && packageJson !== undefined
? { packageJsonContent: redactor.redactText(packageJson) }
: {},
}
}
+148
View File
@@ -0,0 +1,148 @@
/**
* Fire-and-forget telemetry reporter for the dsh-sdk launcher.
*
* The reporter must NEVER block or crash a command: {@link TelemetryReporter.report}
* schedules a detached send and returns immediately, and the underlying delivery
* resolves on every path (consent skip, network failure, non-OK status) instead
* of rejecting. {@link TelemetryReporter.flush} lets the launcher optionally
* drain in-flight sends within a cap before exit.
*
* @module @deepseek-ai/dsh-telemetry/reporter
*/
import type { ConsentDecision } from './consent-resolver.ts'
import type { TelemetryPayload } from './payload.ts'
import { getOrCreateAnonymousId, type AnonymousId } from './anonymous-id.ts'
import { SecretRedactor } from './secret-redactor.ts'
/**
* Fail-safe placeholder collection endpoint. The `.invalid` TLD guarantees
* delivery fails harmlessly until a collector is deployed. This is a fixed
* protocol constant, not a deployment tunable.
*/
// TODO(telemetry-endpoint): Replace the placeholder before release.
export const DSH_TELEMETRY_ENDPOINT = 'https://telemetry.example.invalid/v1/dsh-sdk'
/** Wire-envelope schema version; bump on any incompatible body change. */
export const TELEMETRY_SCHEMA_VERSION = 1
/** Default per-request send timeout in milliseconds. */
export const DEFAULT_SEND_TIMEOUT_MS = 3000
/** Default cap for {@link TelemetryReporter.flush} in milliseconds. */
export const DEFAULT_FLUSH_TIMEOUT_MS = 2000
/** Outcome of one delivery attempt; delivery never rejects. */
export type DeliveryOutcome =
| { status: 'skipped'; reason: string }
| { status: 'sent' }
| { status: 'failed'; error: string }
/** The JSON body posted to the telemetry endpoint. */
interface TelemetryEnvelope extends TelemetryPayload {
schemaVersion: number
anonymousId: AnonymousId
sentAt: string
}
/** Injectable seams for {@link TelemetryReporter}; every field has a default. */
export interface TelemetryReporterOptions {
/** Collection endpoint; defaults to {@link DSH_TELEMETRY_ENDPOINT}. */
endpoint?: string
/** `fetch` implementation; defaults to the global `fetch`. */
fetch?: typeof globalThis.fetch
/** Anonymous-id provider; defaults to {@link getOrCreateAnonymousId}. */
anonymousId?: () => Promise<AnonymousId>
/** Redactor applied to the assembled envelope as a final backstop; defaults to a fresh {@link SecretRedactor}. */
redactor?: SecretRedactor
/** Per-request send timeout in milliseconds. */
timeoutMs?: number
/** Clock for the envelope timestamp; defaults to `Date.now`. */
now?: () => number
}
/** Sends telemetry payloads fire-and-forget, swallowing every failure. */
export class TelemetryReporter {
readonly #endpoint: string
readonly #fetch: typeof globalThis.fetch
readonly #anonymousId: () => Promise<AnonymousId>
readonly #redactor: SecretRedactor
readonly #timeoutMs: number
readonly #now: () => number
readonly #inflight = new Set<Promise<DeliveryOutcome>>()
/** @param options - endpoint, transport, id provider, and timing seams. */
constructor(options: TelemetryReporterOptions = {}) {
this.#endpoint = options.endpoint ?? DSH_TELEMETRY_ENDPOINT
this.#fetch = options.fetch ?? globalThis.fetch
this.#anonymousId = options.anonymousId ?? getOrCreateAnonymousId
this.#redactor = options.redactor ?? new SecretRedactor()
this.#timeoutMs = options.timeoutMs ?? DEFAULT_SEND_TIMEOUT_MS
this.#now = options.now ?? Date.now
}
/**
* Schedule a detached, non-blocking send. Returns immediately and never
* throws; the send's outcome is observable only through {@link flush}.
* @param payload - the command payload to report.
* @param consent - resolved consent; a denial short-circuits to a skip.
*/
report(payload: TelemetryPayload, consent: ConsentDecision): void {
const pending = this.#deliver(payload, consent)
this.#inflight.add(pending)
void pending.finally(() => this.#inflight.delete(pending))
}
/**
* Await in-flight sends up to a timeout so a caller can drain before exit.
* Resolves on the cap regardless of send progress; never rejects.
* @param timeoutMs - maximum time to wait; defaults to {@link DEFAULT_FLUSH_TIMEOUT_MS}.
*/
async flush(timeoutMs: number = DEFAULT_FLUSH_TIMEOUT_MS): Promise<void> {
if (this.#inflight.size === 0) return
const drained = Promise.allSettled([...this.#inflight]).then(() => undefined)
let timer!: ReturnType<typeof setTimeout>
const capped = new Promise<void>((resolve) => {
timer = setTimeout(resolve, timeoutMs)
})
try {
await Promise.race([drained, capped])
} finally {
clearTimeout(timer)
}
}
/** Deliver one payload, resolving to an outcome on every path (never rejects). */
async #deliver(payload: TelemetryPayload, consent: ConsentDecision): Promise<DeliveryOutcome> {
if (!consent.allowed) return { status: 'skipped', reason: consent.reason }
try {
const envelope: TelemetryEnvelope = {
schemaVersion: TELEMETRY_SCHEMA_VERSION,
anonymousId: await this.#anonymousId(),
sentAt: new Date(this.#now()).toISOString(),
...payload,
// Idempotent backstop over the only free-form fields, in case a caller
// built the payload without buildTelemetryPayload. Applied to content
// text only so the anonymous id and metadata are never disturbed.
...payload.cordisYmlContent !== undefined
? { cordisYmlContent: this.#redactor.redactText(payload.cordisYmlContent) }
: {},
...payload.packageJsonContent !== undefined
? { packageJsonContent: this.#redactor.redactText(payload.packageJsonContent) }
: {},
}
const response = await this.#fetch(this.#endpoint, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(envelope),
signal: AbortSignal.timeout(this.#timeoutMs),
})
if (!response.ok) return { status: 'failed', error: `HTTP ${response.status}` }
return { status: 'sent' }
} catch (error) {
// Telemetry is best-effort: network faults, aborts, and id/redaction
// errors are swallowed so the command is never affected.
return { status: 'failed', error: error instanceof Error ? error.message : String(error) }
}
}
}
@@ -0,0 +1,208 @@
/**
* Conservative secret redactor: the safety backstop that scrubs credential-like
* values from telemetry content before it leaves the machine.
*
* The redactor never drops a field or line — it only replaces the secret-shaped
* VALUE with a fixed placeholder, so the surrounding structure (keys, package
* names, base URLs, dependency pins) stays intact for the maintainer. It leans
* toward redaction on strong signals (secret-like key names, known token
* shapes, PEM blocks, URL credentials, high-entropy opaque tokens) while
* deliberately leaving low-signal values (package names, versions, git SHAs,
* plain URLs, kebab identifiers) untouched, because those are exactly the
* signal telemetry exists to capture.
*
* @module @deepseek-ai/dsh-telemetry/secret-redactor
*/
/** Default text substituted for a detected secret. */
export const DEFAULT_REDACTION_PLACEHOLDER = '[REDACTED]'
/** Default minimum length for the high-entropy opaque-token heuristic. */
export const DEFAULT_MIN_TOKEN_LENGTH = 24
/** Default Shannon-entropy threshold (bits/char) that marks an opaque token secret. */
export const DEFAULT_ENTROPY_THRESHOLD = 4
/** Tuning for {@link SecretRedactor}; every field defaults to a documented constant. */
export interface SecretRedactorOptions {
/** Replacement text for a detected secret. */
placeholder?: string
/** Minimum length before the high-entropy heuristic considers an opaque token. */
minTokenLength?: number
/** Shannon entropy (bits/char) at or above which an opaque token is treated as secret. */
entropyThreshold?: number
}
/**
* Regexes for well-known credential shapes. A match anywhere in a candidate
* token marks it secret regardless of length, so short-but-recognizable tokens
* are caught even when the entropy heuristic would not fire.
*/
const KNOWN_SECRET_PATTERNS: readonly RegExp[] = [
/sk-(?:ant-)?[A-Za-z0-9_-]{10,}/, // OpenAI / DeepSeek / Anthropic style
/gh[pousr]_[A-Za-z0-9]{16,}/, // GitHub personal/oauth/server/refresh tokens
/github_pat_[A-Za-z0-9_]{20,}/, // GitHub fine-grained PAT
/xox[baprs]-[A-Za-z0-9-]{10,}/, // Slack tokens
/AKIA[0-9A-Z]{16}/, // AWS access key id
/AIza[0-9A-Za-z_-]{35}/, // Google API key
/eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/, // JWT
]
/**
* Key names (normalized to lowercase, separators stripped) whose value is a
* secret. Split by match strategy so short/ambiguous words do not over-match:
* `author` must not trip the `auth` rule.
*/
const KEY_SUBSTRING_INDICATORS: readonly string[] = [
'password', 'passwd', 'passphrase', 'secret', 'apikey', 'apisecret',
'clientsecret', 'privatekey', 'secretkey', 'accesskey', 'credential',
'connectionstring', 'sastoken', 'xapikey', 'authtoken', 'accesstoken',
'refreshtoken', 'idtoken', 'sessiontoken', 'bearertoken',
]
const KEY_SUFFIX_INDICATORS: readonly string[] = ['token']
const KEY_EXACT_INDICATORS: readonly string[] = [
'auth', 'authorization', 'cookie', 'bearer', 'dsn', 'signature',
]
/**
* Whether a key name marks its value as a secret.
* @param key - raw object key or assignment name.
* @returns whether the value under this key must be redacted.
*/
export function keyLooksSecret(key: string): boolean {
const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, '')
if (normalized.length === 0) return false
if (KEY_SUBSTRING_INDICATORS.some(indicator => normalized.includes(indicator))) return true
if (KEY_SUFFIX_INDICATORS.some(indicator => normalized.endsWith(indicator))) return true
return KEY_EXACT_INDICATORS.includes(normalized)
}
/** Shannon entropy in bits per character. */
function shannonEntropy(value: string): number {
const counts = new Map<string, number>()
for (const char of value) counts.set(char, (counts.get(char) ?? 0) + 1)
let entropy = 0
for (const count of counts.values()) {
const probability = count / value.length
entropy -= probability * Math.log2(probability)
}
return entropy
}
/** Opaque-token character set (base64/base64url plus common token punctuation). */
const OPAQUE_TOKEN = /^[A-Za-z0-9+/=_.-]+$/
/** Version-like leader kept visible (dependency pins, semver). */
const VERSION_LIKE = /^v?\d+(?:\.\d+)+/
/**
* Conservative secret detector and redactor for telemetry content.
* Detection is a pure function of the input; construction only fixes tunables.
*/
export class SecretRedactor {
readonly #placeholder: string
readonly #minTokenLength: number
readonly #entropyThreshold: number
/** @param options - placeholder text and heuristic thresholds. */
constructor(options: SecretRedactorOptions = {}) {
this.#placeholder = options.placeholder ?? DEFAULT_REDACTION_PLACEHOLDER
this.#minTokenLength = options.minTokenLength ?? DEFAULT_MIN_TOKEN_LENGTH
this.#entropyThreshold = options.entropyThreshold ?? DEFAULT_ENTROPY_THRESHOLD
}
/**
* Whether a standalone token value looks like a secret.
* @param value - candidate token, already trimmed of surrounding quotes.
* @returns whether the value should be redacted on its own merits.
*/
isSecretValue(value: string): boolean {
if (KNOWN_SECRET_PATTERNS.some(pattern => pattern.test(value))) return true
if (value.length < this.#minTokenLength) return false
if (!OPAQUE_TOKEN.test(value)) return false
// Git SHAs and integrity digests are hex and public — never a secret we hide.
if (/^[0-9a-fA-F]+$/.test(value)) return false
if (VERSION_LIKE.test(value)) return false
const classes = (/[a-z]/.test(value) ? 1 : 0) + (/[A-Z]/.test(value) ? 1 : 0) + (/[0-9]/.test(value) ? 1 : 0)
return classes >= 3 || shannonEntropy(value) >= this.#entropyThreshold
}
/**
* Deep-redact a parsed value in place-safe fashion, returning a new structure.
* A secret-named key redacts its string value outright; every other string is
* judged on its own shape. Non-string leaves pass through untouched.
* @param value - parsed JSON-like value (object, array, or primitive).
* @returns a structurally identical value with secret strings replaced.
*/
redactValue<T>(value: T): T {
return this.#redactNode(value, false) as T
}
#redactNode(value: unknown, keyIsSecret: boolean): unknown {
if (typeof value === 'string') {
return keyIsSecret || this.isSecretValue(value) ? this.#placeholder : value
}
if (Array.isArray(value)) return value.map(item => this.#redactNode(item, false))
if (value !== null && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([key, child]) => [key, this.#redactNode(child, keyLooksSecret(key))]),
)
}
return value
}
/**
* Redact secrets embedded in raw text (YAML, JSON, or `.env`-style content),
* preserving every line and key while replacing only secret-shaped values.
* @param text - raw file or message text.
* @returns text with detected secrets replaced by the placeholder.
*/
redactText(text: string): string {
let output = this.#redactPemBlocks(text)
output = this.#redactAssignments(output)
output = this.#redactUrlCredentials(output)
output = this.#redactBearerTokens(output)
return this.#redactStandaloneTokens(output)
}
#redactPemBlocks(text: string): string {
return text.replace(
/-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----[\s\S]*?-----END (?:[A-Z ]+ )?PRIVATE KEY-----/g,
this.#placeholder,
)
}
#redactAssignments(text: string): string {
// `key: value`, `key = value`, or `"key": "value"` across YAML/JSON/.env.
return text.replace(
/("?)([A-Za-z0-9_.-]+)\1(\s*[:=]\s*)(["']?)([^\n\r"']+)\4/g,
(match, keyQuote: string, key: string, separator: string, valueQuote: string, value: string) =>
keyLooksSecret(key) && value.trim().length > 0
? `${keyQuote}${key}${keyQuote}${separator}${valueQuote}${this.#placeholder}${valueQuote}`
: match,
)
}
#redactUrlCredentials(text: string): string {
// Redact only the password in `scheme://user:password@host`, keeping host visible.
return text.replace(
/([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)([^\s/@]+)(@)/gi,
(_match, prefix: string, _password: string, at: string) => `${prefix}${this.#placeholder}${at}`,
)
}
#redactBearerTokens(text: string): string {
// The candidate must contain a digit: real bearer credentials are never
// letters-only, while prose like "bearer authentication" is.
return text.replace(
/(bearer\s+)((?=[a-z._-]*[0-9])[a-z0-9._-]{8,})/gi,
(_match, prefix: string) => `${prefix}${this.#placeholder}`,
)
}
#redactStandaloneTokens(text: string): string {
// `/` is excluded so package names, file paths, and URLs are never split or
// redacted; a secret containing `/` is still scrubbed piecewise.
return text.replace(/[A-Za-z0-9][A-Za-z0-9+=_.-]{7,}/g, token =>
this.isSecretValue(token) ? this.#placeholder : token)
}
}