consent-resolver.ts 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
  1. /**
  2. * Consent resolution for dsh-sdk telemetry.
  3. *
  4. * Telemetry is OFF only when `cordis.yml` contains a telemetry entry that is
  5. * explicitly `disabled`; every other file state reports (no `cordis.yml`, an
  6. * enabled entry, or no telemetry entry at all). The resolver PARSES `cordis.yml`
  7. * — it never boots a Cordis application — because several launcher commands
  8. * (`build`, `create`) never boot Cordis at all. `DO_NOT_TRACK` and CI
  9. * environment signals force a denial regardless of file state.
  10. *
  11. * @module @deepseek-ai/dsh-telemetry/consent-resolver
  12. */
  13. import { readFile } from 'node:fs/promises'
  14. import { join } from 'node:path'
  15. import { parseDocument, type ScalarTag } from 'yaml'
  16. /** Default `cordis.yml` entry name that carries telemetry consent. */
  17. export const DEFAULT_TELEMETRY_PLUGIN_NAME = '@deepseek-ai/dsh-telemetry'
  18. /**
  19. * Passthrough for Cordis' `!!js` expression tag so parsing consent never fails
  20. * on projects that inline JavaScript expressions; the resolver only reads plain
  21. * `name`/`disabled` scalars and does not evaluate expressions.
  22. */
  23. const JS_EXPRESSION_TAG: ScalarTag = {
  24. tag: 'tag:yaml.org,2002:js',
  25. resolve: value => value,
  26. }
  27. /** Why telemetry is or is not permitted for one command. */
  28. export type ConsentReason =
  29. | 'enabled'
  30. | 'disabled'
  31. | 'absent'
  32. | 'no-config'
  33. | 'do-not-track'
  34. | 'ci'
  35. | 'unreadable'
  36. /** Resolved telemetry consent for one command invocation. */
  37. export interface ConsentDecision {
  38. /** Whether telemetry may be sent. */
  39. allowed: boolean
  40. /** The signal that determined {@link allowed}. */
  41. reason: ConsentReason
  42. }
  43. /** Tuning for {@link ConsentResolver}; every field defaults to a documented value. */
  44. export interface ConsentResolverOptions {
  45. /** `cordis.yml` entry name whose enabled state carries consent. */
  46. telemetryPluginName?: string
  47. /** Environment used for `DO_NOT_TRACK`/CI checks; defaults to `process.env`. */
  48. env?: NodeJS.ProcessEnv
  49. /** Honor `DO_NOT_TRACK`/CI env signals as a hard opt-out. Defaults to `true`. */
  50. honorEnvOptOut?: boolean
  51. /** Consent when `cordis.yml` does not exist yet (first `create`). Defaults to `true` (telemetry is default-on). */
  52. allowWhenNoConfig?: boolean
  53. /** Consent when `cordis.yml` exists but has no telemetry entry. Defaults to `true` (report unless a present entry is disabled). */
  54. allowWhenEntryAbsent?: boolean
  55. }
  56. /** Whether an environment variable is set to a non-empty, non-"0"/"false" value. */
  57. function envEnabled(value: string | undefined): boolean {
  58. if (value === undefined) return false
  59. const normalized = value.trim().toLowerCase()
  60. return normalized.length > 0 && normalized !== '0' && normalized !== 'false'
  61. }
  62. /** Read a `cordis.yml` entry's `name`/`disabled` scalars, tolerating `!!js` tags. */
  63. function readTelemetryEntry(text: string, pluginName: string): { present: boolean; disabled: boolean } {
  64. const document = parseDocument(text, { customTags: [JS_EXPRESSION_TAG] })
  65. const contents: unknown = document.toJS({ maxAliasCount: -1 })
  66. if (!Array.isArray(contents)) return { present: false, disabled: false }
  67. for (const entry of contents) {
  68. if (entry === null || typeof entry !== 'object') continue
  69. const record = entry as Record<string, unknown>
  70. if (record.name === pluginName) return { present: true, disabled: record.disabled === true }
  71. }
  72. return { present: false, disabled: false }
  73. }
  74. /** Resolve telemetry consent by parsing a project's `cordis.yml` and the environment. */
  75. export class ConsentResolver {
  76. readonly #pluginName: string
  77. readonly #env: NodeJS.ProcessEnv
  78. readonly #honorEnvOptOut: boolean
  79. readonly #allowWhenNoConfig: boolean
  80. readonly #allowWhenEntryAbsent: boolean
  81. /** @param options - plugin name, environment, and default-decision knobs. */
  82. constructor(options: ConsentResolverOptions = {}) {
  83. this.#pluginName = options.telemetryPluginName ?? DEFAULT_TELEMETRY_PLUGIN_NAME
  84. this.#env = options.env ?? process.env
  85. this.#honorEnvOptOut = options.honorEnvOptOut ?? true
  86. this.#allowWhenNoConfig = options.allowWhenNoConfig ?? true
  87. this.#allowWhenEntryAbsent = options.allowWhenEntryAbsent ?? true
  88. }
  89. /**
  90. * Resolve consent for a command run in the given project directory.
  91. * @param projectDir - absolute or relative project root containing `cordis.yml`.
  92. * @returns the consent decision and the signal that produced it.
  93. */
  94. async resolve(projectDir: string): Promise<ConsentDecision> {
  95. if (this.#honorEnvOptOut) {
  96. if (envEnabled(this.#env.DO_NOT_TRACK)) return { allowed: false, reason: 'do-not-track' }
  97. if (envEnabled(this.#env.CI)) return { allowed: false, reason: 'ci' }
  98. }
  99. let text: string
  100. try {
  101. text = await readFile(join(projectDir, 'cordis.yml'), 'utf8')
  102. } catch (error) {
  103. // Missing cordis.yml is the first-init (`create`) path; any other read
  104. // fault is treated conservatively as its own reason.
  105. if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
  106. return { allowed: this.#allowWhenNoConfig, reason: 'no-config' }
  107. }
  108. return { allowed: false, reason: 'unreadable' }
  109. }
  110. const entry = readTelemetryEntry(text, this.#pluginName)
  111. if (!entry.present) return { allowed: this.#allowWhenEntryAbsent, reason: 'absent' }
  112. return entry.disabled ? { allowed: false, reason: 'disabled' } : { allowed: true, reason: 'enabled' }
  113. }
  114. }