Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
6 changes: 6 additions & 0 deletions cordis.patch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions docs/configuration-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions docs/配置参考.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)。
Expand Down
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
10 changes: 6 additions & 4 deletions src/components/defaults.ts
Original file line number Diff line number Diff line change
@@ -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}` }
Binary file added src/components/download-guard/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
65 changes: 65 additions & 0 deletions src/components/download-guard/index.ts
Original file line number Diff line number Diff line change
@@ -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()
}
})
}
6 changes: 6 additions & 0 deletions src/components/download-guard/locale/en.json
Original file line number Diff line number Diff line change
@@ -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."
}
}
6 changes: 6 additions & 0 deletions src/components/download-guard/locale/zh.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"meta": {
"title": "dsh-ops-download-guard",
"description": "拦截会把文件写到磁盘的 shell 下载,并给出运维配置的更快下载器。未配置 downloadGuard.template 时完全不生效。"
}
}
6 changes: 6 additions & 0 deletions src/components/download-guard/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
49 changes: 49 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -99,6 +100,40 @@ function optionalStringList(raw: Record<string, unknown>, 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<string, unknown>
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
Expand All @@ -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
}

/**
Expand Down Expand Up @@ -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),
}
}

Expand Down
Loading