dsh-perm-gate 5.0.4 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.ja.md +0 -2
- package/CHANGELOG.ko.md +0 -2
- package/CHANGELOG.md +4 -2
- package/INSTALL.ja.md +0 -15
- package/INSTALL.ko.md +0 -15
- package/INSTALL.md +0 -15
- package/INSTALL.zh.md +0 -15
- package/README.ja.md +6 -18
- package/README.ko.md +6 -18
- package/README.md +40 -19
- package/README.zh.md +34 -18
- package/assets/appcontainer-launcher.cs +703 -0
- package/dsh.plugin.json +2 -2
- package/lib/appcontainer/compile.d.ts +17 -0
- package/lib/appcontainer/compile.js +74 -0
- package/lib/appcontainer/exempt.d.ts +18 -0
- package/lib/appcontainer/exempt.js +71 -0
- package/lib/appcontainer/index.d.ts +85 -0
- package/lib/appcontainer/index.js +286 -0
- package/lib/appcontainer/probe.d.ts +44 -0
- package/lib/appcontainer/probe.js +66 -0
- package/lib/appcontainer/sid.d.ts +23 -0
- package/lib/appcontainer/sid.js +33 -0
- package/lib/appcontainer/wrap.d.ts +22 -0
- package/lib/appcontainer/wrap.js +40 -0
- package/lib/cli.js +0 -0
- package/lib/client.js +1425 -1108
- package/lib/client.js.map +1 -1
- package/lib/config.d.ts +41 -1
- package/lib/config.js +6 -0
- package/lib/index.d.ts +1 -1
- package/lib/index.js +53 -0
- package/lib/runtime.d.ts +22 -0
- package/lib/runtime.js +24 -0
- package/locale/de.json +1 -0
- package/locale/en.json +1 -0
- package/locale/es.json +1 -0
- package/locale/fr.json +1 -0
- package/locale/it.json +1 -0
- package/locale/ja.json +1 -0
- package/locale/ko.json +1 -0
- package/locale/ru.json +1 -0
- package/locale/zh.json +1 -0
- package/package.json +28 -28
- package/scripts/patch-permission-glyph.mjs +0 -0
- package/README.de.md +0 -487
- package/README.es.md +0 -489
- package/README.fr.md +0 -498
- package/README.it.md +0 -490
- package/README.ru.md +0 -478
package/dsh.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "dsh-perm-gate",
|
|
3
3
|
"name": "dsh-perm-gate",
|
|
4
|
-
"version": "5.0
|
|
4
|
+
"version": "5.1.0",
|
|
5
5
|
"description": {
|
|
6
6
|
"en": "Deterministic-first, fail-closed permission gate for DeepSeek Harness 0.1.2+: a P0 hard-deny -> P1 precise session grant -> P2 static allow/deny rule chain -> P3 optional LLM classifier -> P4 ask pipeline over the tools/pre-execute waterfall, with a standalone CLI dry-run evaluator and an auditable decision log. It only vetoes with deny/ask and always delegates otherwise, and unknown or malformed rules fail loud instead of silently standing the gate down.",
|
|
7
7
|
"zh": "面向 DeepSeek Harness 0.1.2+ 的确定性优先、失败即拒的权限闸门:在 tools/pre-execute 瀑布上串起 P0 硬拒绝 → P1 精确会话授权 → P2 静态 allow/deny 规则链 → P3 可选 LLM 语义分类 → P4 ask,并附带独立 CLI 干跑评估器与可审计的决策日志。只以 deny/ask 否决、其余一律放行,规则未知或非法时直接报错,绝不静默失效。"
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
},
|
|
14
14
|
"license": "MIT",
|
|
15
15
|
"engines": {
|
|
16
|
-
"dsh": "
|
|
16
|
+
"dsh": "0.1.0-rc.2 || 0.1.0-rc.3 || 0.1.0-rc.6 || 0.1.0-rc.7 || 0.1.0-rc.8 || 0.1.1-rc.1 || 0.1.1-rc.2 || 0.1.2-rc.1 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.5-rc.3 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2"
|
|
17
17
|
},
|
|
18
18
|
"components": {
|
|
19
19
|
"host": "lib/index.js",
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** The shipped C# launcher source (read-only asset). */
|
|
2
|
+
export declare const LAUNCHER_SOURCE: string;
|
|
3
|
+
/** Compile-cache root (also swept by the controller's housekeeping). */
|
|
4
|
+
export declare const CACHE_DIR: string;
|
|
5
|
+
/** Locate the .NET Framework C# compiler, or undefined when absent. */
|
|
6
|
+
export declare function locateCsc(): string | undefined;
|
|
7
|
+
export interface CompiledLauncher {
|
|
8
|
+
readonly exePath: string;
|
|
9
|
+
readonly hash: string;
|
|
10
|
+
readonly cached: boolean;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Compile (or reuse) the launcher for the current source hash. Returns the exe
|
|
14
|
+
* path; undefined means the platform cannot provide one (no csc) — callers
|
|
15
|
+
* must treat sandbox mode as unavailable (fail-closed), never run unwrapped.
|
|
16
|
+
*/
|
|
17
|
+
export declare function ensureLauncher(): Promise<CompiledLauncher | undefined>;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* compile.ts —— launcher.exe 的按需编译(csc.exe,.NET Framework 自带)。
|
|
3
|
+
*
|
|
4
|
+
* 产物按 launcher.cs 的 sha256 缓存在 `os.tmpdir()/dsh-perm-gate-ac/`——
|
|
5
|
+
* 源码不变则编译一次终身复用;换版本自动重编。C# 源码随包发布
|
|
6
|
+
* (`assets/appcontainer-launcher.cs`),插件本体零原生依赖。
|
|
7
|
+
*
|
|
8
|
+
* csc 定位:`%WINDIR%\Microsoft.NET\Framework64\v4.0.30319\csc.exe`(Win10/
|
|
9
|
+
* 11/24H2 全系自带 .NET Framework 4.x),缺失时回退 Framework(32 位)。
|
|
10
|
+
* 找不到 csc → 返回 undefined,上层按 fail-closed 处理(沙盒开启时该调用
|
|
11
|
+
* 硬拒绝,绝不回退到容器外执行)。
|
|
12
|
+
*/
|
|
13
|
+
import { execFile } from 'node:child_process';
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
import { existsSync } from 'node:fs';
|
|
16
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
/** The shipped C# launcher source (read-only asset). */
|
|
21
|
+
export const LAUNCHER_SOURCE = fileURLToPath(new URL('../../assets/appcontainer-launcher.cs', import.meta.url));
|
|
22
|
+
/** Compile-cache root (also swept by the controller's housekeeping). */
|
|
23
|
+
export const CACHE_DIR = path.join(os.tmpdir(), 'dsh-perm-gate-ac');
|
|
24
|
+
function cscCandidates() {
|
|
25
|
+
const windir = process.env['WINDIR'] ?? 'C:\\Windows';
|
|
26
|
+
return [
|
|
27
|
+
path.join(windir, 'Microsoft.NET', 'Framework64', 'v4.0.30319', 'csc.exe'),
|
|
28
|
+
path.join(windir, 'Microsoft.NET', 'Framework', 'v4.0.30319', 'csc.exe'),
|
|
29
|
+
];
|
|
30
|
+
}
|
|
31
|
+
/** Locate the .NET Framework C# compiler, or undefined when absent. */
|
|
32
|
+
export function locateCsc() {
|
|
33
|
+
for (const candidate of cscCandidates()) {
|
|
34
|
+
if (existsSync(candidate))
|
|
35
|
+
return candidate;
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
async function run(cwd, exe, args) {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
execFile(exe, args, { cwd, windowsHide: true, timeout: 120_000 }, (error, _stdout, stderr) => {
|
|
42
|
+
if (error !== undefined && error !== null) {
|
|
43
|
+
reject(new Error(`csc failed: ${String(stderr ?? error.message).slice(-400)}`));
|
|
44
|
+
}
|
|
45
|
+
else
|
|
46
|
+
resolve();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Compile (or reuse) the launcher for the current source hash. Returns the exe
|
|
52
|
+
* path; undefined means the platform cannot provide one (no csc) — callers
|
|
53
|
+
* must treat sandbox mode as unavailable (fail-closed), never run unwrapped.
|
|
54
|
+
*/
|
|
55
|
+
export async function ensureLauncher() {
|
|
56
|
+
const csc = locateCsc();
|
|
57
|
+
if (csc === undefined)
|
|
58
|
+
return undefined;
|
|
59
|
+
const source = await readFile(LAUNCHER_SOURCE, 'utf8');
|
|
60
|
+
const hash = createHash('sha256').update(source, 'utf8').digest('hex').slice(0, 16);
|
|
61
|
+
const exePath = path.join(CACHE_DIR, `appcontainer-launcher-${hash}.exe`);
|
|
62
|
+
const marker = `${exePath}.ok`;
|
|
63
|
+
if (existsSync(exePath) && existsSync(marker))
|
|
64
|
+
return { exePath, hash, cached: true };
|
|
65
|
+
await mkdir(CACHE_DIR, { recursive: true });
|
|
66
|
+
await run(CACHE_DIR, csc, [
|
|
67
|
+
'/nologo', '/target:exe', '/optimize+',
|
|
68
|
+
'/r:System.Web.Extensions.dll',
|
|
69
|
+
`/out:${exePath}`,
|
|
70
|
+
LAUNCHER_SOURCE,
|
|
71
|
+
]);
|
|
72
|
+
await writeFile(marker, String(process.pid), 'utf8');
|
|
73
|
+
return { exePath, hash, cached: false };
|
|
74
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type LoopbackExemptResult = {
|
|
2
|
+
readonly status: 'exempt' | 'already-exempt';
|
|
3
|
+
} | {
|
|
4
|
+
readonly status: 'failed';
|
|
5
|
+
readonly message: string;
|
|
6
|
+
} | {
|
|
7
|
+
readonly status: 'unsupported';
|
|
8
|
+
readonly message: string;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Ensure the container SID is on the machine's loopback-exempt list.
|
|
12
|
+
*
|
|
13
|
+
* `elevate` (default false): when true and the non-elevated add is denied, one
|
|
14
|
+
* UAC elevation prompt is offered via PowerShell Start-Process -Verb RunAs.
|
|
15
|
+
*/
|
|
16
|
+
export declare function ensureLoopbackExempt(sid: string, options?: {
|
|
17
|
+
elevate?: boolean;
|
|
18
|
+
}): Promise<LoopbackExemptResult>;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* exempt.ts —— AppContainer 回环豁免(CheckNetIsolation LoopbackExempt)。
|
|
3
|
+
*
|
|
4
|
+
* 背景:AppContainer 进程默认连 127.0.0.1 都不可达(WFP 回环隔离)。豁免以
|
|
5
|
+
* 容器 SID 为键,与本插件的**稳定派生 SID**(sid.ts)配合——全机只需做一次,
|
|
6
|
+
* 之后每个会话直接复用。豁免写入 HKLM 防火墙策略区,非提权进程可能被拒;
|
|
7
|
+
* 被拒不致命(沙盒内进程只是够不到 127.0.0.1 的代理/宿主服务),但要把
|
|
8
|
+
* 手工提权命令原样告诉用户。
|
|
9
|
+
*
|
|
10
|
+
* 幂等:`-s` 子命令列出已豁免 SID,命中即跳过;避免每次会话都打一次
|
|
11
|
+
* CheckNetIsolation(它也不便宜)。
|
|
12
|
+
*/
|
|
13
|
+
import { execFile } from 'node:child_process';
|
|
14
|
+
function checkNetIsolationPath() {
|
|
15
|
+
const windir = process.env['WINDIR'] ?? 'C:\\Windows';
|
|
16
|
+
return `${windir}\\System32\\CheckNetIsolation.exe`;
|
|
17
|
+
}
|
|
18
|
+
function run(exe, args, timeoutMs = 30_000) {
|
|
19
|
+
return new Promise((resolve) => {
|
|
20
|
+
execFile(exe, args, { windowsHide: true, timeout: timeoutMs }, (error, stdout, stderr) => {
|
|
21
|
+
const code = error === undefined || error === null ? 0 : error.code ?? 1;
|
|
22
|
+
resolve({ code, stdout: String(stdout ?? ''), stderr: String(stderr ?? '') });
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Ensure the container SID is on the machine's loopback-exempt list.
|
|
28
|
+
*
|
|
29
|
+
* `elevate` (default false): when true and the non-elevated add is denied, one
|
|
30
|
+
* UAC elevation prompt is offered via PowerShell Start-Process -Verb RunAs.
|
|
31
|
+
*/
|
|
32
|
+
export async function ensureLoopbackExempt(sid, options = {}) {
|
|
33
|
+
if (process.platform !== 'win32') {
|
|
34
|
+
return { status: 'unsupported', message: 'loopback exemption is Windows-only (AppContainer)' };
|
|
35
|
+
}
|
|
36
|
+
const exe = checkNetIsolationPath();
|
|
37
|
+
const list = await run(exe, ['LoopbackExempt', '-s']);
|
|
38
|
+
if (list.code !== 0 && list.stdout === '' && list.stderr !== '') {
|
|
39
|
+
return { status: 'failed', message: `CheckNetIsolation -s failed: ${list.stderr.trim().slice(0, 200)}` };
|
|
40
|
+
}
|
|
41
|
+
if (list.stdout.includes(sid))
|
|
42
|
+
return { status: 'already-exempt' };
|
|
43
|
+
const add = await run(exe, ['LoopbackExempt', '-a', `-p=${sid}`]);
|
|
44
|
+
if (add.code === 0)
|
|
45
|
+
return { status: 'exempt' };
|
|
46
|
+
const denied = /denied|administrator|elevation/i.test(`${add.stderr}${add.stdout}`);
|
|
47
|
+
if (denied && options.elevate === true) {
|
|
48
|
+
const elevated = await elevate(exe, sid);
|
|
49
|
+
if (elevated !== undefined)
|
|
50
|
+
return elevated;
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
status: 'failed',
|
|
54
|
+
message: `loopback exemption needs elevation (win32 exit ${add.code}). Run once from an elevated prompt:\n`
|
|
55
|
+
+ ` ${exe} LoopbackExempt -a -p=${sid}\n`
|
|
56
|
+
+ `The SID is stable per machine, so this is a one-time step.`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
async function elevate(exe, sid) {
|
|
60
|
+
const command = `${exe} LoopbackExempt -a -p=${sid}`;
|
|
61
|
+
return new Promise((resolve) => {
|
|
62
|
+
execFile('powershell.exe', ['-NoProfile', '-Command', `Start-Process -Verb RunAs -Wait -WindowStyle Hidden '${command}'`], { windowsHide: true, timeout: 120_000 }, (error) => {
|
|
63
|
+
if (error === undefined || error === null) {
|
|
64
|
+
resolve({ status: 'exempt' });
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
resolve(undefined); // caller falls back to the manual-guidance message
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** Sandbox-relevant settings, resolved from the volatile config each read. */
|
|
2
|
+
export interface SandboxSettings {
|
|
3
|
+
readonly enabled: boolean;
|
|
4
|
+
readonly mode: 'read-only' | 'workspace-write';
|
|
5
|
+
/** '' = follow the builtin filtered proxy; otherwise an explicit loopback URL. */
|
|
6
|
+
readonly proxy: string;
|
|
7
|
+
readonly loopback: 'exempt' | 'off';
|
|
8
|
+
}
|
|
9
|
+
/** What the gate should do with an allowed shell call. */
|
|
10
|
+
export type SandboxRewriteResult = {
|
|
11
|
+
readonly kind: 'wrap';
|
|
12
|
+
readonly command: string;
|
|
13
|
+
} | {
|
|
14
|
+
readonly kind: 'deny';
|
|
15
|
+
readonly reason: string;
|
|
16
|
+
};
|
|
17
|
+
/** The call face the controller needs (subset of ToolExecutionLike). */
|
|
18
|
+
export interface SandboxCall {
|
|
19
|
+
readonly name: string;
|
|
20
|
+
readonly arguments: Record<string, unknown>;
|
|
21
|
+
readonly cwd?: string;
|
|
22
|
+
}
|
|
23
|
+
export interface SandboxControllerDeps {
|
|
24
|
+
/** Default workspace when the call carries no cwd (the host process cwd). */
|
|
25
|
+
readonly cwdRoot: () => string;
|
|
26
|
+
/** The builtin filtered proxy's actually-bound port, when it is up. */
|
|
27
|
+
readonly builtinProxyPort: () => number | undefined;
|
|
28
|
+
readonly log: (message: string) => void;
|
|
29
|
+
readonly platform?: () => NodeJS.Platform;
|
|
30
|
+
readonly now?: () => number;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Workspace roots that must never receive container ACEs: granting the whole
|
|
34
|
+
* user profile / a drive root / the OS directory turns "workspace-write" into
|
|
35
|
+
* a machine-wide write grant (R3). Returns the denial reason or undefined.
|
|
36
|
+
*/
|
|
37
|
+
export declare function isForbiddenWorkspace(workspace: string, home?: string, winDir?: string): string | undefined;
|
|
38
|
+
export declare class SandboxController {
|
|
39
|
+
private readonly deps;
|
|
40
|
+
private launcherPromise;
|
|
41
|
+
private probe;
|
|
42
|
+
private tempRoot;
|
|
43
|
+
private configRoot;
|
|
44
|
+
private exemptSettled;
|
|
45
|
+
private exemptLastAttempt;
|
|
46
|
+
private sid;
|
|
47
|
+
private swept;
|
|
48
|
+
constructor(deps: SandboxControllerDeps);
|
|
49
|
+
private get platform();
|
|
50
|
+
private get now();
|
|
51
|
+
/** Comparison key for change detection (settings-edit → re-ensure). */
|
|
52
|
+
static keyOf(settings: SandboxSettings, sid: string): string;
|
|
53
|
+
/**
|
|
54
|
+
* Compile the launcher once per source hash, ask it for the OS-derived
|
|
55
|
+
* container SID, and run the one-shot `--probe` verdict. Failures are NOT
|
|
56
|
+
* cached (a missing csc may appear after a .NET install — retry next call);
|
|
57
|
+
* the loopback exemption settles only on success (a failure retries with
|
|
58
|
+
* backoff — a transient denial must not disable loopback for the process
|
|
59
|
+
* lifetime).
|
|
60
|
+
*/
|
|
61
|
+
ensure(settings: SandboxSettings): Promise<{
|
|
62
|
+
exePath: string;
|
|
63
|
+
hash: string;
|
|
64
|
+
} | undefined>;
|
|
65
|
+
/** The proxy URL injected into sandboxed processes, if any. */
|
|
66
|
+
resolveProxyUrl(settings: SandboxSettings): string | undefined;
|
|
67
|
+
/**
|
|
68
|
+
* Gate-aware rewrite of one allowed shell call: undefined (not applicable —
|
|
69
|
+
* disabled / not a shell tool / non-Windows), wrap (the rewritten command),
|
|
70
|
+
* or deny (fail-closed when the sandbox is on but cannot be honored).
|
|
71
|
+
*/
|
|
72
|
+
wrap(settings: SandboxSettings, exec: SandboxCall): Promise<SandboxRewriteResult | undefined>;
|
|
73
|
+
/** One standing scratch dir per plugin process, removed on dispose. */
|
|
74
|
+
private ensureTempDir;
|
|
75
|
+
/** Private config dir — NOT container-writable (R5). */
|
|
76
|
+
private ensureConfigDir;
|
|
77
|
+
/** Best-effort scratch cleanup at plugin dispose. */
|
|
78
|
+
dispose(): Promise<void>;
|
|
79
|
+
/**
|
|
80
|
+
* One-shot housekeeping: orphan scratch dirs (crashed processes leak them —
|
|
81
|
+
* 6 found on this machine) older than 24h, and stale compile-cache exes
|
|
82
|
+
* older than 7 days. Best effort, once per process.
|
|
83
|
+
*/
|
|
84
|
+
private sweep;
|
|
85
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* appcontainer/index.ts —— 沙盒控制器:把门控放行的 shell 调用重写进
|
|
3
|
+
* Windows AppContainer(目标 2 的编排层)。
|
|
4
|
+
*
|
|
5
|
+
* 出网模型(设计定案,见 README「AppContainer 沙盒」一节):
|
|
6
|
+
* - 属性列表启动 **零能力** → 非回环出网被 WFP 物理封死(不是环境变量那种
|
|
7
|
+
* 可绕过的软约束);
|
|
8
|
+
* - 容器 SID 由 OS 派生(launcher `--print-sid`),回环豁免做一次终身有效
|
|
9
|
+
* (exempt.ts,失败带退避重试——只缓存成功);
|
|
10
|
+
* - 代理来自配置参数 `sandboxProxy`:空 = 跟随本插件的内置过滤代理
|
|
11
|
+
* (NetworkLifecycle 实际绑定的 127.0.0.1:<port>);显式 URL 逐字注入
|
|
12
|
+
* (必须是回环地址——容器只可达回环)。注入时**同时置空 NO_PROXY**,
|
|
13
|
+
* 否则继承的 NO_PROXY 会让容器绕开过滤代理直连回环(R4)。
|
|
14
|
+
*
|
|
15
|
+
* fail-closed:沙盒开启但启动器不可用 / 命令文本缺失 / 工作区越禁 / POSIX
|
|
16
|
+
* 方言工具 → 返回 deny 而不是放行裸执行——这是权限闸门,不是便利贴。
|
|
17
|
+
*/
|
|
18
|
+
import { execFile } from 'node:child_process';
|
|
19
|
+
import { readdir, mkdtemp, rm, stat } from 'node:fs/promises';
|
|
20
|
+
import { homedir, tmpdir } from 'node:os';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { SHELL_TOOLS } from '../evaluate.js';
|
|
23
|
+
import { NO_PROXY_ENV_NAMES, PROXY_ENV_NAMES } from '../proxy.js';
|
|
24
|
+
import { ensureLauncher, locateCsc } from './compile.js';
|
|
25
|
+
import { ensureLoopbackExempt } from './exempt.js';
|
|
26
|
+
import { runProbe } from './probe.js';
|
|
27
|
+
import { buildWrappedCommand, writeLaunchConfig } from './wrap.js';
|
|
28
|
+
/** Tools whose command text is POSIX shell dialect — cmd.exe cannot honor it. */
|
|
29
|
+
const POSIX_DIALECT_TOOLS = new Set(['bash', 'sh']);
|
|
30
|
+
/** Retry cadence for a FAILED loopback exemption (successes settle permanently). */
|
|
31
|
+
const EXEMPT_RETRY_MS = 60_000;
|
|
32
|
+
/**
|
|
33
|
+
* Workspace roots that must never receive container ACEs: granting the whole
|
|
34
|
+
* user profile / a drive root / the OS directory turns "workspace-write" into
|
|
35
|
+
* a machine-wide write grant (R3). Returns the denial reason or undefined.
|
|
36
|
+
*/
|
|
37
|
+
export function isForbiddenWorkspace(workspace, home = homedir(), winDir = process.env['WINDIR'] ?? 'C:\\Windows') {
|
|
38
|
+
const norm = (p) => path.resolve(p).replace(/[\\/]+$/, '').toLowerCase();
|
|
39
|
+
const target = norm(workspace);
|
|
40
|
+
if (path.parse(target).root === target && target.endsWith(':'))
|
|
41
|
+
return 'workspace is a drive root';
|
|
42
|
+
if (target === norm(home))
|
|
43
|
+
return 'workspace is the user home directory (would grant a whole-home ACE)';
|
|
44
|
+
if (target === norm(winDir))
|
|
45
|
+
return 'workspace is the Windows directory';
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
export class SandboxController {
|
|
49
|
+
deps;
|
|
50
|
+
launcherPromise;
|
|
51
|
+
probe;
|
|
52
|
+
tempRoot;
|
|
53
|
+
configRoot;
|
|
54
|
+
exemptSettled = false;
|
|
55
|
+
exemptLastAttempt = 0;
|
|
56
|
+
sid;
|
|
57
|
+
swept = false;
|
|
58
|
+
constructor(deps) {
|
|
59
|
+
this.deps = deps;
|
|
60
|
+
}
|
|
61
|
+
get platform() {
|
|
62
|
+
return this.deps.platform ? this.deps.platform() : process.platform;
|
|
63
|
+
}
|
|
64
|
+
get now() {
|
|
65
|
+
return this.deps.now ? this.deps.now() : Date.now();
|
|
66
|
+
}
|
|
67
|
+
/** Comparison key for change detection (settings-edit → re-ensure). */
|
|
68
|
+
static keyOf(settings, sid) {
|
|
69
|
+
return JSON.stringify([sid, settings.enabled, settings.mode, settings.proxy, settings.loopback]);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Compile the launcher once per source hash, ask it for the OS-derived
|
|
73
|
+
* container SID, and run the one-shot `--probe` verdict. Failures are NOT
|
|
74
|
+
* cached (a missing csc may appear after a .NET install — retry next call);
|
|
75
|
+
* the loopback exemption settles only on success (a failure retries with
|
|
76
|
+
* backoff — a transient denial must not disable loopback for the process
|
|
77
|
+
* lifetime).
|
|
78
|
+
*/
|
|
79
|
+
async ensure(settings) {
|
|
80
|
+
this.launcherPromise ??= ensureLauncher().then(async (compiled) => {
|
|
81
|
+
if (compiled === undefined) {
|
|
82
|
+
this.deps.log('[dsh-perm-gate] sandbox: csc.exe not found — install .NET Framework 4.x; sandboxed calls will be DENIED (fail-closed)');
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
// OS-derived container SID (opaque; both era shapes accepted).
|
|
86
|
+
this.sid = await launcherSid(compiled.exePath);
|
|
87
|
+
if (this.sid === undefined) {
|
|
88
|
+
this.deps.log('[dsh-perm-gate] sandbox: launcher --print-sid failed — sandboxed calls will be DENIED (fail-closed)');
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
// One-shot probe verdict (diagnostics/attribution ONLY — never a
|
|
92
|
+
// pass-through basis; per-launch resolution stays the trust anchor).
|
|
93
|
+
this.probe = await runProbe(compiled.exePath);
|
|
94
|
+
if (this.probe !== undefined) {
|
|
95
|
+
this.deps.log(`[dsh-perm-gate] sandbox probe: era=${this.probe.era} sidSubAuthorities=${this.probe.sidSubAuthorities} registered=${this.probe.registered.state} tryLaunch.win32Err=${this.probe.tryLaunch.win32Err}`);
|
|
96
|
+
}
|
|
97
|
+
// Fire-and-forget housekeeping (orphan scratch + stale compile cache).
|
|
98
|
+
void this.sweep().catch(() => undefined);
|
|
99
|
+
return { exePath: compiled.exePath, hash: compiled.hash };
|
|
100
|
+
}, (error) => {
|
|
101
|
+
this.launcherPromise = undefined; // transient compile failure: retry next call
|
|
102
|
+
this.deps.log(`[dsh-perm-gate] sandbox: launcher compile failed: ${String(error)}`);
|
|
103
|
+
return undefined;
|
|
104
|
+
});
|
|
105
|
+
const compiled = await this.launcherPromise;
|
|
106
|
+
if (compiled !== undefined && settings.loopback === 'exempt' && !this.exemptSettled && this.now - this.exemptLastAttempt >= EXEMPT_RETRY_MS) {
|
|
107
|
+
if (this.sid === undefined)
|
|
108
|
+
await this.launcherPromise;
|
|
109
|
+
if (this.sid !== undefined) {
|
|
110
|
+
this.exemptLastAttempt = this.now;
|
|
111
|
+
const result = await ensureLoopbackExempt(this.sid, { elevate: false });
|
|
112
|
+
// Only successes settle: a transient elevation denial must not
|
|
113
|
+
// disable loopback for the whole process lifetime (B finding).
|
|
114
|
+
if (result.status === 'exempt' || result.status === 'already-exempt' || result.status === 'unsupported') {
|
|
115
|
+
this.exemptSettled = true;
|
|
116
|
+
if (result.status === 'exempt')
|
|
117
|
+
this.deps.log(`[dsh-perm-gate] sandbox: loopback exempted for ${this.sid}`);
|
|
118
|
+
}
|
|
119
|
+
else if (result.status === 'failed') {
|
|
120
|
+
this.deps.log(`[dsh-perm-gate] sandbox: ${result.message}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return compiled;
|
|
125
|
+
}
|
|
126
|
+
/** The proxy URL injected into sandboxed processes, if any. */
|
|
127
|
+
resolveProxyUrl(settings) {
|
|
128
|
+
if (settings.proxy !== '') {
|
|
129
|
+
let parsed;
|
|
130
|
+
try {
|
|
131
|
+
parsed = new URL(settings.proxy);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
parsed = undefined;
|
|
135
|
+
}
|
|
136
|
+
const loopback = parsed !== undefined
|
|
137
|
+
&& (parsed.hostname === '127.0.0.1' || parsed.hostname === 'localhost' || parsed.hostname === '[::1]' || parsed.hostname === '::1');
|
|
138
|
+
if (!loopback) {
|
|
139
|
+
this.deps.log(`[dsh-perm-gate] sandbox: proxy "${settings.proxy}" is not loopback-bound — the container cannot reach it; ignored`);
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
return settings.proxy;
|
|
143
|
+
}
|
|
144
|
+
const port = this.deps.builtinProxyPort();
|
|
145
|
+
return port !== undefined && port > 0 ? `http://127.0.0.1:${port}` : undefined;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Gate-aware rewrite of one allowed shell call: undefined (not applicable —
|
|
149
|
+
* disabled / not a shell tool / non-Windows), wrap (the rewritten command),
|
|
150
|
+
* or deny (fail-closed when the sandbox is on but cannot be honored).
|
|
151
|
+
*/
|
|
152
|
+
async wrap(settings, exec) {
|
|
153
|
+
if (!settings.enabled)
|
|
154
|
+
return undefined;
|
|
155
|
+
if (this.platform !== 'win32')
|
|
156
|
+
return undefined; // feature is Windows-only: silently off (apply() warns)
|
|
157
|
+
if (!SHELL_TOOLS.has(exec.name))
|
|
158
|
+
return undefined;
|
|
159
|
+
// POSIX-dialect tools cannot be honored by the container's cmd.exe —
|
|
160
|
+
// running them anyway would silently reinterpret the command (R9).
|
|
161
|
+
if (POSIX_DIALECT_TOOLS.has(exec.name)) {
|
|
162
|
+
return { kind: 'deny', reason: `sandbox: "${exec.name}" is a POSIX-dialect tool the AppContainer wrapper cannot honor (cmd.exe semantics) — run it via a Windows shell tool or disable sandboxEnabled` };
|
|
163
|
+
}
|
|
164
|
+
const raw = exec.arguments['command'];
|
|
165
|
+
if (typeof raw !== 'string' || raw.trim() === '') {
|
|
166
|
+
return { kind: 'deny', reason: 'sandbox: shell call carries no command text — refusing to run it unconfined' };
|
|
167
|
+
}
|
|
168
|
+
const workspace = exec.cwd !== undefined && exec.cwd !== '' ? exec.cwd : this.deps.cwdRoot();
|
|
169
|
+
const forbidden = isForbiddenWorkspace(workspace);
|
|
170
|
+
if (forbidden !== undefined) {
|
|
171
|
+
return { kind: 'deny', reason: `sandbox: ${forbidden} — refusing to grant container ACEs there` };
|
|
172
|
+
}
|
|
173
|
+
const launcher = await this.ensure(settings);
|
|
174
|
+
if (launcher === undefined) {
|
|
175
|
+
const hint = this.probe !== undefined ? ` (probe: ${this.probe.era})` : '';
|
|
176
|
+
return { kind: 'deny', reason: `sandbox: launcher unavailable (compile failed or csc missing)${hint} — failing closed` };
|
|
177
|
+
}
|
|
178
|
+
const proxyUrl = this.resolveProxyUrl(settings);
|
|
179
|
+
const proxyEnv = {};
|
|
180
|
+
if (proxyUrl !== undefined) {
|
|
181
|
+
for (const name of PROXY_ENV_NAMES)
|
|
182
|
+
proxyEnv[name] = proxyUrl;
|
|
183
|
+
// Clear inherited NO_PROXY: otherwise hosts with NO_PROXY=127.0.0.1,…
|
|
184
|
+
// bypass the filtering proxy straight to loopback services (R4).
|
|
185
|
+
for (const name of NO_PROXY_ENV_NAMES)
|
|
186
|
+
proxyEnv[name] = '';
|
|
187
|
+
}
|
|
188
|
+
const tempDir = await this.ensureTempDir();
|
|
189
|
+
// The launch config lives OUTSIDE the container-writable scratch dir: a
|
|
190
|
+
// resident container process must never be able to rewrite a future
|
|
191
|
+
// call's config into granting ACEs elsewhere (R5 TOCTOU).
|
|
192
|
+
const configDir = await this.ensureConfigDir();
|
|
193
|
+
const configPath = await writeLaunchConfig({
|
|
194
|
+
workspace,
|
|
195
|
+
tempDir,
|
|
196
|
+
mode: settings.mode,
|
|
197
|
+
proxyEnv,
|
|
198
|
+
}, configDir);
|
|
199
|
+
const wrapped = buildWrappedCommand(launcher.exePath, configPath, raw);
|
|
200
|
+
this.deps.log(`[dsh-perm-gate] sandbox(appcontainer): ${raw.split('\n')[0].slice(0, 120)}`);
|
|
201
|
+
return { kind: 'wrap', command: wrapped };
|
|
202
|
+
}
|
|
203
|
+
/** One standing scratch dir per plugin process, removed on dispose. */
|
|
204
|
+
async ensureTempDir() {
|
|
205
|
+
this.tempRoot ??= await mkdtemp(path.join(tmpdir(), 'dsh-perm-gate-ac-'));
|
|
206
|
+
return this.tempRoot;
|
|
207
|
+
}
|
|
208
|
+
/** Private config dir — NOT container-writable (R5). */
|
|
209
|
+
async ensureConfigDir() {
|
|
210
|
+
this.configRoot ??= await mkdtemp(path.join(tmpdir(), 'dsh-perm-gate-ac-cfg-'));
|
|
211
|
+
return this.configRoot;
|
|
212
|
+
}
|
|
213
|
+
/** Best-effort scratch cleanup at plugin dispose. */
|
|
214
|
+
async dispose() {
|
|
215
|
+
for (const dir of [this.tempRoot, this.configRoot]) {
|
|
216
|
+
if (dir !== undefined) {
|
|
217
|
+
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
this.tempRoot = undefined;
|
|
221
|
+
this.configRoot = undefined;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* One-shot housekeeping: orphan scratch dirs (crashed processes leak them —
|
|
225
|
+
* 6 found on this machine) older than 24h, and stale compile-cache exes
|
|
226
|
+
* older than 7 days. Best effort, once per process.
|
|
227
|
+
*/
|
|
228
|
+
async sweep() {
|
|
229
|
+
if (this.swept)
|
|
230
|
+
return;
|
|
231
|
+
this.swept = true;
|
|
232
|
+
const dayMs = 24 * 60 * 60_000;
|
|
233
|
+
const now = this.now;
|
|
234
|
+
let entries = [];
|
|
235
|
+
try {
|
|
236
|
+
entries = await readdir(tmpdir());
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
for (const name of entries) {
|
|
242
|
+
if (!name.startsWith('dsh-perm-gate-ac-'))
|
|
243
|
+
continue;
|
|
244
|
+
const full = path.join(tmpdir(), name);
|
|
245
|
+
try {
|
|
246
|
+
const info = await stat(full);
|
|
247
|
+
if (now - info.mtimeMs > dayMs)
|
|
248
|
+
await rm(full, { recursive: true, force: true });
|
|
249
|
+
}
|
|
250
|
+
catch { /* racing with another process's sweep is fine */ }
|
|
251
|
+
}
|
|
252
|
+
// Compile cache: csc writes launcher-<hash>.exe (+ .ok); keep only recent
|
|
253
|
+
// artifacts — stale hashes are dead once the source moved on.
|
|
254
|
+
const csc = locateCsc();
|
|
255
|
+
if (csc === undefined)
|
|
256
|
+
return;
|
|
257
|
+
const { CACHE_DIR } = await import('./compile.js');
|
|
258
|
+
let cached = [];
|
|
259
|
+
try {
|
|
260
|
+
cached = await readdir(CACHE_DIR);
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
for (const name of cached) {
|
|
266
|
+
if (!name.startsWith('appcontainer-launcher-'))
|
|
267
|
+
continue;
|
|
268
|
+
const full = path.join(CACHE_DIR, name);
|
|
269
|
+
try {
|
|
270
|
+
const info = await stat(full);
|
|
271
|
+
if (now - info.mtimeMs > 7 * dayMs)
|
|
272
|
+
await rm(full, { force: true });
|
|
273
|
+
}
|
|
274
|
+
catch { /* ignore */ }
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
/** Ask the launcher for its OS-derived container SID (deterministic per name). */
|
|
279
|
+
function launcherSid(exePath) {
|
|
280
|
+
return new Promise((resolve) => {
|
|
281
|
+
execFile(exePath, ['--print-sid'], { windowsHide: true, timeout: 30_000 }, (error, stdout) => {
|
|
282
|
+
const sid = String(stdout ?? '').trim();
|
|
283
|
+
resolve(error === undefined || error === null ? (sid !== '' ? sid : undefined) : undefined);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export interface ProbeDerive {
|
|
2
|
+
readonly name: string;
|
|
3
|
+
readonly module: string;
|
|
4
|
+
readonly hr: number;
|
|
5
|
+
}
|
|
6
|
+
export type ProbeRegisteredState = 'yes' | 'no' | 'unknown' | 'n/a';
|
|
7
|
+
/** The `--probe` verdict, exactly the launcher's single-line JSON. */
|
|
8
|
+
export interface LauncherProbe {
|
|
9
|
+
readonly era: 'classic' | 'moniker' | 'none';
|
|
10
|
+
readonly derive: ProbeDerive;
|
|
11
|
+
readonly sid: string;
|
|
12
|
+
readonly sidSubAuthorities: number;
|
|
13
|
+
readonly registered: {
|
|
14
|
+
readonly state: ProbeRegisteredState;
|
|
15
|
+
readonly lookupHr: number;
|
|
16
|
+
readonly mappingMoniker: string | null;
|
|
17
|
+
};
|
|
18
|
+
readonly tryLaunch: {
|
|
19
|
+
readonly attempted: boolean;
|
|
20
|
+
readonly win32Err: number;
|
|
21
|
+
readonly childExit: number | null;
|
|
22
|
+
};
|
|
23
|
+
readonly osBuild: number;
|
|
24
|
+
readonly capabilities: Readonly<Record<string, boolean>>;
|
|
25
|
+
readonly mxc: {
|
|
26
|
+
readonly present: boolean;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Parse the probe's single-line stdout. Returns undefined for anything that
|
|
31
|
+
* is not a recognizable verdict (crash output, truncated line, foreign text) —
|
|
32
|
+
* the controller treats undefined as UNAVAILABLE-with-retry.
|
|
33
|
+
*/
|
|
34
|
+
export declare function parseProbeJson(text: string): LauncherProbe | undefined;
|
|
35
|
+
/**
|
|
36
|
+
* The in-memory probe cache key. The launcher hash covers code changes, the
|
|
37
|
+
* OS build covers kernel-side behavior changes; context is deliberately NOT
|
|
38
|
+
* in the key because the cache never leaves the process (see header).
|
|
39
|
+
*/
|
|
40
|
+
export declare function probeCacheKey(launcherHash: string, probe: LauncherProbe): string;
|
|
41
|
+
/** Run `launcher --probe` and parse its verdict. Undefined = launcher unusable. */
|
|
42
|
+
export declare function runProbe(exePath: string, timeoutMs?: number): Promise<LauncherProbe | undefined>;
|
|
43
|
+
/** Human-attributable one-liner for deny logs (which resolution level failed). */
|
|
44
|
+
export declare function probeAttribution(probe: LauncherProbe): string;
|