diff --git a/.gitignore b/.gitignore index b4bf0160..6b1fca5e 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,4 @@ bench/runs/ bench/llm.patch.yml # Update-lock sidecars the CLI leaves next to whatever it rewrites (also seen under test/). *.dsh-ops-update-*.lock +.npm-cache/ diff --git a/cordis.patch.yml b/cordis.patch.yml index 6f7590c9..e829917b 100644 --- a/cordis.patch.yml +++ b/cordis.patch.yml @@ -29,6 +29,12 @@ residentTools: true discovery: enabled: true + # Inert until template is set; off by default so an install that never + # opted in gets no listener at all. + download-guard: + enabled: false + downloadGuard: + template: "" panel: enabled: false tui: diff --git a/docs/configuration-reference.md b/docs/configuration-reference.md index 556f58ce..6c019ff2 100644 --- a/docs/configuration-reference.md +++ b/docs/configuration-reference.md @@ -83,6 +83,7 @@ falling back to the built-in surface; scoped diagnostics remain accessible. | `binaryPath` / `bashPath` | managed worker/Shell resolution unless explicitly selected | | `required` | false; missing required worker fails activation | | `denyCredentialReads` | true; Native-only override, PTC rejects overrides and retains its boundary | +| `downloadGuard.template` | empty; inert until set. A command with `{url}` (required) and optional `{file}` that a disk-writing shell download is redirected to | | `allowSystemShellFallback` | false; Windows automatic fallback is opt-in | `shell: false` retains explicit `bashPath`, `allowSystemShellFallback` and `hidePwsh` intent, diff --git "a/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" "b/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" index c8a8bf21..b279f302 100644 --- "a/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" +++ "b/docs/\351\205\215\347\275\256\345\217\202\350\200\203.md" @@ -68,6 +68,7 @@ presets: | `binaryPath`/`bashPath` | 默认受管理解析,可显式指定 | | `required` | false;所需 worker 缺失时拒绝激活 | | `denyCredentialReads` | true,Native 可显式覆盖;PTC 拒绝覆盖并持续执行边界 | +| `downloadGuard.template` | 空;未设置时完全不生效。一条含 `{url}`(必需)与可选 `{file}` 的命令模板,会落盘的 shell 下载将被重定向到它 | | `allowSystemShellFallback` | false,Windows 自动系统回退须主动选择 | `shell: false` 保留显式 `bashPath`、`allowSystemShellFallback` 与 `hidePwsh` 意图,但不发布 Bash、不隐藏宿主 Shell;重新启用时应用保存值。自定义 PTC 卡片锁定 `files` 开启,并提示用 `residentTools` 控制启动 API。PTC 必须 `files: true`,且宿主真实为 run_code-only 展示并有 PTC runtime;不接受 `residentNames`,不把外部 Native 提供者当类型化 SDK。[第三方 PTC 注册暂不支持](外部工具提供者.md)。 diff --git a/package.json b/package.json index a23f53f8..c9f8e65c 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,12 @@ "./discovery/package.json": "./lib/components/discovery/package.json", "./discovery/locale/*.json": "./lib/components/discovery/locale/*.json", "./discovery/icon": "./lib/components/discovery/icon.png", + "./download-guard": { + "default": "./lib/components/download-guard/index.js" + }, + "./download-guard/package.json": "./lib/components/download-guard/package.json", + "./download-guard/locale/*.json": "./lib/components/download-guard/locale/*.json", + "./download-guard/icon": "./lib/components/download-guard/icon.png", "./hide-official": { "default": "./lib/components/hide-official/index.js" }, diff --git a/src/components/defaults.ts b/src/components/defaults.ts index 01ed3409..80aa0cbe 100644 --- a/src/components/defaults.ts +++ b/src/components/defaults.ts @@ -1,9 +1,11 @@ /** Package defaults only. Saved Loader overrides take precedence over this layer. */ -export const COMPONENTS = ['mode-minimal', 'mode-standard', 'mode-ptc', 'mode-cordis', 'discovery', 'panel', 'tui'] as const +export const COMPONENTS = ['mode-minimal', 'mode-standard', 'mode-ptc', 'mode-cordis', 'discovery', 'download-guard', 'panel', 'tui'] as const export type Component = typeof COMPONENTS[number] export function componentEnabled(component: Component, _platform: string): boolean { - // Enhanced presets and progressive discovery start enabled; data capture and - // the TUI integration are opt-in. - return !['panel', 'tui'].includes(component) + // Enhanced presets and progressive discovery start enabled. The download + // guard is opt-in: it only acts once an operator names a downloader, so + // enabling it by default would add a listener to installs that gain nothing. + // Data capture and the TUI integration are opt-in for their own reasons. + return !['download-guard', 'panel', 'tui'].includes(component) } export function componentEntry(component: Component): string { return `dsh-ops/${component}` } diff --git a/src/components/download-guard/icon.png b/src/components/download-guard/icon.png new file mode 100644 index 00000000..cb35b43e Binary files /dev/null and b/src/components/download-guard/icon.png differ diff --git a/src/components/download-guard/index.ts b/src/components/download-guard/index.ts new file mode 100644 index 00000000..0b5e3eb5 --- /dev/null +++ b/src/components/download-guard/index.ts @@ -0,0 +1,65 @@ +/** + * `dsh-ops/download-guard` row: refuse a shell download that writes to disk and + * name the operator's faster downloader instead. + * + * WHY THIS IS A ROW AND NOT PART OF A MODE + * + * It changes what the model is allowed to do, so it must be switchable on its + * own. Mounting it as a row means disabling it in the profile tears its + * listener down through the normal fiber lifecycle, with no extra flag to keep + * in sync. + * + * WHY THE LISTENER ALLOWS BY DEFAULT + * + * With no `downloadGuard.template` configured this row registers nothing at + * all. That is deliberate: the plugin ships to installs that never asked for a + * download policy, and silently refusing their `curl -o` would be a regression + * they did not choose. The whole decision lives in `./policy.ts`, which returns + * `allow` until the operator names a downloader. + * + * @module dsh-ops/components/download-guard + */ +import type { Context } from '../../adapter/dsh/types.js' +import { resolveConfig } from '../../config.js' +import { inspectCommand } from '../../download-guard/detect.js' +import { decideDownload } from '../../download-guard/policy.js' + +export const name = 'dsh-ops-download-guard' + +/** Shell tools whose `command` argument is worth inspecting. */ +const SHELL_TOOLS = new Set(['ops_bash', 'bash', 'pwsh', 'pwsh_persistent', 'shell']) + +/** + * Mount the row. + * + * @param ctx - the row's cordis context. + * @param rawConfig - the row's config, validated by `resolveConfig`. + */ +export function apply(ctx: Context, rawConfig?: unknown): void { + const config = resolveConfig(rawConfig) + const guard = config.downloadGuard + + // Nothing configured: register nothing at all, so an install that never opted + // in pays no listener and no per-call work. + if (guard.template.trim() === '') return + + ctx.on('tools/pre-execute', async (exec, next) => { + try { + const toolName = typeof exec?.name === 'string' ? exec.name : '' + if (!SHELL_TOOLS.has(toolName)) return next() + const args = exec?.arguments as { command?: unknown, script_path?: unknown } | undefined + if (args === null || typeof args !== 'object') return next() + // `script_path` runs a file whose contents are not in this call, so there + // is nothing to inspect. Guessing at the file would be a false positive. + if (typeof args.script_path === 'string' && args.script_path !== '') return next() + const command = args.command + const hit = inspectCommand(command) + const decision = decideDownload(hit, guard, typeof command === 'string' ? command : '') + if (decision.kind === 'allow') return next() + return { kind: 'deny', reason: decision.reason, info: decision.info } + } catch { + // A detector bug must never block an unrelated command. + return next() + } + }) +} diff --git a/src/components/download-guard/locale/en.json b/src/components/download-guard/locale/en.json new file mode 100644 index 00000000..5df5ea2b --- /dev/null +++ b/src/components/download-guard/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "dsh-ops-download-guard", + "description": "Refuse shell downloads that write to disk, and name the faster downloader the operator configured. Does nothing until downloadGuard.template is set." + } +} diff --git a/src/components/download-guard/locale/zh.json b/src/components/download-guard/locale/zh.json new file mode 100644 index 00000000..695ffa50 --- /dev/null +++ b/src/components/download-guard/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "dsh-ops-download-guard", + "description": "拦截会把文件写到磁盘的 shell 下载,并给出运维配置的更快下载器。未配置 downloadGuard.template 时完全不生效。" + } +} diff --git a/src/components/download-guard/package.json b/src/components/download-guard/package.json new file mode 100644 index 00000000..6f0c9869 --- /dev/null +++ b/src/components/download-guard/package.json @@ -0,0 +1,6 @@ +{ + "name": "dsh-ops-download-guard", + "description": "Refuse a shell download that writes to disk and name the operator's configured faster downloader instead. Inert until downloadGuard.template is set.", + "icon": "./icon.png", + "type": "module" +} diff --git a/src/config.ts b/src/config.ts index 5f08bda6..644808a5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -22,6 +22,7 @@ export const LEGACY_FASTCTX_KEYS = Object.freeze(['serverName', 'enableShellTool export const CORE_CONFIG_KEYS = Object.freeze([ ...LEGACY_FASTCTX_KEYS, 'binaryPath', 'required', 'shellPolicy', 'deniedHostTools', 'promptPolicy', 'extraGuidance', 'bashPath', 'publishBashTool', 'allowSystemShellFallback', 'denyCredentialReads', 'configUpdateVersion', + 'downloadGuard', ]) /** Upper bound accepted for the legacy `toolCallTimeoutMs`, matching the loader's timer ceiling. */ @@ -99,6 +100,40 @@ function optionalStringList(raw: Record, key: string, fallback: return [...value] } +/** + * Validate the `downloadGuard` block. + * + * A malformed block fails loud and names the offending key, matching the rest + * of this file. An absent block is the normal state and resolves to "no + * forwarder configured", which leaves every download command alone. + * + * @param rawConfig - the raw `downloadGuard` value, or undefined. + * @returns the resolved configuration. + */ +function resolveDownloadGuard(rawConfig: unknown): DownloadGuardConfig { + if (rawConfig === undefined || rawConfig === null) return { template: '' } + if (typeof rawConfig !== 'object' || Array.isArray(rawConfig)) { + throw new ConfigError(`downloadGuard must be an object, got ${JSON.stringify(rawConfig)}`) + } + const raw = rawConfig as Record + rejectUnknownKeys(raw, ['template']) + // An empty string is how the shipped patch row spells "not set", so it must + // resolve to the same inert state as an absent key rather than failing the + // load of an install that never configured anything. + const value = raw.template + if (value !== undefined && value !== null && typeof value !== 'string') { + throw new ConfigError(`downloadGuard.template must be a string, got ${JSON.stringify(value)}`) + } + const template = typeof value === 'string' ? value.trim() : '' + if (template.trim() !== '' && !template.includes('{url}')) { + throw new ConfigError( + `downloadGuard.template must contain {url} so the suggested command can identify what to fetch, ` + + `got ${JSON.stringify(template)}`, + ) + } + return { template } +} + /** The resolved configuration, with every default applied. */ export interface ResolvedConfig { configUpdateVersion: number @@ -112,6 +147,19 @@ export interface ResolvedConfig { bashPath: string | undefined publishBashTool: boolean allowSystemShellFallback: boolean + downloadGuard: DownloadGuardConfig +} + +/** + * Operator configuration for the download guard. + * + * Empty by default: an unconfigured install must behave exactly as it did + * before this feature existed. The guard only refuses a call once the operator + * has named a downloader that is strictly better than the one the model picked. + */ +export interface DownloadGuardConfig { + /** Command template with `{url}` (required to act) and optional `{file}`. */ + template: string } /** @@ -171,6 +219,7 @@ export function resolveConfig(rawConfig?: unknown): ResolvedConfig { // needs one of them can turn the fence off for this row; the environment // variable only ever widens the list, so a model cannot open it. denyCredentialReads: optionalBoolean(raw, 'denyCredentialReads', true), + downloadGuard: resolveDownloadGuard(raw.downloadGuard), } } diff --git a/src/download-guard/detect.ts b/src/download-guard/detect.ts new file mode 100644 index 00000000..5f94409b --- /dev/null +++ b/src/download-guard/detect.ts @@ -0,0 +1,242 @@ +/** + * Detect a shell call that writes downloaded bytes to a local file. + * + * WHY THIS IS DETECTION ONLY + * + * Deciding what to DO about such a call is a policy question and lives in + * `./policy.ts`; this module answers one narrow, testable question: given a + * command string, does it look like a download that lands on disk? + * + * FALSE NEGATIVES ARE CHEAPER THAN FALSE POSITIVES + * + * Missing a download leaves the status quo: the command runs as it always did. + * Refusing an ordinary command breaks a working session and is visible to the + * user. Every rule below therefore requires a WRITE signal — a flag that names + * an output file — and never fires merely because the text mentions a + * downloader. That distinction is not theoretical: a rule matching the bare + * word `download` refused a legitimate `node download.cjs --help`, because in a + * shell `node download.cjs` and `aria2-dl.js something` are indistinguishable + * strings. + * + * @module dsh-ops/download-guard/detect + */ + +/** What matched, and where the bytes appear to be going. */ +export interface DownloadHit { + /** Short machine name of the matched rule, for tests and refusal text. */ + rule: string + /** The URL the command appears to fetch, when one is visible in the text. */ + url?: string + /** The local file the command appears to write, when one is visible. */ + target?: string +} + +/** + * Split a command line into sub-commands on unquoted `;`, `&&`, `||`, `|` and + * newlines, so a download buried after an unrelated `cd` is still examined on + * its own. + * + * Quotes are tracked so a separator inside them does not split: `echo "a;b"` + * is one sub-command. This is not a full shell parser and does not try to be — + * it only has to be conservative in the direction that avoids false positives. + * + * @param command - the raw command string. + * @returns one trimmed string per sub-command, separators removed. + */ +export function splitSubcommands(command: string): string[] { + const parts: string[] = [] + let current = '' + let quote: 'none' | 'single' | 'double' = 'none' + for (let index = 0; index < command.length; index += 1) { + const char = command[index] as string + if (quote === 'single') { + current += char + if (char === "'") quote = 'none' + continue + } + if (quote === 'double') { + current += char + if (char === '"') quote = 'none' + else if (char === '\\' && index + 1 < command.length) { + current += command[index + 1] as string + index += 1 + } + continue + } + if (char === "'") { quote = 'single'; current += char; continue } + if (char === '"') { quote = 'double'; current += char; continue } + if (char === '\\' && index + 1 < command.length) { + current += char + (command[index + 1] as string) + index += 1 + continue + } + if (char === ';' || char === '\n') { parts.push(current); current = ''; continue } + if (char === '&' && command[index + 1] === '&') { parts.push(current); current = ''; index += 1; continue } + if (char === '|') { + parts.push(current) + current = '' + if (command[index + 1] === '|') index += 1 + continue + } + current += char + } + parts.push(current) + return parts.map(part => part.trim()).filter(part => part.length > 0) +} + +/** Strip a leading `VAR=value` assignment run, which is not the command word. */ +function commandWordOf(segment: string): { word: string, rest: string } { + let rest = segment.trim() + for (;;) { + const assignment = /^[A-Za-z_][A-Za-z0-9_]*=\S*\s+/.exec(rest) + if (assignment === null) break + rest = rest.slice(assignment[0].length) + } + const match = /^(\S+)(?:\s+([\s\S]*))?$/.exec(rest) + if (match === null) return { word: '', rest: '' } + return { word: match[1] ?? '', rest: match[2] ?? '' } +} + +/** + * Extract the argument that follows exactly one of `flags`. + * + * Both `-o FILE` and `-oFILE` spellings are accepted, and `--long=FILE` too, + * because all three appear in real usage. A repeated flag must resolve to the + * LAST occurrence, because that is the one the tool honours. + * + * A value of `-` is returned as-is: callers decide what it means, because for + * curl and wget it means stdout rather than a file. + * + * @param args - the argument tail of a sub-command. + * @param flags - short and long spellings to look for, e.g. `['-o', '--output']`. + * @returns the value when one is found. + */ +function flagValue(args: string[], flags: readonly string[]): string | undefined { + let found: string | undefined + for (let index = 0; index < args.length; index += 1) { + const arg = args[index] as string + for (const flag of flags) { + if (arg === flag) { + const next = args[index + 1] + // A leading `-` normally means the next option, not a value; `-` alone + // is the documented stdout sentinel and must be taken literally. + if (next !== undefined && (next === '-' || !next.startsWith('-'))) found = next + continue + } + if (arg.startsWith(`${flag}=`)) { found = arg.slice(flag.length + 1); continue } + // A short flag may carry its value with no separator: -oFILE. + if (flag.length === 2 && arg.startsWith(flag) && arg.length > 2) found = arg.slice(2) + } + } + return found !== undefined && found !== '' ? found : undefined +} + +/** First `http://` or `https://` token in the segment, if any. */ +function urlOf(segment: string): string | undefined { + const match = /(https?:\/\/[^\s"'`]+)/i.exec(segment) + return match?.[1] +} + +/** A trailing `> FILE` / `>> FILE` redirect target, when present. */ +function redirectTargetOf(segment: string): string | undefined { + const match = /(?:^|[^>\d])>{1,2}\s*(?![&|])([^\s;|&]+)/.exec(segment) + const target = match?.[1] + if (target === undefined) return undefined + return target === '/dev/null' || target === 'nul' || target === '$null' ? undefined : target +} + +/** + * Whether the sub-command fetches over the network at all. + * + * Used only to decide whether a redirect target is worth reporting; the + * refusal itself is driven by the presence of a write flag. + * + * @param segment - one sub-command. + * @returns true when a URL or a known downloader appears. + */ +function fetchesNetwork(segment: string): boolean { + return urlOf(segment) !== undefined || /\b(curl|wget)\b/i.test(segment) +} + +/** Quote-aware split of an argument tail into tokens. */ +function argumentsOf(rest: string): string[] { + const match = rest.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) + return match ?? [] +} + +/** + * Inspect one sub-command. + * + * @param segment - one sub-command from {@link splitSubcommands}. + * @returns the hit, or undefined when the command does not write a download. + */ +export function inspectSegment(segment: string): DownloadHit | undefined { + const { word, rest } = commandWordOf(segment) + if (word === '') return undefined + const base = word.replace(/^.*[\\/]/, '').replace(/\.exe$/i, '').toLowerCase() + const args = argumentsOf(rest) + + // curl: -o / --output names a file; -O / --remote-name derives the name from + // the URL but still lands one. A bare `curl URL` prints to stdout, so it is + // NOT a download and must stay allowed. + if (base === 'curl') { + const output = flagValue(args, ['-o', '--output']) + // `-o -` streams to stdout; nothing lands on disk. + if (output !== undefined && output !== '-') return { rule: 'curl-output', url: urlOf(segment), target: output } + const remoteName = args.some(arg => arg === '-O' || arg === '--remote-name' + || arg === '--remote-name-all' || /^-[A-Za-z]*O[A-Za-z]*$/.test(arg)) + if (remoteName) return { rule: 'curl-remote-name', url: urlOf(segment) } + const redirect = redirectTargetOf(segment) + if (redirect !== undefined && fetchesNetwork(segment)) { + return { rule: 'redirect', url: urlOf(segment), target: redirect } + } + return undefined + } + + // wget writes to a file by design; -O names it, -q only silences progress. + if (base === 'wget') { + const output = flagValue(args, ['-O', '--output-document']) + if (output !== undefined) return { rule: 'wget', url: urlOf(segment), target: output } + // `-O -` means stdout, not a file. It is the documented way to stream + // wget into a pipe (`wget -qO- URL | tar xz`), so it must stay allowed. + if (args.some(arg => arg === '-O-' || arg === '--output-document=-' + || /^-[A-Za-z]*O-$/.test(arg))) return undefined + const redirect = redirectTargetOf(segment) + if (redirect !== undefined) return { rule: 'wget', url: urlOf(segment), target: redirect } + // A bare `wget URL` still lands a file in the working directory. + if (urlOf(segment) !== undefined) return { rule: 'wget', url: urlOf(segment) } + return undefined + } + + // PowerShell downloaders: only the *OutFile / -Destination spellings write a + // named file. `Invoke-RestMethod` without one returns a parsed object, which + // is not a download. + if (base === 'invoke-webrequest' || base === 'iwr') { + const output = flagValue(args, ['-OutFile']) + return output === undefined ? undefined : { rule: 'Invoke-WebRequest', url: urlOf(segment), target: output } + } + if (base === 'invoke-restmethod' || base === 'irm') { + const output = flagValue(args, ['-OutFile']) + return output === undefined ? undefined : { rule: 'Invoke-RestMethod', url: urlOf(segment), target: output } + } + if (base === 'start-bitstransfer') { + const output = flagValue(args, ['-Destination']) + return output === undefined ? undefined : { rule: 'Start-BitsTransfer', url: urlOf(segment), target: output } + } + return undefined +} + +/** + * Inspect a whole command line, sub-command by sub-command. + * + * @param command - the raw `command` argument of a shell tool call. + * @returns the first download hit, or undefined. + */ +export function inspectCommand(command: unknown): DownloadHit | undefined { + if (typeof command !== 'string' || command.trim() === '') return undefined + for (const segment of splitSubcommands(command)) { + const hit = inspectSegment(segment) + if (hit !== undefined) return hit + } + return undefined +} diff --git a/src/download-guard/policy.ts b/src/download-guard/policy.ts new file mode 100644 index 00000000..01fa9e4d --- /dev/null +++ b/src/download-guard/policy.ts @@ -0,0 +1,121 @@ +/** + * Turn a detection result into a decision, using operator configuration. + * + * WHY THE FORWARDER IS CONFIGURATION, NOT CODE + * + * A guard that hardcodes one downloader is only useful on the machine it was + * written for. The plugin therefore never names a downloader: the operator + * supplies a command template, and every part of the refusal text is derived + * from it. With no template configured the guard stays out of the way + * entirely — a default that refuses work the operator never asked to change + * would be worse than the slow download it prevents. + * + * @module dsh-ops/download-guard/policy + */ +import type { DownloadHit } from './detect.js' + +/** + * One configured forwarder. + * + * `template` is the whole command, with `{url}` and `{file}` replaced by the + * detected values. A template is used verbatim otherwise, so it can carry any + * flags the operator's downloader needs. + */ +export interface ForwarderConfig { + /** The command to suggest, containing `{url}` and optionally `{file}`. */ + template: string +} + +/** What the guard decided for one call. */ +export type DownloadDecision = + | { kind: 'allow' } + | { kind: 'deny', reason: string, info: { name: string, code: string } } + +/** Quote one path for a POSIX shell only when it would otherwise split. */ +function shellQuote(value: string): string { + if (value === '') return "''" + if (/^[A-Za-z0-9_\-./:=@%+,]+$/.test(value)) return value + return `'${value.replace(/'/g, `'\\''`)}'` +} + +/** + * Render the forwarder command for one hit. + * + * `{url}` is required because a template without it cannot identify what to + * download. `{file}` is optional: a downloader that derives the name from the + * URL does not need it. When the hit has no visible target but the template + * asks for one, the placeholder is left intact rather than replaced with a + * guess, so the operator can see why it was not filled. + * + * @param template - the configured command template. + * @param hit - the detection result. + * @returns the command to show the model. + */ +export function renderForwarder(template: string, hit: DownloadHit): string { + let rendered = template + if (hit.url !== undefined) rendered = rendered.split('{url}').join(shellQuote(hit.url)) + if (hit.target !== undefined) rendered = rendered.split('{file}').join(shellQuote(hit.target)) + return rendered +} + +/** + * Build the refusal text. + * + * The shape follows the host's own refusals: what was refused, why it matters, + * and the exact command that does it better. A refusal that does not name a + * working alternative just moves the problem to the model. + * + * @param hit - the detection result. + * @param template - the configured forwarder template. + * @param command - the original command, for context. + * @returns the refusal reason. + */ +export function refusalReason(hit: DownloadHit, template: string, command: string): string { + const shown = command.length > 240 ? `${command.slice(0, 240)}...` : command + const lines = [ + `This command downloads to disk through ${hit.rule}, which writes one connection at a time.`, + hit.target === undefined + ? 'The bytes land in a local file either way, so a downloader built for the job is strictly faster.' + : `The bytes go to ${hit.target} either way, so a downloader built for the job is strictly faster.`, + '', + 'Use the configured downloader instead:', + ` ${renderForwarder(template, hit)}`, + ] + if (hit.url !== undefined) lines.push('', `URL: ${hit.url}`) + lines.push( + '', + 'If that downloader is not what you want here, say so instead of retrying this command:', + 'the guard refuses rather than silently rewriting your command.', + '', + `Blocked command: ${shown}`, + ) + return lines.join('\n') +} + +/** + * Decide what to do with one inspected call. + * + * @param hit - the detection result, when the command looks like a download. + * @param config - the operator's forwarder configuration, when configured. + * @param command - the original command text. + * @returns the decision. + */ +export function decideDownload( + hit: DownloadHit | undefined, + config: ForwarderConfig | undefined, + command: string, +): DownloadDecision { + // No detection: nothing to say. + if (hit === undefined) return { kind: 'allow' } + // No configured forwarder: the operator has not opted in, so the command runs + // exactly as it always did. This is the difference between a plugin that + // improves a workflow and one that breaks an unconfigured install. + if (config === undefined || config.template.trim() === '') return { kind: 'allow' } + // A template that cannot name the URL cannot produce a runnable alternative. + if (!config.template.includes('{url}') || hit.url === undefined) return { kind: 'allow' } + return { + kind: 'deny', + reason: refusalReason(hit, config.template, command), + info: { name: 'DownloadGuardError', code: 'DOWNLOAD_TO_FILE' }, + } +} diff --git a/src/index.ts b/src/index.ts index d8b69f98..a1f27a23 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ import { mountRuntimeSettings } from './settings/runtime-host.js' import { companionVersion } from './settings/companion.js' import * as panelModule from './components/panel/index.js' import * as tuiModule from './components/tui/index.js' +import * as downloadGuardModule from './components/download-guard/index.js' export { Config } from './settings/merged.js' export { applyComponent } from './legacy/host.js' export const name = 'dsh-ops' @@ -58,6 +59,7 @@ export async function apply(root: Context, rawConfig: unknown): Promise { const effective = gateConsumer(resolved, config.global, linked) await host.plugin({ name: `dsh-ops/custom-${resolved.preset}`, apply(ctx: Context) { return mountIntegration(ctx, effective) } }) } + await mount(downloadGuardModule, config['download-guard']) await mount(panelModule, config.panel) await mount(tuiModule, config.tui) } finally { endMergedRow() } diff --git a/src/settings/merged.ts b/src/settings/merged.ts index c19653c3..0ba0f412 100644 --- a/src/settings/merged.ts +++ b/src/settings/merged.ts @@ -15,6 +15,7 @@ import { ConfigError } from '../config.js' import { resolveModeConfig, type ModeConfig } from '../presets/config.js' import { MODE_IDS } from '../presets/types.js' import { resolveDiscoveryConfig } from '../discovery/config.js' +import { resolveConfig, type DownloadGuardConfig } from '../config.js' import { resolvePanelConfig } from '../panel/collect.js' import { resolveActivityConfig } from '../tui/activity.js' import { PRESET_SETTINGS } from './preset-schema.js' @@ -29,7 +30,7 @@ type ActivityConfig = ReturnType export const MODES = MODE_IDS export type ModeKey = typeof MODES[number] /** Every section the row accepts. */ -export const FEATURES = [...MODES, 'discovery', 'panel', 'tui', 'other'] as const +export const FEATURES = [...MODES, 'discovery', 'download-guard', 'panel', 'tui', 'other'] as const export type Feature = typeof FEATURES[number] /** Bundle-wide switches; resident publication is deliberately mode-local. */ @@ -57,6 +58,7 @@ export interface MergedConfig { readonly ptc: FeatureState readonly cordis: FeatureState readonly discovery: FeatureState + readonly 'download-guard': FeatureState readonly panel: FeatureState readonly tui: FeatureState readonly other: FeatureState<{ pwsh7Link: boolean }> @@ -64,7 +66,9 @@ export interface MergedConfig { const ENABLED_BY_DEFAULT: Readonly> = Object.freeze({ minimal: true, standard: true, ptc: true, cordis: true, discovery: true, - panel: false, tui: false, other: false, + // The download guard is off until an operator names a downloader: mounting it + // against an empty template would register a listener that can never act. + 'download-guard': false, panel: false, tui: false, other: false, }) function object(name: string, value: unknown): Record { @@ -135,6 +139,7 @@ export function resolveMergedConfig(raw?: unknown): MergedConfig { ptc: modeState('ptc', input.ptc), cordis: modeState('cordis', input.cordis), discovery: discoveryState(input.discovery), + 'download-guard': downloadGuardState(input['download-guard']), panel: panelState(input.panel), tui: tuiState(input.tui), other: otherState(input.other), @@ -155,6 +160,13 @@ function discoveryState(value: unknown): FeatureState { : { enabled, config: resolved as DiscoveryConfig } } +function downloadGuardState(value: unknown): FeatureState { + const { enabled, rest, resolved } = split('download-guard', value) + return resolved === undefined + ? { enabled, config: resolveConfig(rest).downloadGuard, raw: rest } + : { enabled, config: resolved as DownloadGuardConfig } +} + function panelState(value: unknown): FeatureState { const { enabled, rest, resolved } = split('panel', value) return resolved === undefined diff --git a/test/download-guard-detect.test.mjs b/test/download-guard-detect.test.mjs new file mode 100644 index 00000000..e68de035 --- /dev/null +++ b/test/download-guard-detect.test.mjs @@ -0,0 +1,127 @@ +/** + * Detection rules for "this shell call writes a download to disk". + * + * The cases split into two groups and the second one matters more: + * + * - downloads that must be recognised, including the spellings that are easy + * to miss (`-oFILE` with no space, `--output=FILE`, a redirect after a `;`) + * - ordinary commands that must stay ALLOWED, because a refusal here breaks a + * working session. This group is deliberately larger than the first. + * + * Every assertion runs the real detector; there is no mocking, so removing a + * rule or loosening a guard turns this suite red. + */ +import { strict as assert } from 'node:assert' +import { inspectCommand, inspectSegment, splitSubcommands } from '../lib/download-guard/detect.js' + +let checks = 0 + +/** Assert one command line is recognised as a download with the given rule. */ +function fires(command, rule, extra = {}) { + const hit = inspectCommand(command) + assert.notEqual(hit, undefined, `expected a hit for ${JSON.stringify(command)}`) + assert.equal(hit.rule, rule, `rule for ${JSON.stringify(command)}`) + for (const [key, value] of Object.entries(extra)) { + assert.equal(hit[key], value, `${key} for ${JSON.stringify(command)}`) + } + checks += 1 +} + +/** Assert one command line is left alone. */ +function allows(command) { + const hit = inspectCommand(command) + assert.equal(hit, undefined, `expected no hit for ${JSON.stringify(command)}, got ${JSON.stringify(hit)}`) + checks += 1 +} + +// --- recognised downloads --------------------------------------------------- + +fires('curl -o out.iso https://example.test/a.iso', 'curl-output', { + url: 'https://example.test/a.iso', + target: 'out.iso', +}) +fires('curl -oFILE https://example.test/a.iso', 'curl-output', { target: 'FILE' }) +fires('curl --output out.iso https://example.test/a.iso', 'curl-output', { target: 'out.iso' }) +fires('curl --output=out.iso https://example.test/a.iso', 'curl-output', { target: 'out.iso' }) +fires('curl -sSL -o out.iso https://example.test/a.iso', 'curl-output', { target: 'out.iso' }) +// A repeated -o resolves to the last one, which is the file curl writes. +fires('curl -o first.iso -o second.iso https://example.test/a.iso', 'curl-output', { target: 'second.iso' }) +fires('curl -O https://example.test/a.iso', 'curl-remote-name') +fires('curl -sSLO https://example.test/a.iso', 'curl-remote-name') +fires('curl https://example.test/a.iso > out.iso', 'redirect', { target: 'out.iso' }) +fires('curl https://example.test/a.iso >> out.iso', 'redirect', { target: 'out.iso' }) + +fires('wget https://example.test/a.iso', 'wget', { url: 'https://example.test/a.iso' }) +fires('wget -O out.iso https://example.test/a.iso', 'wget', { target: 'out.iso' }) +fires('wget --output-document=out.iso https://example.test/a.iso', 'wget', { target: 'out.iso' }) +fires('wget -q https://example.test/a.iso', 'wget') + +fires('Invoke-WebRequest -Uri https://example.test/a.iso -OutFile out.iso', 'Invoke-WebRequest', { target: 'out.iso' }) +fires('iwr https://example.test/a.iso -OutFile out.iso', 'Invoke-WebRequest', { target: 'out.iso' }) +fires('Invoke-RestMethod https://example.test/a.iso -OutFile out.iso', 'Invoke-RestMethod', { target: 'out.iso' }) +fires('Start-BitsTransfer -Source https://example.test/a.iso -Destination out.iso', 'Start-BitsTransfer', { target: 'out.iso' }) + +// A download after an unrelated command is still found, and a Windows path to +// the executable is recognised by its base name. +fires('cd /tmp && curl -o out.iso https://example.test/a.iso', 'curl-output') +fires('echo start; wget https://example.test/a.iso', 'wget') +fires('C:\\Windows\\System32\\curl.exe -o out.iso https://example.test/a.iso', 'curl-output') +fires('/usr/bin/wget https://example.test/a.iso', 'wget') +// A leading environment assignment is not the command word. +fires('FOO=1 BAR=2 wget https://example.test/a.iso', 'wget') + +// --- ordinary commands that must stay allowed ------------------------------- + +allows('ls -la') +allows('git status') +allows('npm run build') +allows('cat package.json') +allows('grep -rn TODO src/') +allows('node scripts/build.mjs') + +// curl that prints to stdout is not a download: no file receives the bytes. +allows('curl https://example.test/api') +allows('curl -s https://example.test/api') +allows('curl -I https://example.test/api') +allows('curl -X POST -d @body.json https://example.test/api') + +// The historic false positive: the word "download" in an argument proves +// nothing about whether bytes land on disk. +allows('node download.cjs --help') +allows('node download.cjs') +allows('./download.sh --dry-run') +allows('echo download') +allows('grep -rn "wget" docs/') + +// Redirecting *from* is a read, not a write. +allows('sort < input.txt') +allows('cat < input.txt > /dev/null') +allows('command > /dev/null') +allows('node tool.js 2>&1 | tee log.txt') + +// PowerShell cmdlets that return objects rather than writing files. +allows('Invoke-RestMethod https://example.test/api') +allows('Invoke-WebRequest https://example.test/api') +allows('iwr https://example.test/api') + +// A pipe into something that is not a downloader. +allows('curl https://example.test/api | jq .') +allows('wget -qO- https://example.test/api | head -5') + +// --- argument shapes -------------------------------------------------------- + +assert.deepEqual(splitSubcommands('a; b && c || d | e'), ['a', 'b', 'c', 'd', 'e']) +assert.deepEqual(splitSubcommands('echo "a;b"'), ['echo "a;b"']) +assert.deepEqual(splitSubcommands("echo 'x && y'"), ["echo 'x && y'"]) +assert.deepEqual(splitSubcommands(' \n '), []) +checks += 4 + +// The detector must not crash on the shapes a tool call can actually carry. +assert.equal(inspectCommand(''), undefined) +assert.equal(inspectCommand(undefined), undefined) +assert.equal(inspectCommand(null), undefined) +assert.equal(inspectCommand(42), undefined) +assert.equal(inspectSegment(''), undefined) +checks += 5 + +console.log(`PASS download-guard detection: ${checks} checks`) diff --git a/test/download-guard-mount.test.mjs b/test/download-guard-mount.test.mjs new file mode 100644 index 00000000..4f60b4e6 --- /dev/null +++ b/test/download-guard-mount.test.mjs @@ -0,0 +1,79 @@ +/** + * The download-guard row as the composition root actually mounts it. + * + * The sibling `download-guard-row` suite drives the component directly; this one + * goes through `apply()` with a real cordis context, so it fails if the row is + * dropped from the composition root, if the `download-guard` config section + * stops resolving, or if the enabled/disabled switch stops being honoured. + * + * Cost note: cordis only. No worker, no DSH host, no subprocess. + */ +import { Context } from '@deepseek-ai/cordis' +import { assert, test, report } from './lib/harness.mjs' +import { apply } from '../lib/index.js' + +const TEMPLATE = 'aria2c -x16 -s16 -o {file} {url}' +const CMD = 'curl -o out.iso https://example.test/a.iso' + +/** + * Boot the composition root with one row config and return a dispatcher for + * the pre-execute waterfall. + */ +async function boot(rowConfig) { + const ctx = new Context() + await apply(ctx, rowConfig) + return async (name, args) => ctx.waterfall( + 'tools/pre-execute', + { name, arguments: args }, + () => Promise.resolve({ kind: 'allow' }), + ) +} + +await test('the shipped defaults mount no refusing behaviour', async () => { + // `download-guard.enabled: false` is what the bundle patch ships; a default + // install must never refuse a download it was not told to police. + const call = await boot({}) + const decision = await call('ops_bash', { command: CMD }) + assert.equal(decision.kind, 'allow') +}) + +await test('an enabled row with a template refuses, through the whole root', async () => { + const call = await boot({ + 'download-guard': { enabled: true, downloadGuard: { template: TEMPLATE } }, + }) + const decision = await call('ops_bash', { command: CMD }) + assert.equal(decision.kind, 'deny') + assert.ok(decision.reason.includes('aria2c -x16 -s16 -o out.iso')) +}) + +await test('enabled but with an empty template stays inert', async () => { + // Enabling the row is not the same as configuring a downloader. The shipped + // patch spells "not set" as an empty string, and that must not refuse. + const call = await boot({ + 'download-guard': { enabled: true, downloadGuard: { template: '' } }, + }) + const decision = await call('ops_bash', { command: CMD }) + assert.equal(decision.kind, 'allow') +}) + +await test('a disabled row ignores a template that is present', async () => { + // An operator who filled the template and then switched the row off expects + // it off; the config value must not keep the listener alive. + const call = await boot({ + 'download-guard': { enabled: false, downloadGuard: { template: TEMPLATE } }, + }) + const decision = await call('ops_bash', { command: CMD }) + assert.equal(decision.kind, 'allow') +}) + +await test('ordinary commands pass through the enabled row', async () => { + const call = await boot({ + 'download-guard': { enabled: true, downloadGuard: { template: TEMPLATE } }, + }) + for (const command of ['ls -la', 'git status', 'curl https://example.test/api']) { + const decision = await call('ops_bash', { command }) + assert.equal(decision.kind, 'allow', `must allow ${JSON.stringify(command)}`) + } +}) + +await report('download-guard row via the composition root') diff --git a/test/download-guard-policy.test.mjs b/test/download-guard-policy.test.mjs new file mode 100644 index 00000000..248607d2 --- /dev/null +++ b/test/download-guard-policy.test.mjs @@ -0,0 +1,100 @@ +/** + * The decision layer: detection plus operator configuration becomes a refusal + * or an allow. + * + * The assertions that matter most are the ALLOW ones. A guard that refuses + * work the operator never asked to change is worse than the slow download it + * exists to prevent, so an unconfigured install, a template missing the URL + * placeholder, and a template that cannot fill the file name all have to + * resolve to "run the command". + */ +import { strict as assert } from 'node:assert' +import { decideDownload, refusalReason, renderForwarder } from '../lib/download-guard/policy.js' +import { inspectCommand } from '../lib/download-guard/detect.js' + +let checks = 0 + +const CMD = 'curl -o out.iso https://example.test/a.iso' +const HIT = inspectCommand(CMD) +const TEMPLATE = 'aria2c -x16 -s16 -o {file} {url}' + +assert.notEqual(HIT, undefined, 'precondition: the sample command is detected') + +/** Assert a decision kind. */ +function decides(hit, config, expected) { + const decision = decideDownload(hit, config, CMD) + assert.equal(decision.kind, expected, `expected ${expected}, got ${decision.kind}`) + checks += 1 +} + +// --- refuses when properly configured -------------------------------------- + +decides(HIT, { template: TEMPLATE }, 'deny') + +{ + const decision = decideDownload(HIT, { template: TEMPLATE }, CMD) + assert.equal(decision.info.code, 'DOWNLOAD_TO_FILE') + assert.equal(decision.info.name, 'DownloadGuardError') + // The refusal must carry a runnable command, not just a complaint. + assert.ok(decision.reason.includes('aria2c -x16 -s16 -o out.iso https://example.test/a.iso')) + // It must also name what was blocked, so the model can tell which call failed. + assert.ok(decision.reason.includes('out.iso')) + checks += 3 +} + +// --- allows in every ambiguous case ---------------------------------------- + +// No detection, no opinion. +decides(undefined, { template: TEMPLATE }, 'allow') +// No configuration: the operator has not opted in. +decides(HIT, undefined, 'allow') +// Blank template is the same as no configuration. +decides(HIT, { template: ' ' }, 'allow') +// A template without {url} cannot produce a runnable download command. +decides(HIT, { template: 'aria2c {file}' }, 'allow') +// No URL visible in the original command means the template cannot be filled. +decides({ rule: 'wget' }, { template: TEMPLATE }, 'allow') + +// --- rendering ------------------------------------------------------------- + +{ + const rendered = renderForwarder(TEMPLATE, HIT) + assert.equal(rendered, 'aria2c -x16 -s16 -o out.iso https://example.test/a.iso') + checks += 1 +} +{ + // No target: the file placeholder stays visible rather than being guessed. + const rendered = renderForwarder(TEMPLATE, { rule: 'curl-remote-name', url: 'https://example.test/a.iso' }) + assert.equal(rendered, 'aria2c -x16 -s16 -o {file} https://example.test/a.iso') + checks += 1 +} +{ + // Paths with spaces are quoted so the suggested command actually runs. + const rendered = renderForwarder('aria2c -o {file} {url}', { + rule: 'curl-output', + url: 'https://example.test/a.iso', + target: 'C:/Program Files/out.iso', + }) + assert.ok(rendered.includes("'C:/Program Files/out.iso'"), rendered) + checks += 1 +} +{ + // A URL containing a single quote must not break out of the quoting. + const rendered = renderForwarder('dl {url}', { rule: 'wget', url: "https://example.test/a'b" }) + assert.ok(!rendered.includes("a'b"), rendered) + checks += 1 +} + +// --- the refusal text itself ---------------------------------------------- + +{ + const longCommand = `curl -o out.iso https://example.test/${'x'.repeat(400)}` + const reason = refusalReason(HIT, TEMPLATE, longCommand) + // Long commands are truncated so the refusal stays readable. + assert.ok(reason.includes('...')) + // The rule name appears, so the model knows what pattern fired. + assert.ok(reason.includes('curl-output')) + checks += 2 +} + +console.log(`PASS download-guard policy: ${checks} checks`) diff --git a/test/download-guard-row.test.mjs b/test/download-guard-row.test.mjs new file mode 100644 index 00000000..f6a31120 --- /dev/null +++ b/test/download-guard-row.test.mjs @@ -0,0 +1,97 @@ +/** + * The download-guard row, driven through a real cordis context. + * + * This suite mounts the built component and fires the actual + * `tools/pre-execute` waterfall, so it fails if the listener is not registered, + * if the decision shape drifts from what the host expects, or if the row + * starts refusing before an operator configures a downloader. + * + * Cost note: it boots cordis only. No worker, no DSH host, no subprocess. + */ +import { Context } from '@deepseek-ai/cordis' +import { assert, test, report } from './lib/harness.mjs' +import { apply } from '../lib/components/download-guard/index.js' + +const TEMPLATE = 'aria2c -x16 -s16 -o {file} {url}' +const CMD = 'curl -o out.iso https://example.test/a.iso' + +/** + * Mount the row on a fresh context and return a function that dispatches one + * pre-execute call through the real waterfall. + */ +async function mount(config) { + const ctx = new Context() + apply(ctx, config) + return async (name, args) => ctx.waterfall( + 'tools/pre-execute', + { name, arguments: args }, + () => Promise.resolve({ kind: 'allow' }), + ) +} + +await test('an unconfigured row registers no refusing behaviour', async () => { + const call = await mount({}) + // Every download shape must pass straight through: an install that never set + // a template has not opted into a download policy. + for (const command of [CMD, 'wget https://example.test/a.iso', 'curl -o x https://example.test/x']) { + const decision = await call('ops_bash', { command }) + assert.equal(decision.kind, 'allow', `unconfigured row must allow ${JSON.stringify(command)}`) + } +}) + +await test('a configured row refuses a download and names the forwarder', async () => { + const call = await mount({ downloadGuard: { template: TEMPLATE } }) + const decision = await call('ops_bash', { command: CMD }) + assert.equal(decision.kind, 'deny') + assert.ok(decision.reason.includes('aria2c -x16 -s16 -o out.iso https://example.test/a.iso'), + 'the refusal must carry a runnable command') + assert.equal(decision.info.code, 'DOWNLOAD_TO_FILE') +}) + +await test('an ordinary command is allowed even when configured', async () => { + const call = await mount({ downloadGuard: { template: TEMPLATE } }) + for (const command of [ + 'ls -la', + 'git status', + 'npm run build', + 'curl https://example.test/api', + 'node download.cjs --help', + 'grep -rn wget docs/', + ]) { + const decision = await call('ops_bash', { command }) + assert.equal(decision.kind, 'allow', `must allow ${JSON.stringify(command)}`) + } +}) + +await test('a non-shell tool is never inspected', async () => { + const call = await mount({ downloadGuard: { template: TEMPLATE } }) + const decision = await call('ops_read_files', { command: CMD }) + assert.equal(decision.kind, 'allow', 'file tools carry no shell command') +}) + +await test('script_path is not guessed at', async () => { + const call = await mount({ downloadGuard: { template: TEMPLATE } }) + // The file's contents are not in this call; treating the path as a command + // would be a false positive. + const decision = await call('ops_bash', { script_path: 'scripts/fetch.sh' }) + assert.equal(decision.kind, 'allow') +}) + +await test('a malformed call cannot turn a refusal into a crash', async () => { + const call = await mount({ downloadGuard: { template: TEMPLATE } }) + for (const args of [undefined, null, {}, { command: 42 }, { command: '' }]) { + const decision = await call('ops_bash', args) + assert.equal(decision.kind, 'allow', `must not refuse ${JSON.stringify(args)}`) + } +}) + +await test('a template without {url} is rejected at load time', async () => { + const ctx = new Context() + assert.throws( + () => apply(ctx, { downloadGuard: { template: 'aria2c {file}' } }), + /must contain \{url\}/, + 'a template that cannot name the URL would produce an unactionable refusal', + ) +}) + +await report('download-guard row')