diff --git a/README.md b/README.md index 8ee515b..205488a 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ The original jishaku (Discord.py) is [here](https://github.com/Gorialis/jishaku) ## Features - **`jsk js`** — Evaluate JavaScript in the running process (async, with token redaction). +- **`jsk cjs`** — Same as `jsk js`, plus a working `require()` resolved against your bot project. +- **`jsk mjs`** — Evaluate JavaScript as a real ES module — `import`/top-level `await` work. - **`jsk sh`** — Run system shell commands with live-streamed output (PowerShell/cmd/`$SHELL`). - **`jsk cat` / `jsk curl`** — Read local files (with line spans) or remote text resources. - **Diagnostics** — `jsk` status summary, `jsk ping` round-trip timing, `jsk tasks` / `jsk cancel`. @@ -105,12 +107,13 @@ client.login(process.env.DISCORD_TOKEN) | `secretValues` | `string[]` | `[]` | Extra exact strings to redact in security mode. | | `shellTimeout` | `number` | `120000` | Kill a `jsk sh` process after this many ms of inactivity. | | `exitOnShutdown` | `boolean` | `false` | Call `process.exit(0)` after `jsk shutdown` destroys the client. | -| `evalTimeout` | `number` | `10000` | Cap on a single *synchronous* stretch of a `jsk js` eval (see below). | +| `evalTimeout` | `number` | `10000` | Cap on a single *synchronous* stretch of a `jsk js`/`jsk cjs` eval (see below). | | `shell` | `ShellOverride` | *(auto)* | Override which shell `jsk sh` spawns (see below). | +| `evalModuleDir` | `string` | `process.cwd()` | Base directory `jsk cjs`'s `require` and `jsk mjs`'s `import` resolve modules from (see below). | ### Security mode -The Discord token is **always** redacted from djsk's own output. Setting `security: true` additionally best-effort redacts, from everything djsk sends, replies with, edits, or logs — including `jsk js` results, `jsk cat` / `jsk curl` output (message and file attachments), and shell output: +The Discord token is **always** redacted from djsk's own output. Setting `security: true` additionally best-effort redacts, from everything djsk sends, replies with, edits, or logs — including `jsk js`/`jsk cjs`/`jsk mjs` results, `jsk cat` / `jsk curl` output (message and file attachments), and shell output: - secret-like `process.env` values (keys matching `TOKEN`, `SECRET`, `KEY`, `PASSWORD`, `API`, …); - `.env`-style assignments (`SECRET_KEY=...`), even when not loaded into the environment; @@ -126,7 +129,7 @@ new Jishaku(client, { }) ``` -**`jsk js` user code.** Because eval'd code can call Discord directly (e.g. `message.reply(...)`, `channel.send(...)`, `interaction.editReply(...)`) — bypassing djsk's own output path — security mode protects this in three layers, active only while an eval is running: +**`jsk js`/`jsk cjs`/`jsk mjs` user code.** Because eval'd code can call Discord directly (e.g. `message.reply(...)`, `channel.send(...)`, `interaction.editReply(...)`) — bypassing djsk's own output path — security mode protects this in three layers, active only while an eval is running: 1. The eval scope's `message`, `msg`, `channel`, `author` and `me` are Proxy-guarded, so their response methods (and `channel`/DMs reached through them) scrub before sending. 2. For anything reached another way (`client.channels.cache.get(id).send(...)`, a webhook, an interaction, a fetched user, ...), djsk temporarily patches `send`/`reply`/`edit`/`editReply`/ `followUp`/`update` on the installed library's own exported classes for the duration of that single eval, then restores the originals — regardless of how the object was obtained. Gateway/IPC/shard-control methods that happen to share a name (e.g. `Shard.send`, any `*Manager.edit`) are excluded so they aren't corrupted. @@ -176,7 +179,7 @@ client.on('interactionCreate', (interaction) => jsk.onInteractionCreate(interact ## Commands -All commands are used as `${prefix}jsk ` (e.g. `.jsk js 1 + 1`) or `/jsk `. +All commands are used as `${prefix}jsk ` (e.g. `.jsk js return 1 + 1`) or `/jsk `. Non-owners get no reaction at all when using text commands (djsk doesn't even reveal it's listening), and an ephemeral "You are not allowed to use this command." reply when using the slash command. @@ -184,7 +187,9 @@ Non-owners get no reaction at all when using text commands (djsk doesn't even re | -------------------------- | ------------------------------------------------------------------- | | `jsk` | Status summary (versions, memory, cache counts, latency). | | `jsk help` | Lists all commands. | -| `jsk js ` (`eval`) | Evaluates JavaScript. Single expressions auto-return. | +| `jsk js ` (`eval`) | Evaluates JavaScript. Use `return` to produce a result. | +| `jsk cjs ` (`commonjs`) | Like `jsk js`, plus a working `require()`. | +| `jsk mjs ` (`esm`) | Evaluates as a real ES module (`import` works). Use `export default` for a result. | | `jsk retain [on\|off]` | Toggles REPL variable retention (the `vars` object and `_`). | | `jsk sh ` (`shell`) | Runs a system shell command, streaming output. | | `jsk cat ` | Reads a file, optionally a line span. | @@ -194,22 +199,28 @@ Non-owners get no reaction at all when using text commands (djsk doesn't even re | `jsk tasks` | Lists running djsk tasks. | | `jsk cancel ` | Cancels a task (`~` for all, `-1` for the most recent). | -### `jsk js` scope +### `jsk js` / `jsk cjs` / `jsk mjs` scope -The following variables are injected into the evaluation scope: +The following variables are injected into the evaluation scope of all three: -`client` / `bot`, `ctx`, `message` / `msg`, `interaction`, `author`, `channel`, `guild`, `me`, `_` (last result), `vars` (a persistent object when retention is on), `signal` (an `AbortSignal`, see below), and `dynamicImport` (see below). +`client` / `bot`, `ctx`, `message` / `msg`, `interaction`, `author`, `channel`, `guild`, `me`, `_` (last result), `vars` (a persistent object when retention is on), `signal` (an `AbortSignal`, see below), and `dynamicImport` (see below). `jsk cjs` additionally gets `require`. -`message`/`msg` are `null` and `interaction` is set when `js`/`sh` was invoked via slash command (through the code-input modal) instead of a text command, and vice versa. +`message`/`msg` are `null` and `interaction` is set when invoked via slash command (through the code-input modal) instead of a text command, and vice versa. -`jsk js` runs eval'd code via `vm.Script#runInThisContext()` rather than a plain function, in the *current* realm — Node's ambient globals and live object references (client, message, ...) work exactly as if it were a plain function, but bare `import(...)` doesn't (it needs `--experimental-vm-modules`, which not every djsk consumer's process runs with). Use the injected `dynamicImport(specifier)` instead — it's a normal function defined outside the vm boundary, so it isn't affected by that restriction: `const os = await dynamicImport('node:os')`. +`jsk js` and `jsk cjs` run eval'd code via `vm.Script#runInThisContext()` rather than a plain function, in the *current* realm — Node's ambient globals and live object references (client, message, ...) work exactly as if it were a plain function, but bare `import(...)` doesn't (it needs `--experimental-vm-modules`, which not every djsk consumer's process runs with). Use the injected `dynamicImport(specifier)` instead — it's a normal function defined outside the vm boundary, so it isn't affected by that restriction: `const os = await dynamicImport('node:os')`. `jsk cjs` additionally gets a real `require()`, resolved against `evalModuleDir` (default `process.cwd()`) — so `require('discord.js')`, `require('./some-local-file')`, etc. resolve against *your bot project*, not djsk's own. -**Cancelling a running eval.** `jsk js` registers itself in `jsk tasks`, and is cancellable two ways: +**`jsk mjs` is different.** Static `import` syntax can't appear inside a wrapped function body at all (an ECMAScript rule, not a `vm.Script` limitation), so `jsk mjs` instead runs your code as the top level of a real, freshly-loaded ES module — real `import`, real top-level `await`. Two consequences: + +- There's no `return` — a module's top level has no return value. Use `export default ` to produce a result instead (e.g. `export default 1 + 1;`). +- It writes a transient `.mjs` file under `/.djsk-tmp` for the duration of the eval (deleted immediately after; a `.gitignore` is dropped in that folder so it never pollutes your repo). This is required for real npm package imports to resolve — dynamically `import()`-ing a `data:` URL works for `node:` builtins but can't resolve real packages (no filesystem location for Node to walk up node_modules from), so a real file is the only way to make `import 'some-package'` actually work. +- It does **not** get `evalTimeout`'s synchronous-runaway protection (see below) — a bare `while (true) {}` in `jsk mjs` blocks the whole process with no recovery short of a restart, since that protection is a `vm.Script` feature `jsk mjs` doesn't use. `jsk cancel` still works for an eval stuck *awaiting* something. + +**Cancelling a running eval.** All three register themselves in `jsk tasks`, and are cancellable two ways: - `jsk cancel` — stops an eval stuck *awaiting* something (an infinite retry loop with an `await` in it, a Discord call that never resolves, `await new Promise(() => {})`, ...). `signal` is provided so eval'd code can cooperate explicitly too — pass it to anything that accepts an `AbortSignal` (`fetch(url, { signal })`) or poll `signal.aborted` inside a loop. -- `evalTimeout` — a hard cap (ms, default `10000`) on any single *synchronous* stretch of the eval, e.g. a bare `while (true) {}`. This case can't be helped by `jsk cancel`: while the eval is stuck in synchronous code, the entire bot process is blocked and can't process *any* Discord events, including a cancel request — so it's enforced automatically instead (via V8's execution watchdog, which can genuinely preempt a tight loop), terminating the eval once it's exceeded. +- `evalTimeout` — a hard cap (ms, default `10000`) on any single *synchronous* stretch of a `jsk js`/`jsk cjs` eval, e.g. a bare `while (true) {}`. This case can't be helped by `jsk cancel`: while the eval is stuck in synchronous code, the entire bot process is blocked and can't process *any* Discord events, including a cancel request — so it's enforced automatically instead (via V8's execution watchdog, which can genuinely preempt a tight loop), terminating the eval once it's exceeded. Not available for `jsk mjs` — see above. -Between the two, a `jsk js` eval can (almost) always be recovered from without restarting the bot. `evalTimeout` only preempts synchronous *JS* execution, not time spent parked in a blocking *native* call (`child_process.execSync` on a slow command, say) — for `execSync`/`execFileSync`/`spawnSync` specifically (reached via `dynamicImport('node:child_process')`, since bare `import(...)` isn't available — see above), a call that doesn't set its own `timeout` gets `evalTimeout` as one automatically, since those three already support it natively (killing the child and unblocking the parent). Other blocking natives with no such option (`fs.readFileSync` hung on a slow pipe, a bare `Atomics.wait()`, ...) remain a real, if rarer, gap that still needs a restart. +Between the two, a `jsk js`/`jsk cjs` eval can (almost) always be recovered from without restarting the bot. `evalTimeout` only preempts synchronous *JS* execution, not time spent parked in a blocking *native* call (`child_process.execSync` on a slow command, say) — for `execSync`/`execFileSync`/`spawnSync` specifically (reached via `dynamicImport('node:child_process')`, since bare `import(...)` isn't available — see above), a call that doesn't set its own `timeout` gets `evalTimeout` as one automatically, since those three already support it natively (killing the child and unblocking the parent). Other blocking natives with no such option (`fs.readFileSync` hung on a slow pipe, a bare `Atomics.wait()`, ...) remain a real, if rarer, gap that still needs a restart. > [!Note] > diff --git a/src/commands/cjs.test.ts b/src/commands/cjs.test.ts new file mode 100644 index 0000000..b22a215 --- /dev/null +++ b/src/commands/cjs.test.ts @@ -0,0 +1,71 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { Context } from '../context' +import { Jishaku } from '../jishaku' +import { cjsCommands } from './cjs' + +const cjsCommand = cjsCommands[0] + +function makeJsk(configOverrides: Record = {}): Jishaku { + return new Jishaku( + // biome-ignore lint/suspicious/noExplicitAny: minimal fake client for tests. + { token: 't0ken-fake' } as any, + { consoleLog: false, ...configOverrides }, + ) +} + +function makeContext(code: string, jsk: Jishaku = makeJsk()) { + const send = vi.fn(async (payload: unknown) => ({ payload })) + const react = vi.fn(async () => {}) + // biome-ignore lint/suspicious/noExplicitAny: minimal fake message for tests. + const message = { channel: { send }, react, author: {} } as any + const source = { kind: 'message' as const, message } + const ctx = new Context(jsk, source, 'cjs', code) + return { ctx, send, react } +} + +describe('jsk cjs — require', () => { + it('resolves node: builtins via require', async () => { + const { ctx, send } = makeContext('return typeof require("node:os").platform()') + + await cjsCommand.handler(ctx) + + const [payload] = send.mock.calls[0] as [{ content: string }] + expect(payload.content).toBe('string') + }) + + it('still requires an explicit return — no auto-return', async () => { + const { ctx, send } = makeContext('1 + 1') + + await cjsCommand.handler(ctx) + + expect(send).not.toHaveBeenCalled() + }) + + it("resolves require() relative to config.evalModuleDir, not djsk's own directory", async () => { + // Simulate a host bot project by placing a resolvable package under a synthetic project + // root's node_modules, then pointing evalModuleDir at that root. + const projectRoot = mkdtempSync(path.join(tmpdir(), 'djsk-cjs-host-')) + const packageDir = path.join(projectRoot, 'node_modules', 'fake-pkg') + mkdirSync(packageDir, { recursive: true }) + writeFileSync( + path.join(packageDir, 'package.json'), + JSON.stringify({ name: 'fake-pkg', version: '1.0.0', main: 'index.js' }), + ) + writeFileSync(path.join(packageDir, 'index.js'), 'module.exports = 42') + + try { + const jsk = makeJsk({ evalModuleDir: projectRoot }) + const { ctx, send } = makeContext('return require("fake-pkg")', jsk) + + await cjsCommand.handler(ctx) + + const [payload] = send.mock.calls[0] as [{ content: string }] + expect(payload.content).toBe('42') + } finally { + rmSync(projectRoot, { recursive: true, force: true }) + } + }) +}) diff --git a/src/commands/cjs.ts b/src/commands/cjs.ts new file mode 100644 index 0000000..06d8bf7 --- /dev/null +++ b/src/commands/cjs.ts @@ -0,0 +1,20 @@ +import { createRequire } from 'node:module' +import path from 'node:path' +import { runVmEval } from './eval-shared' +import type { Command } from './registry' + +const cjsCommand: Command = { + name: 'cjs', + aliases: ['commonjs'], + summary: 'Evaluates JavaScript with `require` available. Use `return` to produce a result.', + async handler(ctx) { + // `createRequire` only needs a directory to anchor resolution from — the filename need not + // exist. Anchored at `evalModuleDir` (default `process.cwd()`) rather than djsk's own + // package directory, so `require(...)` resolves the *host bot's* node_modules and files, + // not djsk's. + const require = createRequire(path.join(ctx.jsk.config.evalModuleDir, 'jsk-eval-shim.cjs')) + await runVmEval(ctx, ctx.codeblock.content, { require }, 'jsk cjs') + }, +} + +export const cjsCommands: Command[] = [cjsCommand] diff --git a/src/commands/eval-shared.ts b/src/commands/eval-shared.ts new file mode 100644 index 0000000..93b8eba --- /dev/null +++ b/src/commands/eval-shared.ts @@ -0,0 +1,458 @@ +import vm from 'node:vm' +import type { Context } from '../context' +import { installPrototypeGuards, installRestGuard } from '../prototype-guard' +import { guardOutbound } from '../security' +import { inspectResult, MESSAGE_LIMIT, stripAnsi } from '../util/format' +import { loadLibraryModule } from '../util/meta' + +const GUARDED_CHILD_PROCESS_METHODS = new Set(['execSync', 'execFileSync', 'spawnSync']) + +/** + * Wraps `fn` (one of `child_process`'s `execSync`/`execFileSync`/`spawnSync`) so a call that + * doesn't specify its own `timeout` gets `timeoutMs` as a default, without overriding a value + * the eval's own code explicitly passed. + * + * These three are the common way eval'd code blocks on a *native* call rather than JS + * execution — and unlike a synchronous JS loop, {@link EvalTimedOutError}'s `vm.Script` timeout + * can't preempt them (V8's execution watchdog only checks in during actual bytecode execution, + * not while parked waiting on a native/libuv call to return; confirmed experimentally). They + * do, however, each already support their own native `timeout` option (which kills the child + * and unblocks the parent) — this just makes sure one is always set. + * + * Doesn't help with other blocking natives with no such option (`fs.readFileSync` hung on a + * slow pipe, a bare `Atomics.wait()`, ...) — those remain a real, if rarer, gap. + */ +// biome-ignore lint/suspicious/noExplicitAny: forwarding execSync/execFileSync/spawnSync's overloaded signatures verbatim. +function withDefaultTimeout(fn: (...args: any[]) => any, timeoutMs: number) { + // biome-ignore lint/suspicious/noExplicitAny: see above. + return (...args: any[]) => { + const last = args[args.length - 1] + if (last !== null && typeof last === 'object' && !Array.isArray(last)) { + if (last.timeout === undefined) args[args.length - 1] = { ...last, timeout: timeoutMs } + } else { + args.push({ timeout: timeoutMs }) + } + return fn(...args) + } +} + +/** + * Builds the `dynamicImport` scope entry shared by every eval flavor. + * + * Bare `import(...)` inside a `vm.Script` requires an opt-in `importModuleDynamically` + * callback, which itself requires Node's --experimental-vm-modules flag — not something every + * djsk consumer's process can be expected to run with. This closure lives outside the + * vm-executed code (in normal module scope), so it can freely `import()` without that + * restriction; eval'd code gets the same capability via `dynamicImport(...)`. + * + * `node:child_process` specifically comes back Proxy-wrapped so execSync/execFileSync/ + * spawnSync default to `evalTimeoutMs` (see {@link withDefaultTimeout}) — this is the actual + * interception point, not a global monkeypatch of the module: mutating the module object + * reached via a default import (`import cp from 'node:child_process'`) does NOT affect what a + * named or namespace import (what `dynamicImport` returns) sees, confirmed experimentally — + * Node's synthetic ESM bindings for built-ins aren't reliably live across that boundary, so + * patching has to happen right here instead. + */ +export function createDynamicImport(evalTimeoutMs: number) { + return async (specifier: string) => { + const imported = await import(specifier) + if (specifier !== 'node:child_process' && specifier !== 'child_process') return imported + + return new Proxy(imported, { + get(target, prop, receiver) { + const value = Reflect.get(target, prop, receiver) + return typeof prop === 'string' && GUARDED_CHILD_PROCESS_METHODS.has(prop) + ? withDefaultTimeout(value, evalTimeoutMs) + : value + }, + }) + } +} + +/** + * Compiles user code into a `vm.Script` whose top-level statement immediately invokes an + * async function taking `argNames` as parameters, applied to whatever's stashed at + * `globalThis[argsKey]` at call time. + * + * Run via `runInThisContext()` — the *current* realm, not a new sandboxed one — so it behaves + * exactly like a plain `AsyncFunction`: full access to Node's ambient globals, and live object + * references (client, message, ...) passed as args work unchanged, with no serialization + * boundary. The difference is that `Script#runInThisContext` accepts a `timeout`, which + * `AsyncFunction` calls never could — see {@link EvalTimedOutError}. + * + * Always a statement body — the user is expected to `return` explicitly. (An earlier version + * of this also tried parsing the code as a single auto-returning expression first; dropped so + * `return` is always required, with no auto-return surprises.) + */ +export function compile( + code: string, + argNames: string[], + argsKey: string, + filename: string, +): vm.Script { + const params = argNames.join(', ') + const invocation = `.apply(null, globalThis[${JSON.stringify(argsKey)}])` + return new vm.Script(`(async function (${params}) {\n${code}\n})${invocation}`, { filename }) +} + +// biome-ignore lint/suspicious/noExplicitAny: structural Message check across libraries. +export function isMessage(value: any): boolean { + return ( + typeof value === 'object' && + value !== null && + 'author' in value && + typeof value.react === 'function' && + ('url' in value || 'id' in value) + ) +} + +/** Thrown to unwind an eval that was stopped via `jsk cancel`. */ +export class EvalCancelledError extends Error { + constructor() { + super('Cancelled via jsk cancel.') + this.name = 'EvalCancelledError' + } +} + +/** + * Thrown when a synchronous stretch of a `vm.Script`-based eval (e.g. a bare `while (true) {}`) + * ran longer than `evalTimeout` and was forcibly terminated by V8's execution watchdog — + * confirmed experimentally to genuinely preempt a tight JS loop, unlike JS-level cooperative + * cancellation (V8 checks for a pending termination request at loop back-edges/calls during + * actual bytecode execution, so this doesn't require the running code to yield). + * + * Unlike {@link EvalCancelledError}, this can't be triggered by `jsk cancel` reactively — + * while the eval is stuck in synchronous code, the whole bot process is blocked (nothing else + * runs either, including processing a cancel request), so there's no "react to the command" + * moment available. `evalTimeout` is a hard cap enforced up front instead. + * + * Only applies to `jsk js`/`jsk cjs` (both run via `vm.Script`). `jsk mjs` runs as a real ES + * module via a genuine dynamic `import()` instead, which has no equivalent timeout mechanism — + * a synchronous runaway there blocks the process with no recovery short of a restart. See the + * doc comment on `jsk mjs`'s handler. + * + * Known gap: this covers synchronous *JS* execution, not a blocking *native* call the code + * might make (e.g. `child_process.execSync` on a slow command) — confirmed experimentally that + * such a call is NOT interrupted by the timeout, since V8's watchdog only preempts during + * bytecode execution, not while parked waiting on a native/libuv call to return. Short of + * restarting the process, there's currently no way around that; it would need running the eval + * in a separate thread that can be forcibly terminated (`node:worker_threads`), which isn't + * viable here without losing direct, synchronous access to the live client/message/channel + * objects the vm-based evals are built around (they aren't structured-cloneable across a + * worker boundary). + */ +export class EvalTimedOutError extends Error { + constructor(timeoutMs: number) { + super(`Synchronous execution exceeded ${timeoutMs}ms and was terminated.`) + this.name = 'EvalTimedOutError' + } +} + +/** + * Resolves/rejects with `promise`, but rejects with {@link EvalCancelledError} as soon as + * `signal` aborts — whichever comes first. + * + * This only wins the race at an `await` point in the running eval (or in a Promise chain it + * started, e.g. a pending `fetch`) — it doesn't help with a synchronous runaway (`while (true) + * {}` blocks the event loop entirely, so nothing — this included — runs until it returns + * control); {@link EvalTimedOutError} covers that case instead. This one covers the far more + * common "hang" shape: an eval stuck awaiting something that never resolves (an infinite retry + * loop with an `await` in it, a Discord call that never comes back, `await new Promise(() => + * {})`, ...). + */ +export function raceAbort(promise: Promise, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(new EvalCancelledError()) + + return new Promise((resolve, reject) => { + const onAbort = () => reject(new EvalCancelledError()) + signal.addEventListener('abort', onAbort, { once: true }) + promise.then( + (value) => { + signal.removeEventListener('abort', onAbort) + resolve(value) + }, + (error) => { + signal.removeEventListener('abort', onAbort) + reject(error) + }, + ) + }) +} + +type WriteFn = typeof process.stdout.write + +/** + * Temporarily wraps `process.stdout`/`process.stderr`'s `write()`, so an eval command can + * surface everything written to the terminal during its run — not just `console.*` (which + * itself writes through these same streams under the hood), but also raw + * `process.stdout.write()` calls and output from any library the user code touches. + * `restore()` undoes the wrapping and returns the captured text; safe to call more than once. + * + * `scrub`, when given (security mode), is also applied to what actually reaches the real + * stream — not just the copy captured here for Discord — so a `console.log(client.token)` + * doesn't leak the token into the bot's own local output/logs either. `null` (the default + * outside security mode) leaves real output completely untouched, unchanged from before. + * + * This patches the process-wide streams for the eval's duration, so two evals running + * concurrently would see each other's output in their capture — an accepted trade-off for a + * single-owner debug tool. + */ +export function captureTerminalOutput(scrub: ((text: string) => string) | null): { + restore: () => string +} { + const chunks: string[] = [] + const originalStdoutWrite: WriteFn = process.stdout.write + const originalStderrWrite: WriteFn = process.stderr.write + + // Calls the original via `.apply(stream, ...)` rather than a pre-bound reference, so + // `restore()` can put back the exact original function (not a `.bind()` wrapper around + // it) — otherwise repeated eval calls would pile up an ever-growing chain of bound wrappers. + const wrap = + (stream: NodeJS.WriteStream, original: WriteFn): WriteFn => + (chunk: unknown, ...rest: unknown[]) => { + const text = + typeof chunk === 'string' + ? chunk + : Buffer.isBuffer(chunk) + ? chunk.toString('utf-8') + : String(chunk) + // Stripped for the Discord-bound capture only — colors are decided by whether the + // *real* stdout/stderr is a TTY, which has nothing to do with whether this is headed to + // Discord (not a terminal), so raw escape codes would otherwise show up as literal + // garbage (see stripAnsi's doc comment). The real stream output is left untouched. + chunks.push(stripAnsi(text)) + const outgoing = scrub ? scrub(text) : chunk + // biome-ignore lint/suspicious/noExplicitAny: forwarding Node's overloaded write(chunk, encoding?, callback?) verbatim. + return (original as any).apply(stream, [outgoing, ...rest]) + } + + process.stdout.write = wrap(process.stdout, originalStdoutWrite) + process.stderr.write = wrap(process.stderr, originalStderrWrite) + + return { + restore: () => { + process.stdout.write = originalStdoutWrite + process.stderr.write = originalStderrWrite + return chunks.join('') + }, + } +} + +/** + * Sends `terminalOutput` (wrapped in a codeblock) followed by `text` (plain), preferring a + * single combined message when it fits. + * + * When it doesn't fit, the two are sent as separate, independently-paginated messages instead + * of combining them into one oversized string and handing that to {@link Context.sendResult}: + * that pagination is codeblock-*unaware* (by design — it's also used for plain results with no + * codeblock at all), so it slices the combined text at fixed byte offsets with no regard for + * where the codeblock's fences landed. The fences only happen to survive at the very start of + * page 1 and the very end of the last page; every page in between is missing both, since + * nothing re-opens/re-closes the codeblock at the split points. Sending `terminalOutput` + * through {@link Context.sendCodeblock} instead re-wraps every page in its own fences. + */ +export async function sendTerminalAndText( + ctx: Context, + terminalOutput: string, + text: string | null, + filename: string, +): Promise { + if (!terminalOutput) { + if (text !== null) await ctx.sendResult(text, filename) + return + } + + const codeblock = `\`\`\`\n${terminalOutput}\n\`\`\`` + const combined = text !== null ? `${codeblock}\n${text}` : codeblock + + // Matches Context.sendResult's own scrubbed-length check, so this decides on the same basis + // it will (scrubbing doesn't reliably preserve length, and ctx.sendResult scrubs again below + // regardless — redaction is idempotent, so double-scrubbing is harmless). + if (ctx.jsk.scrub(combined).length <= MESSAGE_LIMIT) { + await ctx.sendResult(combined, filename) + return + } + + await ctx.sendCodeblock(terminalOutput, '', filename) + if (text !== null) await ctx.sendResult(text, filename) +} + +export async function sendResult( + ctx: Context, + result: unknown, + terminalOutput: string, +): Promise { + const resultText = isMessage(result) + ? // biome-ignore lint/suspicious/noExplicitAny: verified Message-like above. + `` + : result === undefined + ? null + : inspectResult(result) + + if (!terminalOutput && resultText === null) return + + // Routed through sendTerminalAndText (which itself routes through ctx.sendResult/ + // sendCodeblock, not a raw send) so the captured terminal output gets the same token + // redaction / security-mode secret scrubbing as everything else djsk sends. + await sendTerminalAndText(ctx, terminalOutput, resultText, 'output.js') +} + +/** The common `client`/`message`/... variables injected into every eval flavor's scope. */ +export function buildBaseScope( + ctx: Context, + guard: (value: unknown) => unknown, + controllerSignal: AbortSignal, +): Record { + const jsk = ctx.jsk + return { + client: ctx.client, + bot: ctx.client, + ctx, + message: guard(ctx.message), + msg: guard(ctx.message), + interaction: guard(ctx.interaction), + author: guard(ctx.author), + channel: guard(ctx.channel), + guild: ctx.guild, + // biome-ignore lint/suspicious/noExplicitAny: client.user shape is stable. + me: guard((ctx.client as any).user), + _: jsk.lastResult, + vars: jsk.replVars, + // Exposed so cooperative user code can pass it along (e.g. `fetch(url, { signal })`) or + // poll `signal.aborted` in a loop, for cleaner cancellation than raceAbort alone gives. + signal: controllerSignal, + dynamicImport: createDynamicImport(jsk.config.evalTimeout), + } +} + +/** + * In security mode, hands the eval scope guarded objects so user code that sends/replies/edits + * (message, channel, DMs, interactions reachable through them) is scrubbed too. + */ +export function makeGuard(ctx: Context): (value: unknown) => unknown { + const jsk = ctx.jsk + return (value: unknown) => + jsk.config.security && value && typeof value === 'object' + ? guardOutbound(value, (text) => jsk.scrub(text)) + : value +} + +/** + * In security mode, also guards the library's outbound methods (send/reply/edit/...) and, + * where the library exposes one, its lower-level REST class — for the duration of a single + * eval — so arbitrary Discord calls (any channel/webhook/interaction, or a raw client.rest.post) + * are scrubbed too. Returns the restore functions to call in a `finally`. + */ +export async function installSecurityGuards( + ctx: Context, +): Promise<{ restoreGuards: (() => void) | null; restoreRestGuard: (() => void) | null }> { + const jsk = ctx.jsk + if (!jsk.config.security) return { restoreGuards: null, restoreRestGuard: null } + + const module = await loadLibraryModule() + if (!module) return { restoreGuards: null, restoreRestGuard: null } + + return { + restoreGuards: installPrototypeGuards(module, (text) => jsk.scrub(text)), + restoreRestGuard: installRestGuard(module, (text) => jsk.scrub(text)), + } +} + +/** + * Runs `code` through the shared `vm.Script`-based eval engine (see {@link compile}) — the + * common path behind both `jsk js` and `jsk cjs`, which differ only in `extraScope` (`cjs` + * adds `require`) and `taskName` (shown in `jsk tasks`/used as the script's filename). + * + * Handles task registration/cancellation, terminal output capture, security guarding, the + * `evalTimeout` synchronous watchdog, and reporting the result/error back to Discord. + */ +export async function runVmEval( + ctx: Context, + code: string, + extraScope: Record, + taskName: string, +): Promise { + if (!code.trim()) { + await ctx.send('No code to evaluate.') + return + } + + const jsk = ctx.jsk + const guard = makeGuard(ctx) + const controller = new AbortController() + const scope: Record = { + ...buildBaseScope(ctx, guard, controller.signal), + ...extraScope, + } + const argNames = Object.keys(scope) + const argValues = Object.values(scope) + + // Submitted before the (possibly awaiting) security-guard install below: `await` always + // defers to a microtask tick even when the awaited call resolves synchronously, so + // registering the task first guarantees it's visible in `jsk tasks`/cancellable via + // `jsk cancel` immediately, without an extra tick's delay whenever security mode is off. + const task = jsk.submitTask(taskName, () => controller.abort()) + const { restoreGuards, restoreRestGuard } = await installSecurityGuards(ctx) + // Unique per invocation (task.index is a monotonic counter) so concurrent evals can't + // clobber each other's stashed arguments on the shared global object. + const argsKey = `__djsk_eval_args_${task.index}__` + // In security mode, also scrub what actually reaches the real terminal (not just the copy + // captured for Discord) — otherwise a `console.log(client.token)` still leaks it into the + // bot's own local logs, which may be shipped to a third-party service the operator doesn't + // fully trust. Off by default (matches the general "token redaction is always on, full + // scrubbing is opt-in" convention) so normal debugging output isn't silently altered. + const capture = captureTerminalOutput(jsk.config.security ? (text) => jsk.scrub(text) : null) + try { + // biome-ignore lint/suspicious/noExplicitAny: temporary bridge for vm.runInThisContext, deleted immediately below. + ;(globalThis as any)[argsKey] = argValues + + let scriptResult: unknown + try { + const script = compile(code, argNames, argsKey, taskName) + scriptResult = script.runInThisContext({ + timeout: jsk.config.evalTimeout, + filename: taskName, + }) + } catch (error) { + const errorCode = (error as NodeJS.ErrnoException)?.code + throw errorCode === 'ERR_SCRIPT_EXECUTION_TIMEOUT' + ? new EvalTimedOutError(jsk.config.evalTimeout) + : error + } finally { + // biome-ignore lint/suspicious/noExplicitAny: temporary bridge for vm.runInThisContext. + delete (globalThis as any)[argsKey] + } + + const result = await raceAbort(Promise.resolve(scriptResult), controller.signal) + const terminalOutput = capture.restore() + + if (jsk.retain) jsk.lastResult = result + + await ctx.react('✅') + await sendResult(ctx, result, terminalOutput) + } catch (error) { + const terminalOutput = capture.restore() + + if (error instanceof EvalCancelledError) { + // `jsk cancel` already sends its own confirmation — report only whatever terminal + // output the eval produced before it was stopped, if any, rather than also surfacing + // this as a generic error through Jishaku's catch-and-report handler. + await ctx.react('🛑') + if (terminalOutput) await sendResult(ctx, undefined, terminalOutput) + return + } + + if (error instanceof EvalTimedOutError) { + await ctx.react('⏱️') + await sendTerminalAndText(ctx, terminalOutput, error.message, 'output.js') + return + } + + throw error + } finally { + capture.restore() + restoreRestGuard?.() + restoreGuards?.() + jsk.removeTask(task) + } +} diff --git a/src/commands/js.test.ts b/src/commands/js.test.ts index 556856b..3a03d93 100644 --- a/src/commands/js.test.ts +++ b/src/commands/js.test.ts @@ -68,6 +68,14 @@ describe('jsk js — terminal output capture', () => { expect(send).not.toHaveBeenCalled() }) + it('does not auto-return a bare expression — an explicit `return` is required', async () => { + const { ctx, send } = makeContext('1 + 1') + + await jsCommand.handler(ctx) + + expect(send).not.toHaveBeenCalled() + }) + it('restores process.stdout.write after the eval even if user code throws', async () => { const original = process.stdout.write const { ctx } = makeContext('throw new Error("boom")') diff --git a/src/commands/js.ts b/src/commands/js.ts index e463d2b..85a98b3 100644 --- a/src/commands/js.ts +++ b/src/commands/js.ts @@ -1,418 +1,21 @@ -import vm from 'node:vm' -import type { Context } from '../context' -import { installPrototypeGuards, installRestGuard } from '../prototype-guard' -import { guardOutbound } from '../security' -import { inspectResult, MESSAGE_LIMIT, stripAnsi } from '../util/format' -import { loadLibraryModule } from '../util/meta' +import { runVmEval } from './eval-shared' import type { Command } from './registry' const ENABLE = new Set(['on', 'true', 't', 'yes', 'y', '1', 'enable']) const DISABLE = new Set(['off', 'false', 'f', 'no', 'n', '0', 'disable']) -const GUARDED_CHILD_PROCESS_METHODS = new Set(['execSync', 'execFileSync', 'spawnSync']) - -/** - * Wraps `fn` (one of `child_process`'s `execSync`/`execFileSync`/`spawnSync`) so a call that - * doesn't specify its own `timeout` gets `timeoutMs` as a default, without overriding a value - * the eval's own code explicitly passed. - * - * These three are the common way eval'd code blocks on a *native* call rather than JS - * execution — and unlike a synchronous JS loop, {@link EvalTimedOutError}'s `vm.Script` timeout - * can't preempt them (V8's execution watchdog only checks in during actual bytecode execution, - * not while parked waiting on a native/libuv call to return; confirmed experimentally). They - * do, however, each already support their own native `timeout` option (which kills the child - * and unblocks the parent) — this just makes sure one is always set. - * - * Doesn't help with other blocking natives with no such option (`fs.readFileSync` hung on a - * slow pipe, a bare `Atomics.wait()`, ...) — those remain a real, if rarer, gap. - */ -// biome-ignore lint/suspicious/noExplicitAny: forwarding execSync/execFileSync/spawnSync's overloaded signatures verbatim. -function withDefaultTimeout(fn: (...args: any[]) => any, timeoutMs: number) { - // biome-ignore lint/suspicious/noExplicitAny: see above. - return (...args: any[]) => { - const last = args[args.length - 1] - if (last !== null && typeof last === 'object' && !Array.isArray(last)) { - if (last.timeout === undefined) args[args.length - 1] = { ...last, timeout: timeoutMs } - } else { - args.push({ timeout: timeoutMs }) - } - return fn(...args) - } -} - -/** - * Compiles user code into a `vm.Script` whose top-level statement immediately invokes an - * async function taking `argNames` as parameters, applied to whatever's stashed at - * `globalThis[argsKey]` at call time (see the handler below). - * - * Run via `runInThisContext()` — the *current* realm, not a new sandboxed one — so it behaves - * exactly like the plain `AsyncFunction` this replaces: full access to Node's ambient globals, - * and live object references (client, message, ...) passed as args work unchanged, with no - * serialization boundary. The difference is that `Script#runInThisContext` accepts a - * `timeout`, which `AsyncFunction` calls never could — see {@link EvalTimedOutError}. - * - * First tries to treat the whole input as a single expression (auto-returning its - * value, e.g. `1 + 1`); if that isn't valid syntax, falls back to a statement body - * where the user is expected to `return` explicitly. - */ -function compile(code: string, argNames: string[], argsKey: string): vm.Script { - const params = argNames.join(', ') - const invocation = `.apply(null, globalThis[${JSON.stringify(argsKey)}])` - try { - return new vm.Script(`(async function (${params}) {\nreturn (${code}\n);\n})${invocation}`, { - filename: 'jsk js', - }) - } catch { - return new vm.Script(`(async function (${params}) {\n${code}\n})${invocation}`, { - filename: 'jsk js', - }) - } -} - -// biome-ignore lint/suspicious/noExplicitAny: structural Message check across libraries. -function isMessage(value: any): boolean { - return ( - typeof value === 'object' && - value !== null && - 'author' in value && - typeof value.react === 'function' && - ('url' in value || 'id' in value) - ) -} - -/** Thrown to unwind a `jsk js` eval that was stopped via `jsk cancel`. */ -class EvalCancelledError extends Error { - constructor() { - super('Cancelled via jsk cancel.') - this.name = 'EvalCancelledError' - } -} - -/** - * Thrown when a synchronous stretch of a `jsk js` eval (e.g. a bare `while (true) {}`) ran - * longer than `evalTimeout` and was forcibly terminated by V8's execution watchdog — confirmed - * experimentally to genuinely preempt a tight JS loop, unlike JS-level cooperative cancellation - * (V8 checks for a pending termination request at loop back-edges/calls during actual bytecode - * execution, so this doesn't require the running code to yield). - * - * Unlike {@link EvalCancelledError}, this can't be triggered by `jsk cancel` reactively — - * while the eval is stuck in synchronous code, the whole bot process is blocked (nothing else - * runs either, including processing a cancel request), so there's no "react to the command" - * moment available. `evalTimeout` is a hard cap enforced up front instead. - * - * Known gap: this covers synchronous *JS* execution, not a blocking *native* call the code - * might make (e.g. `child_process.execSync` on a slow command) — confirmed experimentally that - * such a call is NOT interrupted by the timeout, since V8's watchdog only preempts during - * bytecode execution, not while parked waiting on a native/libuv call to return. Short of - * restarting the process, there's currently no way around that; it would need running the eval - * in a separate thread that can be forcibly terminated (`node:worker_threads`), which isn't - * viable here without losing direct, synchronous access to the live client/message/channel - * objects `jsk js` is built around (they aren't structured-cloneable across a worker boundary). - */ -class EvalTimedOutError extends Error { - constructor(timeoutMs: number) { - super(`Synchronous execution exceeded ${timeoutMs}ms and was terminated.`) - this.name = 'EvalTimedOutError' - } -} - -/** - * Resolves/rejects with `promise`, but rejects with {@link EvalCancelledError} as soon as - * `signal` aborts — whichever comes first. - * - * This only wins the race at an `await` point in the running eval (or in a Promise chain it - * started, e.g. a pending `fetch`) — it doesn't help with a synchronous runaway (`while (true) - * {}` blocks the event loop entirely, so nothing — this included — runs until it returns - * control); {@link EvalTimedOutError} covers that case instead. This one covers the far more - * common "hang" shape: an eval stuck awaiting something that never resolves (an infinite retry - * loop with an `await` in it, a Discord call that never comes back, `await new Promise(() => - * {})`, ...). - */ -function raceAbort(promise: Promise, signal: AbortSignal): Promise { - if (signal.aborted) return Promise.reject(new EvalCancelledError()) - - return new Promise((resolve, reject) => { - const onAbort = () => reject(new EvalCancelledError()) - signal.addEventListener('abort', onAbort, { once: true }) - promise.then( - (value) => { - signal.removeEventListener('abort', onAbort) - resolve(value) - }, - (error) => { - signal.removeEventListener('abort', onAbort) - reject(error) - }, - ) - }) -} - -type WriteFn = typeof process.stdout.write - -/** - * Temporarily wraps `process.stdout`/`process.stderr`'s `write()`, so `jsk js` can surface - * everything written to the terminal during the eval — not just `console.*` (which itself - * writes through these same streams under the hood), but also raw `process.stdout.write()` - * calls and output from any library the user code touches. `restore()` undoes the wrapping and - * returns the captured text; safe to call more than once. - * - * `scrub`, when given (security mode), is also applied to what actually reaches the real - * stream — not just the copy captured here for Discord — so a `console.log(client.token)` - * doesn't leak the token into the bot's own local output/logs either. `null` (the default - * outside security mode) leaves real output completely untouched, unchanged from before. - * - * This patches the process-wide streams for the eval's duration, so two `jsk js` evals - * running concurrently would see each other's output in their capture — an accepted - * trade-off for a single-owner debug tool. - */ -function captureTerminalOutput(scrub: ((text: string) => string) | null): { - restore: () => string -} { - const chunks: string[] = [] - const originalStdoutWrite: WriteFn = process.stdout.write - const originalStderrWrite: WriteFn = process.stderr.write - - // Calls the original via `.apply(stream, ...)` rather than a pre-bound reference, so - // `restore()` can put back the exact original function (not a `.bind()` wrapper around - // it) — otherwise repeated eval calls would pile up an ever-growing chain of bound wrappers. - const wrap = - (stream: NodeJS.WriteStream, original: WriteFn): WriteFn => - (chunk: unknown, ...rest: unknown[]) => { - const text = - typeof chunk === 'string' - ? chunk - : Buffer.isBuffer(chunk) - ? chunk.toString('utf-8') - : String(chunk) - // Stripped for the Discord-bound capture only — colors are decided by whether the - // *real* stdout/stderr is a TTY, which has nothing to do with whether this is headed to - // Discord (not a terminal), so raw escape codes would otherwise show up as literal - // garbage (see stripAnsi's doc comment). The real stream output is left untouched. - chunks.push(stripAnsi(text)) - const outgoing = scrub ? scrub(text) : chunk - // biome-ignore lint/suspicious/noExplicitAny: forwarding Node's overloaded write(chunk, encoding?, callback?) verbatim. - return (original as any).apply(stream, [outgoing, ...rest]) - } - - process.stdout.write = wrap(process.stdout, originalStdoutWrite) - process.stderr.write = wrap(process.stderr, originalStderrWrite) - - return { - restore: () => { - process.stdout.write = originalStdoutWrite - process.stderr.write = originalStderrWrite - return chunks.join('') - }, - } -} - -/** - * Sends `terminalOutput` (wrapped in a codeblock) followed by `text` (plain), preferring a - * single combined message when it fits. - * - * When it doesn't fit, the two are sent as separate, independently-paginated messages instead - * of combining them into one oversized string and handing that to {@link Context.sendResult}: - * that pagination is codeblock-*unaware* (by design — it's also used for plain results with no - * codeblock at all), so it slices the combined text at fixed byte offsets with no regard for - * where the codeblock's fences landed. The fences only happen to survive at the very start of - * page 1 and the very end of the last page; every page in between is missing both, since - * nothing re-opens/re-closes the codeblock at the split points. Sending `terminalOutput` - * through {@link Context.sendCodeblock} instead re-wraps every page in its own fences. - */ -async function sendTerminalAndText( - ctx: Context, - terminalOutput: string, - text: string | null, - filename: string, -): Promise { - if (!terminalOutput) { - if (text !== null) await ctx.sendResult(text, filename) - return - } - - const codeblock = `\`\`\`\n${terminalOutput}\n\`\`\`` - const combined = text !== null ? `${codeblock}\n${text}` : codeblock - - // Matches Context.sendResult's own scrubbed-length check, so this decides on the same basis - // it will (scrubbing doesn't reliably preserve length, and ctx.sendResult scrubs again below - // regardless — redaction is idempotent, so double-scrubbing is harmless). - if (ctx.jsk.scrub(combined).length <= MESSAGE_LIMIT) { - await ctx.sendResult(combined, filename) - return - } - - await ctx.sendCodeblock(terminalOutput, '', filename) - if (text !== null) await ctx.sendResult(text, filename) -} - -async function sendResult(ctx: Context, result: unknown, terminalOutput: string): Promise { - const resultText = isMessage(result) - ? // biome-ignore lint/suspicious/noExplicitAny: verified Message-like above. - `` - : result === undefined - ? null - : inspectResult(result) - - if (!terminalOutput && resultText === null) return - - // Routed through sendTerminalAndText (which itself routes through ctx.sendResult/ - // sendCodeblock, not a raw send) so the captured terminal output gets the same token - // redaction / security-mode secret scrubbing as everything else djsk sends. - await sendTerminalAndText(ctx, terminalOutput, resultText, 'output.js') -} - const jsCommand: Command = { name: 'js', aliases: ['javascript', 'eval'], - summary: 'Evaluates JavaScript. Single expressions auto-return; use `return` for statements.', + summary: 'Evaluates JavaScript. Use `return` to produce a result.', async handler(ctx) { - const code = ctx.codeblock.content - if (!code.trim()) { - await ctx.send('No code to evaluate.') - return - } - - const jsk = ctx.jsk - - // In security mode, hand the scope guarded objects so user code that sends/replies/edits - // (message, channel, DMs, interactions reachable through them) is scrubbed too. - const guard = (value: unknown): unknown => - jsk.config.security && value && typeof value === 'object' - ? guardOutbound(value, (text) => jsk.scrub(text)) - : value - - const controller = new AbortController() - - const scope: Record = { - client: ctx.client, - bot: ctx.client, - ctx, - message: guard(ctx.message), - msg: guard(ctx.message), - interaction: guard(ctx.interaction), - author: guard(ctx.author), - channel: guard(ctx.channel), - guild: ctx.guild, - // biome-ignore lint/suspicious/noExplicitAny: client.user shape is stable. - me: guard((ctx.client as any).user), - _: jsk.lastResult, - vars: jsk.replVars, - // Exposed so cooperative user code can pass it along (e.g. `fetch(url, { signal })`) or - // poll `signal.aborted` in a loop, for cleaner cancellation than raceAbort alone gives. - signal: controller.signal, - // Bare `import(...)` inside a vm.Script requires an opt-in `importModuleDynamically` - // callback, which itself requires Node's --experimental-vm-modules flag — not something - // every djsk consumer's process can be expected to run with. This closure lives outside - // the vm-executed code (in this file's normal module scope), so it can freely `import()` - // without that restriction; eval'd code gets the same capability via `dynamicImport(...)`. - // - // `node:child_process` specifically comes back Proxy-wrapped so execSync/execFileSync/ - // spawnSync default to `evalTimeout` (see withDefaultTimeout) — this is the actual - // interception point, not a global monkeypatch of the module: mutating the module object - // reached via a default import (`import cp from 'node:child_process'`) does NOT affect - // what a *named*/namespace import (what `dynamicImport` returns) sees, confirmed - // experimentally — Node's synthetic ESM bindings for built-ins aren't reliably live - // across that boundary, so patching has to happen right here instead. - dynamicImport: async (specifier: string) => { - const imported = await import(specifier) - if (specifier !== 'node:child_process' && specifier !== 'child_process') return imported - - return new Proxy(imported, { - get(target, prop, receiver) { - const value = Reflect.get(target, prop, receiver) - return typeof prop === 'string' && GUARDED_CHILD_PROCESS_METHODS.has(prop) - ? withDefaultTimeout(value, jsk.config.evalTimeout) - : value - }, - }) - }, - } - const argNames = Object.keys(scope) - const argValues = Object.values(scope) - - // In security mode, also guard the library's outbound methods (send/reply/edit/...) and, - // where the library exposes one, its lower-level REST class — for the duration of the eval - // — so arbitrary Discord calls (any channel/webhook/interaction, or a raw client.rest.post) - // are scrubbed too. - let restoreGuards: (() => void) | null = null - let restoreRestGuard: (() => void) | null = null - if (jsk.config.security) { - const module = await loadLibraryModule() - if (module) { - restoreGuards = installPrototypeGuards(module, (text) => jsk.scrub(text)) - restoreRestGuard = installRestGuard(module, (text) => jsk.scrub(text)) - } - } - - const task = jsk.submitTask('jsk js', () => controller.abort()) - // Unique per invocation (task.index is a monotonic counter) so concurrent evals can't - // clobber each other's stashed arguments on the shared global object. - const argsKey = `__djsk_eval_args_${task.index}__` - // In security mode, also scrub what actually reaches the real terminal (not just the copy - // captured for Discord) — otherwise a `console.log(client.token)` still leaks it into the - // bot's own local logs, which may be shipped to a third-party service the operator doesn't - // fully trust. Off by default (matches the general "token redaction is always on, full - // scrubbing is opt-in" convention) so normal debugging output isn't silently altered. - const capture = captureTerminalOutput(jsk.config.security ? (text) => jsk.scrub(text) : null) - try { - // biome-ignore lint/suspicious/noExplicitAny: temporary bridge for vm.runInThisContext, deleted immediately below. - ;(globalThis as any)[argsKey] = argValues - - let scriptResult: unknown - try { - const script = compile(code, argNames, argsKey) - scriptResult = script.runInThisContext({ - timeout: jsk.config.evalTimeout, - filename: 'jsk js', - }) - } catch (error) { - const errorCode = (error as NodeJS.ErrnoException)?.code - throw errorCode === 'ERR_SCRIPT_EXECUTION_TIMEOUT' - ? new EvalTimedOutError(jsk.config.evalTimeout) - : error - } finally { - // biome-ignore lint/suspicious/noExplicitAny: temporary bridge for vm.runInThisContext. - delete (globalThis as any)[argsKey] - } - - const result = await raceAbort(Promise.resolve(scriptResult), controller.signal) - const terminalOutput = capture.restore() - - if (jsk.retain) jsk.lastResult = result - - await ctx.react('✅') - await sendResult(ctx, result, terminalOutput) - } catch (error) { - const terminalOutput = capture.restore() - - if (error instanceof EvalCancelledError) { - // `jsk cancel` already sends its own confirmation — report only whatever terminal - // output the eval produced before it was stopped, if any, rather than also surfacing - // this as a generic error through Jishaku's catch-and-report handler. - await ctx.react('🛑') - if (terminalOutput) await sendResult(ctx, undefined, terminalOutput) - return - } - - if (error instanceof EvalTimedOutError) { - await ctx.react('⏱️') - await sendTerminalAndText(ctx, terminalOutput, error.message, 'output.js') - return - } - - throw error - } finally { - capture.restore() - restoreRestGuard?.() - restoreGuards?.() - jsk.removeTask(task) - } + await runVmEval(ctx, ctx.codeblock.content, {}, 'jsk js') }, } const retainCommand: Command = { name: 'retain', - summary: 'Toggles REPL variable retention (the `vars` object and `_`). No arg shows status.', + summary: 'Toggles REPL variable retention (the `vars` object and `_`).', async handler(ctx) { const jsk = ctx.jsk const toggle = ctx.args[0]?.toLowerCase() diff --git a/src/commands/mjs.test.ts b/src/commands/mjs.test.ts new file mode 100644 index 0000000..3079a68 --- /dev/null +++ b/src/commands/mjs.test.ts @@ -0,0 +1,114 @@ +import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Context } from '../context' +import { Jishaku } from '../jishaku' +import { mjsCommands } from './mjs' + +const mjsCommand = mjsCommands[0] + +function makeJsk(configOverrides: Record = {}): Jishaku { + return new Jishaku( + // biome-ignore lint/suspicious/noExplicitAny: minimal fake client for tests. + { token: 't0ken-fake' } as any, + { consoleLog: false, ...configOverrides }, + ) +} + +function makeContext(code: string, jsk: Jishaku) { + const send = vi.fn(async (payload: unknown) => ({ payload })) + const react = vi.fn(async () => {}) + // biome-ignore lint/suspicious/noExplicitAny: minimal fake message for tests. + const message = { channel: { send }, react, author: {} } as any + const source = { kind: 'message' as const, message } + const ctx = new Context(jsk, source, 'mjs', code) + return { ctx, send, react } +} + +describe('jsk mjs', () => { + let projectRoot: string + let jsk: Jishaku + + beforeEach(() => { + projectRoot = mkdtempSync(path.join(tmpdir(), 'djsk-mjs-host-')) + jsk = makeJsk({ evalModuleDir: projectRoot }) + }) + + afterEach(() => { + rmSync(projectRoot, { recursive: true, force: true }) + }) + + it('supports real top-level import of a node: builtin', async () => { + const { ctx, send } = makeContext( + 'import os from "node:os";\nexport default typeof os.platform();', + jsk, + ) + + await mjsCommand.handler(ctx) + + const [payload] = send.mock.calls[0] as [{ content: string }] + expect(payload.content).toBe('string') + }) + + it('resolves a real installed package via import, unlike a data: URL', async () => { + const packageDir = path.join(projectRoot, 'node_modules', 'fake-esm-pkg') + mkdirSync(packageDir, { recursive: true }) + writeFileSync( + path.join(packageDir, 'package.json'), + JSON.stringify({ name: 'fake-esm-pkg', version: '1.0.0', type: 'module', main: 'index.js' }), + ) + writeFileSync(path.join(packageDir, 'index.js'), 'export default 42;') + + const { ctx, send } = makeContext( + 'import value from "fake-esm-pkg";\nexport default value;', + jsk, + ) + + await mjsCommand.handler(ctx) + + const [payload] = send.mock.calls[0] as [{ content: string }] + expect(payload.content).toBe('42') + }) + + it('uses `export default` (not `return`) to surface a result', async () => { + const { ctx, send } = makeContext('return 1 + 1', jsk) + + // `return` at the top level of a module is a syntax error there, unlike in `jsk js`/`jsk + // cjs` — confirming `jsk mjs` doesn't share their `return`-based result convention. (The + // exact error type is loader-dependent — a real Node process throws `SyntaxError`, while + // vitest's own Vite-based dynamic `import()` transform reports a `RolldownError` for the + // same code — so this only asserts that it throws and never reaches `ctx.send`.) + await expect(mjsCommand.handler(ctx)).rejects.toThrow(/return/i) + expect(send).not.toHaveBeenCalled() + }) + + it('can access the injected scope (client, message, ...)', async () => { + const { ctx, send } = makeContext('export default typeof client;', jsk) + + await mjsCommand.handler(ctx) + + const [payload] = send.mock.calls[0] as [{ content: string }] + expect(payload.content).toBe('object') + }) + + it('writes its transient module file under evalModuleDir/.djsk-tmp and cleans it up after', async () => { + const { ctx } = makeContext('export default 1;', jsk) + + await mjsCommand.handler(ctx) + + const tmpDir = path.join(projectRoot, '.djsk-tmp') + expect(existsSync(tmpDir)).toBe(true) + // Only the .gitignore should remain; the per-eval .mjs file is deleted afterward. + expect(readdirSync(tmpDir)).toEqual(['.gitignore']) + }) + + it('cleans up the temp file even when the eval throws', async () => { + const { ctx } = makeContext('throw new Error("boom");', jsk) + + await expect(mjsCommand.handler(ctx)).rejects.toThrow('boom') + + const tmpDir = path.join(projectRoot, '.djsk-tmp') + expect(readdirSync(tmpDir)).toEqual(['.gitignore']) + }) +}) diff --git a/src/commands/mjs.ts b/src/commands/mjs.ts new file mode 100644 index 0000000..e574274 --- /dev/null +++ b/src/commands/mjs.ts @@ -0,0 +1,144 @@ +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { pathToFileURL } from 'node:url' +import { + buildBaseScope, + captureTerminalOutput, + EvalCancelledError, + installSecurityGuards, + makeGuard, + raceAbort, + sendResult, +} from './eval-shared' +import type { Command } from './registry' + +/** Subdirectory (under `evalModuleDir`) `jsk mjs` writes its transient per-eval file into. */ +const TEMP_SUBDIR = '.djsk-tmp' + +/** + * Ensures `/.djsk-tmp` exists, dropping a `*` `.gitignore` into it the first time so + * the transient eval files it holds don't show up in the host bot project's git status. + */ +function ensureTempDir(baseDir: string): string { + const dir = path.join(baseDir, TEMP_SUBDIR) + mkdirSync(dir, { recursive: true }) + const gitignore = path.join(dir, '.gitignore') + if (!existsSync(gitignore)) writeFileSync(gitignore, '*\n') + return dir +} + +const mjsCommand: Command = { + name: 'mjs', + aliases: ['esm'], + summary: + 'Evaluates JavaScript as a real ES module — `import` works. Use `export default` for a result.', + /** + * Unlike `jsk js`/`jsk cjs` (both run via `vm.Script`, see `compile()` in `eval-shared.ts`), + * static `import` syntax cannot appear inside a function body at all — that's a hard + * ECMAScript rule, not a `vm.Script` limitation. Real `import`/top-level `await` therefore + * requires actually running the code as an ES module's top-level, which means going through + * Node's real module loader via dynamic `import()`. + * + * That in turn requires a real file on disk, not a `data:` URL: verified experimentally that + * `import()`-ing a `data:text/javascript,...` URL resolves `node:` builtins fine but fails to + * resolve real npm packages (`Failed to resolve module specifier "..." ... Invalid relative + * URL or base scheme is not hierarchical`), since a `data:` URL has no filesystem location + * for Node's node_modules walk-up to start from. Writing the generated module to a temp file + * under `evalModuleDir` (default `process.cwd()`, i.e. the *host bot's* project — not djsk's + * own) gives the resolver a real location to walk up from, so bare imports of the host's own + * dependencies work. The file is deleted again immediately after (`finally` below); only + * `.djsk-tmp/` itself is left behind, reused across evals. + * + * Context variables (`client`, `message`, ...) can't be passed as function parameters here + * (there's no wrapping function to receive them) — they're stashed on `globalThis` instead + * and destructured by the generated module's first line, exactly like `jsk js`/`jsk cjs` + * bridge their `vm.Script` arguments (see `compile()`), just via a real `const` statement + * instead of a function call. `import`/`export` declarations are hoisted regardless of where + * they appear in the module, so user code can freely `import` after that destructuring line. + * + * Trade-off, not fixed: because this doesn't go through `vm.Script`, there is no equivalent + * of `EvalTimedOutError`'s synchronous-runaway protection here — a bare `while (true) {}` in + * `jsk mjs` blocks the whole process with no recovery short of a restart. `jsk cancel` (via + * `raceAbort`) still works for an eval stuck *awaiting* something, same as `js`/`cjs`. + */ + async handler(ctx) { + const code = ctx.codeblock.content + if (!code.trim()) { + await ctx.send('No code to evaluate.') + return + } + + const jsk = ctx.jsk + const guard = makeGuard(ctx) + const controller = new AbortController() + const scope = buildBaseScope(ctx, guard, controller.signal) + + // Submitted before the (possibly awaiting) security-guard install below: `await` always + // defers to a microtask tick even when the awaited call resolves synchronously, so + // registering the task first guarantees it's visible in `jsk tasks`/cancellable via + // `jsk cancel` immediately, without an extra tick's delay whenever security mode is off. + const task = jsk.submitTask('jsk mjs', () => controller.abort()) + const { restoreGuards, restoreRestGuard } = await installSecurityGuards(ctx) + // Unique per invocation (task.index is a monotonic counter) so concurrent evals get their + // own file and can't clobber each other's stashed arguments on the shared global object. + const argsKey = `__djsk_eval_args_${task.index}__` + const capture = captureTerminalOutput(jsk.config.security ? (text) => jsk.scrub(text) : null) + + const dir = ensureTempDir(jsk.config.evalModuleDir) + const file = path.join(dir, `eval-${task.index}.mjs`) + + try { + // biome-ignore lint/suspicious/noExplicitAny: temporary bridge for the generated module, deleted immediately below. + ;(globalThis as any)[argsKey] = scope + + const preamble = `const { ${Object.keys(scope).join(', ')} } = globalThis[${JSON.stringify(argsKey)}];\n` + writeFileSync(file, preamble + code, 'utf-8') + + let namespace: Record + try { + // Cache-busting query string so repeated evals of the same task-index-free filename + // (or, after a restart, the same task index again) never serve a stale cached module. + namespace = await raceAbort( + import(`${pathToFileURL(file).href}?t=${Date.now()}`), + controller.signal, + ) + } finally { + // biome-ignore lint/suspicious/noExplicitAny: temporary bridge for the generated module. + delete (globalThis as any)[argsKey] + } + + const terminalOutput = capture.restore() + const result = 'default' in namespace ? namespace.default : undefined + + if (jsk.retain) jsk.lastResult = result + + await ctx.react('✅') + await sendResult(ctx, result, terminalOutput) + } catch (error) { + const terminalOutput = capture.restore() + + if (error instanceof EvalCancelledError) { + // `jsk cancel` already sends its own confirmation — report only whatever terminal + // output the eval produced before it was stopped, if any, rather than also surfacing + // this as a generic error through Jishaku's catch-and-report handler. + await ctx.react('🛑') + if (terminalOutput) await sendResult(ctx, undefined, terminalOutput) + return + } + + throw error + } finally { + capture.restore() + restoreRestGuard?.() + restoreGuards?.() + jsk.removeTask(task) + try { + rmSync(file, { force: true }) + } catch { + // Best-effort cleanup; a leftover temp file is harmless (and .gitignore'd). + } + } + }, +} + +export const mjsCommands: Command[] = [mjsCommand] diff --git a/src/commands/registry.ts b/src/commands/registry.ts index 61ef23e..8292a1c 100644 --- a/src/commands/registry.ts +++ b/src/commands/registry.ts @@ -1,7 +1,9 @@ import type { Context } from '../context' +import { cjsCommands } from './cjs' import { filesystemCommands } from './filesystem' import { jsCommands } from './js' import { managementCommands } from './management' +import { mjsCommands } from './mjs' import { rootCommands } from './root' import { shellCommands } from './shell' @@ -21,6 +23,8 @@ export interface Command { export const COMMANDS: Command[] = [ ...rootCommands, ...jsCommands, + ...cjsCommands, + ...mjsCommands, ...shellCommands, ...managementCommands, ...filesystemCommands, diff --git a/src/jishaku.ts b/src/jishaku.ts index 071e319..1226682 100644 --- a/src/jishaku.ts +++ b/src/jishaku.ts @@ -63,6 +63,7 @@ function resolveConfig(config: JishakuConfig): ResolvedConfig { secretValues: config.secretValues ?? [], evalTimeout: config.evalTimeout ?? 10_000, shell: config.shell ?? null, + evalModuleDir: config.evalModuleDir ?? process.cwd(), } } @@ -240,7 +241,7 @@ export class Jishaku { const subcommand: string = raw.options.getSubcommand() if (CODE_SUBCOMMANDS.has(subcommand)) { - await raw.showModal(buildCodeModal(subcommand as 'js' | 'sh')) + await raw.showModal(buildCodeModal(subcommand as 'js' | 'cjs' | 'mjs' | 'sh')) return } diff --git a/src/security.test.ts b/src/security.test.ts index d0a5a1f..9731b29 100644 --- a/src/security.test.ts +++ b/src/security.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { guardOutbound, SecretScrubber } from './security' +import { guardOutbound, SecretScrubber, scrubMessagePayload } from './security' // biome-ignore lint/suspicious/noExplicitAny: minimal fake client for tests. const scrubber = new SecretScrubber({ token: 'super-secret-bot-token' } as any) @@ -91,3 +91,33 @@ describe('guardOutbound', () => { expect(guarded.foo).toBe('bar') }) }) + +describe('scrubMessagePayload', () => { + const scrub = (text: string) => text.replaceAll('SECRET', '[redacted]') + + // Regression test for a bug where `message.reply('a')` failed with discord.js's + // `DiscordAPIError[50006]: Cannot send an empty message`. Its internal `channel.send(data)` + // passes a `MessagePayload`-like class instance (real content nested in `.options.content`, + // not top-level) rather than a plain object; a naive `{ ...payload }` clone stripped its + // prototype, which broke discord.js's own `instanceof MessagePayload` branch downstream. + it('preserves the prototype of a class-instance payload and scrubs its nested options.content', () => { + class MessagePayload { + constructor( + public target: unknown, + public options: { content?: string }, + ) {} + resolveBody() { + return this + } + } + + const payload = new MessagePayload({}, { content: 'a SECRET value' }) + const out = scrubMessagePayload(payload, scrub, true) as MessagePayload + + expect(out).toBeInstanceOf(MessagePayload) + expect(typeof out.resolveBody).toBe('function') + expect(out.options.content).toBe('a [redacted] value') + // The original must be left untouched (scrubbing must not mutate the passed-in payload). + expect(payload.options.content).toBe('a SECRET value') + }) +}) diff --git a/src/security.ts b/src/security.ts index baee64d..2e4893a 100644 --- a/src/security.ts +++ b/src/security.ts @@ -120,9 +120,24 @@ export function scrubMessagePayload( if (typeof payload === 'string') return scrub(payload) if (!payload || typeof payload !== 'object') return payload - const out = { ...payload } + // Preserve the payload's prototype rather than a plain `{ ...payload }` spread: some + // libraries (discord.js) pass their own payload class through here — e.g. `Message#reply` + // builds a `MessagePayload` and hands it straight to `channel.send()`, which branches on + // `instanceof MessagePayload` to decide how to resolve it. A plain-object clone silently + // loses that prototype (and methods like `resolveBody()`), so `send()` falls through to + // treating the clone itself as raw user options — which don't have a top-level `content` (an + // instance's real content lives one level down, in `.options.content`, see below) — and ends + // up building an empty body, surfacing downstream as a confusing + // `DiscordAPIError[50006]: Cannot send an empty message`. + const out = Object.assign(Object.create(Object.getPrototypeOf(payload)), payload) if (typeof out.content === 'string') out.content = scrub(out.content) + // Covers MessagePayload-shaped instances (see above), whose actual content lives in + // `.options.content` rather than at the top level. + if (out.options && typeof out.options === 'object' && typeof out.options.content === 'string') { + out.options = { ...out.options, content: scrub(out.options.content) } + } + if (scrubFiles && Array.isArray(out.files)) { // biome-ignore lint/suspicious/noExplicitAny: file entries are duck-typed across libraries. out.files = out.files.map((file: any) => { diff --git a/src/slash.test.ts b/src/slash.test.ts index 0a39205..b0ee153 100644 --- a/src/slash.test.ts +++ b/src/slash.test.ts @@ -18,6 +18,8 @@ describe('getSlashCommandData', () => { 'status', 'help', 'js', + 'cjs', + 'mjs', 'sh', 'cat', 'curl', @@ -43,11 +45,13 @@ describe('getSlashCommandData', () => { expect(byName.retain.options?.[0].required).toBe(false) }) - it('gives js/sh no options, since they use a code-input modal instead', () => { + it('gives js/cjs/mjs/sh no options, since they use a code-input modal instead', () => { const data = getSlashCommandData() const byName = Object.fromEntries(data.options.map((option) => [option.name, option])) expect(byName.js.options).toBeUndefined() + expect(byName.cjs.options).toBeUndefined() + expect(byName.mjs.options).toBeUndefined() expect(byName.sh.options).toBeUndefined() }) }) @@ -91,8 +95,10 @@ describe('buildCodeModal', () => { }) describe('CODE_SUBCOMMANDS', () => { - it('contains exactly js and sh', () => { + it('contains exactly js, cjs, mjs and sh', () => { expect(CODE_SUBCOMMANDS.has('js')).toBe(true) + expect(CODE_SUBCOMMANDS.has('cjs')).toBe(true) + expect(CODE_SUBCOMMANDS.has('mjs')).toBe(true) expect(CODE_SUBCOMMANDS.has('sh')).toBe(true) expect(CODE_SUBCOMMANDS.has('cat')).toBe(false) }) diff --git a/src/slash.ts b/src/slash.ts index ac0393e..3f4db61 100644 --- a/src/slash.ts +++ b/src/slash.ts @@ -15,7 +15,7 @@ const OPTION_TYPE = { } as const /** Subcommands that take free-form code and prompt with a modal instead of a string option. */ -export const CODE_SUBCOMMANDS = new Set(['js', 'sh']) +export const CODE_SUBCOMMANDS = new Set(['js', 'cjs', 'mjs', 'sh']) /** Prefix used for the modal `customId`, so a submission can be traced back to its subcommand. */ const MODAL_ID_PREFIX = 'djsk:' @@ -43,6 +43,16 @@ export function getSlashCommandData(name = 'jsk') { name: 'js', description: 'Evaluates JavaScript (opens a code input prompt).', }, + { + type: OPTION_TYPE.SUB_COMMAND, + name: 'cjs', + description: 'Evaluates JavaScript with `require` available (opens a code input prompt).', + }, + { + type: OPTION_TYPE.SUB_COMMAND, + name: 'mjs', + description: 'Evaluates JavaScript as a real ES module (opens a code input prompt).', + }, { type: OPTION_TYPE.SUB_COMMAND, name: 'sh', @@ -122,7 +132,17 @@ const MODAL_LABELS: Record