helpers.ts 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
  1. /**
  2. * Internal sandbox-result classification helpers.
  3. *
  4. * @module @deepseek-ai/dsh-bash-sandbox/helpers
  5. */
  6. import { accessSync, constants, statSync } from 'node:fs'
  7. import type { ShellRunResult } from '@deepseek-ai/dsh-shell'
  8. import type { RunnerFailureRule } from '@deepseek-ai/dsh-sandbox'
  9. /** Node-local spawn codes proven to identify executable resolution or permission failure. */
  10. const EXECUTABLE_SPAWN_CODES = new Set(['EACCES', 'ENOENT'])
  11. /** Whether the caller-owned spawn cwd can be entered. */
  12. function isUsableWorkdir(path: string): boolean {
  13. try {
  14. if (!statSync(path).isDirectory()) return false
  15. accessSync(path, constants.X_OK)
  16. return true
  17. } catch {
  18. return false
  19. }
  20. }
  21. /**
  22. * Attribute only Node ENOENT/EACCES failures whose error path equals argv[0]
  23. * after independently ruling out the caller-owned cwd. A supplied error path
  24. * must exactly identify the runner; without one, the syscall must. With a
  25. * usable cwd, these codes describe resolution or execute permission for that
  26. * argv[0] or its shebang interpreter.
  27. * The workdir is checked at classification time, not atomically with spawn;
  28. * concurrent path replacement may change attribution but cannot permit an
  29. * unconfined execution.
  30. * @param error - the original spawn rejection.
  31. * @param runnerProgram - provider argv[0], the executable that establishes confinement.
  32. * @param workdir - the caller-owned spawn cwd, checked independently for usability.
  33. * @returns whether the rejection has executable-specific runner evidence.
  34. */
  35. export function isRunnerSpawnFailure(
  36. error: unknown,
  37. runnerProgram: string | undefined,
  38. workdir: string,
  39. ): boolean {
  40. if (runnerProgram === undefined || !isUsableWorkdir(workdir)) return false
  41. if (typeof error !== 'object' || error === null) return false
  42. const { code, path, syscall } = error as { code?: unknown; path?: unknown; syscall?: unknown }
  43. if (typeof code !== 'string' || !EXECUTABLE_SPAWN_CODES.has(code)) return false
  44. if (typeof syscall !== 'string') return false
  45. const exactSyscall = `spawn ${runnerProgram}`
  46. if (path === undefined) return syscall === exactSyscall
  47. if (typeof path !== 'string' || path.length === 0 || path !== runnerProgram) return false
  48. return syscall === 'spawn' || syscall === exactSyscall
  49. }
  50. /** Fatal runner evidence retained for infrastructure-error detail. */
  51. interface RunnerFailureMatch {
  52. /** The original stderr line that matched a fatal signature. */
  53. detail: string
  54. }
  55. /**
  56. * Classify a failed run against the selected backend's denial dialect.
  57. * @param result - settled foreground run.
  58. * @param signatures - case-insensitive denial substrings from the active wrap.
  59. * @returns whether the failed run matches that denial dialect.
  60. */
  61. export function classifyDenial(result: ShellRunResult, signatures: readonly string[]): boolean {
  62. return matchesSignature(result.exitCode, result.stderr.text, signatures)
  63. }
  64. /**
  65. * Classify one settled process against the selected backend's structured
  66. * runner-failure rules. Each rule requires a nonzero exit, its optional
  67. * exit-code gate, and a fatal signature on one stderr line after exact
  68. * informational lines are excluded.
  69. * @param exitCode - process exit code; null means signal termination.
  70. * @param stderr - collected stderr text, left unchanged.
  71. * @param rules - structured runner-failure rules from the active wrap.
  72. * @returns the first matching fatal line, or undefined when evidence is insufficient.
  73. */
  74. export function classifyRunnerFailure(
  75. exitCode: number | null,
  76. stderr: string,
  77. rules: readonly RunnerFailureRule[],
  78. ): RunnerFailureMatch | undefined {
  79. if (exitCode === null || exitCode === 0) return undefined
  80. const lines = stderr.split(/\r?\n/)
  81. for (const rule of rules) {
  82. if (rule.allowedExitCodes !== undefined && !rule.allowedExitCodes.includes(exitCode)) continue
  83. const informationalLines = new Set((rule.informationalLines ?? []).map(line => line.toLowerCase()))
  84. // An empty or whitespace-only substring is not meaningful runner evidence.
  85. // Ignore it while keeping any valid signatures beside it active.
  86. const fatalSignatures = rule.fatalSignatures
  87. .filter(signature => signature.trim().length > 0)
  88. .map(signature => signature.toLowerCase())
  89. for (const line of lines) {
  90. const lowered = line.toLowerCase()
  91. if (informationalLines.has(lowered)) continue
  92. if (fatalSignatures.some(signature => lowered.includes(signature))) return { detail: line }
  93. }
  94. }
  95. return undefined
  96. }
  97. /**
  98. * Match a non-zero exit against case-insensitive stderr signatures.
  99. * @param exitCode - process exit code; null means signal termination.
  100. * @param stderr - collected stderr text.
  101. * @param signatures - substrings identifying the selected backend's dialect.
  102. * @returns whether this is a non-zero exit whose stderr matches a signature.
  103. */
  104. export function matchesSignature(exitCode: number | null, stderr: string, signatures: readonly string[]): boolean {
  105. if (exitCode === null || exitCode === 0) return false
  106. const lowered = stderr.toLowerCase()
  107. return signatures.some(signature => lowered.includes(signature.toLowerCase()))
  108. }