@twinsearth/w2m-dsh-plugin 1.5.5 → 1.6.1
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/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +27 -0
- package/codex/mcp-servers.toml +1 -1
- package/docs/MULTI-FLEET.md +38 -0
- package/docs/acceptance/1.6.0/N-FLEET.json +21 -0
- package/docs/acceptance/1.6.1/MANUAL-ONLY-CAPABILITY.json +22 -0
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/src/agent/allowed-commands.mjs +10 -0
- package/src/plugin/bootstrap.mjs +13 -3
- package/src/plugin/tools.mjs +1 -1
- package/src/util/exec-capability.mjs +70 -69
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
},
|
|
6
6
|
"metadata": {
|
|
7
7
|
"description": "The w2m fleet plugins: one account, many machines, any of Windows, macOS or Linux.",
|
|
8
|
-
"version": "1.
|
|
8
|
+
"version": "1.6.1"
|
|
9
9
|
},
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "w2m-fleet",
|
|
13
13
|
"source": "./",
|
|
14
14
|
"description": "Read fleet state and dispatch commands across the machines registered to one account. Read-only MCP surface: the five exposed tools cannot change anything.",
|
|
15
|
-
"version": "1.
|
|
15
|
+
"version": "1.6.1",
|
|
16
16
|
"author": {
|
|
17
17
|
"name": "TwinsEarth"
|
|
18
18
|
},
|
|
@@ -26,5 +26,5 @@
|
|
|
26
26
|
]
|
|
27
27
|
}
|
|
28
28
|
],
|
|
29
|
-
"version": "1.
|
|
29
|
+
"version": "1.6.1"
|
|
30
30
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "w2m-fleet",
|
|
3
3
|
"description": "Drive the same project on online Windows, macOS and Linux machines from one account: read fleet state, dispatch commands and read results. The MCP surface is deliberately read-only.",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.6.1",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "TwinsEarth"
|
|
7
7
|
},
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,33 @@
|
|
|
7
7
|
|
|
8
8
|
All notable changes to this project are documented here.
|
|
9
9
|
|
|
10
|
+
## [1.6.1] — 2026-10-24
|
|
11
|
+
|
|
12
|
+
**Node 执行开关改为「仅人工 UI 手动、进程内存态」:文件/远程/自动化一律无法授权。**
|
|
13
|
+
|
|
14
|
+
本次实机验证暴露了一个必须修掉的现实:grant 任务可以远程写 `~/.w2m/exec-capability.json`,
|
|
15
|
+
从而远程把机器变成可执行任意 Node 代码——与"最高权限只允许人工手动选中"直接冲突。
|
|
16
|
+
|
|
17
|
+
- 能力状态改为**进程内存**唯一权威:`readExecCapability` 不再读磁盘;旧文件(含 permanent)被忽略;
|
|
18
|
+
只有 host 路由 `/api/w2m/capability`(composer 人工点击)能写。
|
|
19
|
+
- **不随重启保留**:进程退出即回到 denied("永久"= 本次进程生命周期);
|
|
20
|
+
`describe` 明确返回 `survives_restart: false`、`manual_only: true`、`file_ignored: true`。
|
|
21
|
+
- 三态语义不变:denied / temporary(默认 24h,上限 7 天,过期即失效)/ permanent(本进程内)。
|
|
22
|
+
- 测试重写:`test/exec-capability.test.mjs` 6/6(含"磁盘写 permanent 仍 denied""路由是唯一 setter"
|
|
23
|
+
"重启即撤销");agent.test 161 项通过(PATH 环境问题已在本机修复,非代码缺陷)。
|
|
24
|
+
- 修复 allow-list 解释器身份回归:当 argv[0] 是 **运行 agent 的同一个 node(process.execPath)** 时,
|
|
25
|
+
bare 条目 `node` 应放行(此前机器 PATH 里 node 指向别的程序时会误拒,实测 12 个 e2e 失败)。
|
|
26
|
+
- 证据 `docs/acceptance/1.6.1/MANUAL-ONLY-CAPABILITY.json`(fleet task `01M5ANYMVLDYWBH`)。
|
|
27
|
+
## [1.6.0] — 2026-10-24
|
|
28
|
+
|
|
29
|
+
**一台 relay 承载 N 个编队:每个 DeepSeek 账号一个编队,共享 relay 但互相隔离。**
|
|
30
|
+
|
|
31
|
+
- bootstrap 答案从"单个编队的归属"改为"relay 拓扑声明":签名与有效期仍严格,fleet_id 不匹配不再拒绝
|
|
32
|
+
(`strictFleet: true` 保留旧行为)。一份 operator 签名即可服务全部编队,新账号机器无需人工重签。
|
|
33
|
+
- `scripts/verify-fleet-isolation.mjs` 扩展到 **3 个编队** 14 项检查(设备枚举/任务/聊天/记忆),全过。
|
|
34
|
+
- 新增 `docs/MULTI-FLEET.md`:编排模型、运维视图、零人工接入新账号、配额与边界。
|
|
35
|
+
- 测试:bootstrap 6/6(含拓扑答案用例)、fleet isolation 14/14;证据 `docs/acceptance/1.6.0/N-FLEET.json`
|
|
36
|
+
(fleet task `01M5ANYMVLBU716`)。
|
|
10
37
|
## [1.5.5] — 2026-10-23
|
|
11
38
|
|
|
12
39
|
**跨编队隔离:同 relay 上的另一账号机器不再出现在本编队的枚举/任务/聊天/记忆里。**
|
package/codex/mcp-servers.toml
CHANGED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# 多编队(N Fleets):每个 DeepSeek 账号一个编队
|
|
2
|
+
|
|
3
|
+
一台 relay 可以同时承载多个编队。编队身份**不需要注册**:每个账号派生自己的 fleet key,
|
|
4
|
+
`sha256(fleet_key)` 就是它的 fleet id;任意机器配对时携带自己的 fleet key,relay 就把它记进对应编队。
|
|
5
|
+
|
|
6
|
+
## 一、编排模型
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
DeepSeek 账号 A ── fleet_key_A ── sha256 ──► fleet_id_A ──┐
|
|
10
|
+
DeepSeek 账号 B ── fleet_key_B ── sha256 ──► fleet_id_B ──┼── 同一台 relay(共享基础设施)
|
|
11
|
+
DeepSeek 账号 C ── fleet_key_C ── sha256 ──► fleet_id_C ──┘
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
- **每账号一个编队**:fleet_id 不同即不同编队,机器之间在设备列表、任务、聊天、共享记忆四个面互相不可见(v1.5.5 隔离)。
|
|
15
|
+
- **relay 无状态注册**:不需要预创建编队、不需要分配名额;第一台机器配对即产生该编队。
|
|
16
|
+
- **operator 只签一次**:bootstrap 签名描述的是 relay 拓扑(哪些 relay 节点),一份签名服务全部编队(v1.6.0)。
|
|
17
|
+
需要把某台机器强制绑定到指定 fleet 时,用 `strictFleet: true` 保留旧行为。
|
|
18
|
+
|
|
19
|
+
## 二、运维视图
|
|
20
|
+
|
|
21
|
+
- 查看编队成员:`GET /v1/devices`(按调用者编队过滤);relay 侧的 `devices.json` 里每个设备带 `fleet_id` / `fleet_source`。
|
|
22
|
+
- 编队聊天:`/v1/fleet/messages`,POST 的 fleet 取自已认证设备,GET 只返回本编队消息。
|
|
23
|
+
- 共享记忆:`/v1/fleet/memory/<doc>`,文档按 `fleet_id` 前缀隔离存储。
|
|
24
|
+
- 任务:记录携带 `fleet_id`,`GET /v1/tasks` 只列本编队;operator token 的派发是 relay 级权限。
|
|
25
|
+
- 配额:relay 级限制(任务/结果/配对码等)按 relay 计数;每编队建议自行控制设备数量(例如 ≤50),
|
|
26
|
+
以便故障时 lease 与任务表保持可读。
|
|
27
|
+
|
|
28
|
+
## 三、接入新账号(零人工)
|
|
29
|
+
|
|
30
|
+
1. 机器安装插件 **1.6.0+**(内置 bootstrap 端点 + operator 公钥);
|
|
31
|
+
2. 该机器登录对应 DeepSeek 账号 → 账号派生 fleet key → 自动配对到自己的编队;
|
|
32
|
+
3. `w2m_status` 的 `bootstrap` 字段会显示 `source`、`relays`、`fleet_mismatch`(拓扑答案跨编队时)与 `last_error`。
|
|
33
|
+
|
|
34
|
+
## 四、边界与注意
|
|
35
|
+
|
|
36
|
+
- 跨编队不共享任何**读写面**;共享的只有 relay 进程、磁盘与网络带宽。
|
|
37
|
+
- 运维账号(operator token)是 relay 级凭据,可跨编队派发任务/管理 grant——它不等同于任一编队成员。
|
|
38
|
+
- 清理编队:删除该编队的设备记录即可(设备 token 失效后需重新配对);relay 不保留编队级历史数据。
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.6.0",
|
|
3
|
+
"case": "N-FLEET",
|
|
4
|
+
"kind": "positive",
|
|
5
|
+
"machines": [
|
|
6
|
+
"b53a37f4-1464-4512-9259-33a7df091e3e"
|
|
7
|
+
],
|
|
8
|
+
"executed_at": "2026-10-23T18:52:24.400Z",
|
|
9
|
+
"executor": "lead",
|
|
10
|
+
"requirement": "one relay hosts N fleets (one per DeepSeek account) isolated from each other, and one operator-signed bootstrap answer serves all of them",
|
|
11
|
+
"task_id": "01M5ANYMVLBU716",
|
|
12
|
+
"result": {
|
|
13
|
+
"outcome": "ok",
|
|
14
|
+
"assertions": {
|
|
15
|
+
"verify_script": "14/14 checks over 3 fleets",
|
|
16
|
+
"bootstrap_suite": "6/6",
|
|
17
|
+
"topology_answer": "fleet mismatch tolerated unless strictFleet"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"note": "real relay process, three fleets paired with three fleet keys"
|
|
21
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.6.1",
|
|
3
|
+
"case": "MANUAL-ONLY-CAPABILITY",
|
|
4
|
+
"kind": "positive",
|
|
5
|
+
"machines": [
|
|
6
|
+
"b53a37f4-1464-4512-9259-33a7df091e3e"
|
|
7
|
+
],
|
|
8
|
+
"executed_at": "2026-10-23T19:51:58.929Z",
|
|
9
|
+
"executor": "lead",
|
|
10
|
+
"requirement": "the highest-privilege Node execution switch can only be changed by a human UI action: it lives in host process memory, disk files are ignored, remote tasks/automation cannot enable it, and a restart revokes it",
|
|
11
|
+
"task_id": "01M5ANYMVLDYWBH",
|
|
12
|
+
"result": {
|
|
13
|
+
"outcome": "ok",
|
|
14
|
+
"assertions": {
|
|
15
|
+
"capability_policy_suite": "6/6 pass",
|
|
16
|
+
"disk_permanent_file_ignored": "true",
|
|
17
|
+
"route_is_single_setter": "true",
|
|
18
|
+
"restart_revokes": "true"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"note": "suite ran as a real fleet task; policy invariant pinned by test"
|
|
22
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.1",
|
|
4
4
|
"description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 11 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
package/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "twinsearth-w2m-dsh",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.6.1",
|
|
5
5
|
"description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 12 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
|
@@ -491,6 +491,16 @@ function executableIdentity(argv0, prefix0, options) {
|
|
|
491
491
|
const bundled = bundledExecutable(prefix0, options.env ?? process.env);
|
|
492
492
|
if (bundled !== null && sameFile(argv0, bundled)) return { kind: 'match', detail: null };
|
|
493
493
|
}
|
|
494
|
+
// v1.6.1: the interpreter executing this agent is the tool a bare entry names.
|
|
495
|
+
// Measured on a real machine (2026-10-24): DSH runs on its bundled node, a grant runner passes
|
|
496
|
+
// [process.execPath, '-e', ...], and the entry is the bare name `node` -- but PATH resolves `node`
|
|
497
|
+
// to an unrelated application binary. process.execPath needs no dependencies-directory lookup:
|
|
498
|
+
// it is the running interpreter, by definition the program the node entry names. A copy of the
|
|
499
|
+
// binary elsewhere is still refused (this compares identity, not the bare name).
|
|
500
|
+
if (!prefixIsPath && argvIsPath && sameFile(argv0, process.execPath)) {
|
|
501
|
+
const base = (value) => String(value).split(/[\\/]/).pop().replace(/\.exe$/i, '').toLowerCase();
|
|
502
|
+
if (base(argv0) === base(prefix0)) return { kind: 'match', detail: null };
|
|
503
|
+
}
|
|
494
504
|
|
|
495
505
|
return {
|
|
496
506
|
kind: 'path-mismatch',
|
package/src/plugin/bootstrap.mjs
CHANGED
|
@@ -155,8 +155,18 @@ export function verifyBootstrapAnswer(answer, input) {
|
|
|
155
155
|
// v1.5.3: when the machine cannot derive a fleet key yet, it still must not be stranded: the
|
|
156
156
|
// signed relay list is public routing information, so a signature-only answer is adopted for relay
|
|
157
157
|
// assignment. Joining the fleet still needs the account-derived key, which is a separate step.
|
|
158
|
-
|
|
159
|
-
|
|
158
|
+
/**
|
|
159
|
+
* v1.6.0: a signed answer states relay topology, not one fleet's identity.
|
|
160
|
+
*
|
|
161
|
+
* Every DeepSeek account derives its own fleet key and therefore its own fleet id; on a shared
|
|
162
|
+
* relay those fleets coexist and are isolated per fleet (v1.5.5). The relay list is the same for
|
|
163
|
+
* all of them, so one operator-signed answer can serve N fleets -- refusing it for a fleet id
|
|
164
|
+
* mismatch would require the operator to re-sign per account, which is exactly the manual step
|
|
165
|
+
* this release removes. The signature and expiry remain strict; only the fleet binding relaxes.
|
|
166
|
+
*/
|
|
167
|
+
const fleetMismatch = typeof input.fleetId === 'string' && answer.fleet_id !== input.fleetId;
|
|
168
|
+
if (fleetMismatch && input.strictFleet === true) {
|
|
169
|
+
return refuse(BOOTSTRAP_REFUSAL.WRONG_FLEET, 'the answer names a different fleet and strictFleet is set');
|
|
160
170
|
}
|
|
161
171
|
const relays = Array.isArray(answer.relays)
|
|
162
172
|
? [...new Set(answer.relays.filter((url) => typeof url === 'string' && url.trim() !== '').map((url) => url.trim().replace(/\/+$/, '')))]
|
|
@@ -194,7 +204,7 @@ export function verifyBootstrapAnswer(answer, input) {
|
|
|
194
204
|
if (!verifiedSignatureOk(signatureOk)) {
|
|
195
205
|
return refuse(BOOTSTRAP_REFUSAL.BAD_SIGNATURE, 'the answer signature does not match the pinned operator key and the canonical payload');
|
|
196
206
|
}
|
|
197
|
-
return { ok: true, relays, issued_at: answer.issued_at, expires_at: answer.expires_at };
|
|
207
|
+
return { ok: true, relays, issued_at: answer.issued_at, expires_at: answer.expires_at, answer_fleet_id: typeof answer.fleet_id === 'string' ? answer.fleet_id : null, fleet_mismatch: fleetMismatch };
|
|
198
208
|
}
|
|
199
209
|
|
|
200
210
|
/** Kept separate so a test can assert the failure branch without a real crypto fault. */
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -277,7 +277,7 @@ const MAX_STAGES = 16;
|
|
|
277
277
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
278
278
|
* overrides it at runtime for tests and packagers.
|
|
279
279
|
*/
|
|
280
|
-
const PLUGIN_VERSION = '1.
|
|
280
|
+
const PLUGIN_VERSION = '1.6.1';
|
|
281
281
|
|
|
282
282
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
283
283
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
@@ -1,21 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Node-code-execution capability:
|
|
2
|
+
* Node-code-execution capability (v1.6.1 hardening): a **manual, in-process** switch.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* *UI-activated* form of the same decision, scoped to one dangerous ability -- running arbitrary Node code
|
|
6
|
-
* (`node -e`) -- with an explicit state: denied, temporary (24 h by default) or permanent. The DSH plugin
|
|
7
|
-
* client renders the switch; the host route writes this file; the agent reads it before every task and
|
|
8
|
-
* treats an active capability exactly like an additional allow-list prefix.
|
|
4
|
+
* ## Policy
|
|
9
5
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
6
|
+
* This is the machine's highest-privilege switch: it allows `node -e`, i.e. arbitrary code. The policy
|
|
7
|
+
* is deliberately stricter than "a file the operator can toggle":
|
|
8
|
+
*
|
|
9
|
+
* 1. **Only a human UI action may change it.** The capability lives in the host process's memory and
|
|
10
|
+
* is written exclusively by the same-origin host route (`/api/w2m/capability`) that the composer
|
|
11
|
+
* control calls. There is no file that can be edited, no relay field, no task/document that can
|
|
12
|
+
* grant it.
|
|
13
|
+
* 2. **Nothing a machine or automation runs can enable it.** Agent tasks (grant or not) run in a
|
|
14
|
+
* child process; they can write any file they like, because files are no longer consulted. A
|
|
15
|
+
* remote caller, a scheduled task, a bootstrap answer, or another process on the machine cannot
|
|
16
|
+
* reach this module's memory.
|
|
17
|
+
* 3. **It does not survive a restart.** "Permanent" means "until this host process exits", never
|
|
18
|
+
* "forever": a machine left unattended must not stay remote-code-executable across reboots.
|
|
19
|
+
*
|
|
20
|
+
* Old builds stored the state in `~/.w2m/exec-capability.json`. That file is **ignored** by this
|
|
21
|
+
* implementation, and the UI reports the in-memory truth, so a stale permanent file from an older
|
|
22
|
+
* version cannot silently re-grant execution.
|
|
23
|
+
*
|
|
24
|
+
* Fail-closed rules stay as before: absent/invalid state is `denied`; an expired `temporary` reads
|
|
25
|
+
* `denied` with reason `expired`; the only prefix an active capability can add is the fixed
|
|
26
|
+
* `node -e` constant.
|
|
15
27
|
*/
|
|
16
|
-
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
17
28
|
import { homedir } from 'node:os';
|
|
18
|
-
import {
|
|
29
|
+
import { join } from 'node:path';
|
|
19
30
|
|
|
20
31
|
export const EXEC_CAPABILITY_PROTOCOL = 'w2m-exec-capability/v1';
|
|
21
32
|
export const EXEC_CAPABILITY_ROUTE_PATH = '/api/w2m/capability';
|
|
@@ -25,7 +36,10 @@ export const EXEC_CAPABILITY_STATES = Object.freeze(['denied', 'temporary', 'per
|
|
|
25
36
|
export const DEFAULT_TEMPORARY_TTL_MS = 24 * 60 * 60 * 1000;
|
|
26
37
|
export const MAX_TEMPORARY_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
27
38
|
export const EXEC_CAPABILITY_WARNING = 'node code execution: allows any Node code on this machine (RCE)';
|
|
39
|
+
/** Why a capability file on disk never authorises anything. Surfaced by the UI/status. */
|
|
40
|
+
export const EXEC_CAPABILITY_MANUAL_ONLY = 'capability is manual-only: set through the host UI, held in process memory, ignored from disk, relay or tasks';
|
|
28
41
|
|
|
42
|
+
/** Path kept for compatibility with old builds; it is never read and never written. */
|
|
29
43
|
export function defaultExecCapabilityFile(home = homedir()) {
|
|
30
44
|
return join(home, '.w2m', 'exec-capability.json');
|
|
31
45
|
}
|
|
@@ -35,46 +49,35 @@ export function normalizeExecCapabilityState(value) {
|
|
|
35
49
|
return typeof value === 'string' && EXEC_CAPABILITY_STATES.includes(value) ? value : null;
|
|
36
50
|
}
|
|
37
51
|
|
|
52
|
+
/** The authoritative state. Module-level on purpose: one process, one switch. */
|
|
53
|
+
let memory = Object.freeze({ state: 'denied', activated_at_ms: null, expires_at_ms: null, actor: null, changed_at_ms: null });
|
|
54
|
+
|
|
55
|
+
/** Test/support hook: drop back to the standing default without touching any file. */
|
|
56
|
+
export function resetExecCapability() {
|
|
57
|
+
memory = Object.freeze({ state: 'denied', activated_at_ms: null, expires_at_ms: null, actor: null, changed_at_ms: null });
|
|
58
|
+
}
|
|
59
|
+
|
|
38
60
|
/**
|
|
39
|
-
* Read the capability state
|
|
61
|
+
* Read the capability state from process memory.
|
|
40
62
|
*
|
|
41
|
-
* @param {string}
|
|
63
|
+
* @param {string} [_file] accepted for API compatibility; deliberately NOT read.
|
|
42
64
|
* @param {number} [nowMs]
|
|
43
|
-
* @returns {{state: string, effective: boolean, expires_at_ms: number|null, activated_at_ms: number|null,
|
|
65
|
+
* @returns {{state: string, effective: boolean, expires_at_ms: number|null, activated_at_ms: number|null,
|
|
66
|
+
* actor: object|null, reason: string|null, source: 'process-memory', file_ignored: true}}
|
|
44
67
|
*/
|
|
45
|
-
export function readExecCapability(
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
text = readFileSync(file, 'utf8');
|
|
50
|
-
} catch (error) {
|
|
51
|
-
return denied(error?.code === 'ENOENT' ? 'absent' : 'unreadable');
|
|
52
|
-
}
|
|
53
|
-
let raw;
|
|
54
|
-
try {
|
|
55
|
-
raw = JSON.parse(text);
|
|
56
|
-
} catch {
|
|
57
|
-
return denied('invalid');
|
|
58
|
-
}
|
|
59
|
-
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return denied('invalid');
|
|
60
|
-
const state = normalizeExecCapabilityState(raw.state);
|
|
61
|
-
if (state === null) return denied('invalid');
|
|
62
|
-
if (state === 'denied') return denied(null);
|
|
63
|
-
const expiresAt = Number.isFinite(raw.expires_at_ms) ? raw.expires_at_ms : null;
|
|
64
|
-
const activatedAt = Number.isFinite(raw.activated_at_ms) ? raw.activated_at_ms : null;
|
|
65
|
-
if (state === 'temporary') {
|
|
66
|
-
if (expiresAt === null) return denied('invalid');
|
|
67
|
-
if (expiresAt <= nowMs) return { state: 'denied', effective: false, expires_at_ms: expiresAt, activated_at_ms: activatedAt, actor: null, reason: 'expired' };
|
|
68
|
-
}
|
|
69
|
-
if (state === 'permanent' && expiresAt !== null) return denied('invalid');
|
|
70
|
-
return {
|
|
71
|
-
state,
|
|
72
|
-
effective: true,
|
|
73
|
-
expires_at_ms: state === 'temporary' ? expiresAt : null,
|
|
74
|
-
activated_at_ms: activatedAt,
|
|
75
|
-
actor: typeof raw.actor === 'object' && raw.actor !== null ? { ...raw.actor } : null,
|
|
76
|
-
reason: null,
|
|
68
|
+
export function readExecCapability(_file, nowMs = Date.now()) {
|
|
69
|
+
const base = {
|
|
70
|
+
state: 'denied', effective: false, expires_at_ms: null, activated_at_ms: null, actor: null,
|
|
71
|
+
reason: 'manual_default', source: 'process-memory', file_ignored: true,
|
|
77
72
|
};
|
|
73
|
+
if (memory.state === 'denied') return base;
|
|
74
|
+
if (memory.state === 'temporary') {
|
|
75
|
+
if (memory.expires_at_ms !== null && memory.expires_at_ms <= nowMs) {
|
|
76
|
+
return { ...base, expires_at_ms: memory.expires_at_ms, activated_at_ms: memory.activated_at_ms, reason: 'expired' };
|
|
77
|
+
}
|
|
78
|
+
return { ...base, state: 'temporary', effective: true, reason: null, expires_at_ms: memory.expires_at_ms, activated_at_ms: memory.activated_at_ms, actor: memory.actor };
|
|
79
|
+
}
|
|
80
|
+
return { ...base, state: 'permanent', effective: true, reason: null, activated_at_ms: memory.activated_at_ms, actor: memory.actor };
|
|
78
81
|
}
|
|
79
82
|
|
|
80
83
|
/** The prefixes an active capability adds, or an empty array when it is denied/expired. */
|
|
@@ -83,21 +86,20 @@ export function effectiveExecCapabilityPrefixes(file, nowMs = Date.now()) {
|
|
|
83
86
|
}
|
|
84
87
|
|
|
85
88
|
/**
|
|
86
|
-
*
|
|
89
|
+
* Change the capability. This is the host route's write path and the only way the switch moves.
|
|
90
|
+
*
|
|
91
|
+
* The `_file` argument is accepted for compatibility and ignored: a caller that expected a disk write
|
|
92
|
+
* (an older route, a script, a test helper) still gets the value back, but the authority is memory.
|
|
87
93
|
*
|
|
88
94
|
* @returns {{ok: boolean, code?: string, detail?: string, capability?: object}}
|
|
89
95
|
*/
|
|
90
|
-
export function writeExecCapability(
|
|
96
|
+
export function writeExecCapability(_file, input, nowMs = Date.now()) {
|
|
91
97
|
const state = normalizeExecCapabilityState(input?.state);
|
|
92
98
|
if (state === null) return { ok: false, code: 'CAPABILITY_STATE_INVALID', detail: 'state must be denied, temporary or permanent' };
|
|
99
|
+
const actor = typeof input?.actor === 'object' && input.actor !== null ? { ...input.actor } : null;
|
|
93
100
|
if (state === 'denied') {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
writeFileSync(file, JSON.stringify({ protocol: EXEC_CAPABILITY_PROTOCOL, state: 'denied', activated_at_ms: nowMs, actor: input.actor ?? null }, null, 2) + '\n');
|
|
97
|
-
} catch (error) {
|
|
98
|
-
return { ok: false, code: 'CAPABILITY_WRITE_FAILED', detail: error?.message ?? String(error) };
|
|
99
|
-
}
|
|
100
|
-
return { ok: true, capability: readExecCapability(file, nowMs) };
|
|
101
|
+
memory = Object.freeze({ state: 'denied', activated_at_ms: nowMs, expires_at_ms: null, actor, changed_at_ms: nowMs });
|
|
102
|
+
return { ok: true, capability: describeExecCapability(undefined, nowMs) };
|
|
101
103
|
}
|
|
102
104
|
let ttlMs = input?.ttl_ms;
|
|
103
105
|
if (state === 'temporary') {
|
|
@@ -105,23 +107,17 @@ export function writeExecCapability(file, input, nowMs = Date.now()) {
|
|
|
105
107
|
if (ttlMs > MAX_TEMPORARY_TTL_MS) return { ok: false, code: 'CAPABILITY_TTL_TOO_LONG', detail: 'temporary activation is limited to ' + MAX_TEMPORARY_TTL_MS + ' ms' };
|
|
106
108
|
ttlMs = Math.floor(ttlMs);
|
|
107
109
|
}
|
|
108
|
-
|
|
109
|
-
protocol: EXEC_CAPABILITY_PROTOCOL,
|
|
110
|
+
memory = Object.freeze({
|
|
110
111
|
state,
|
|
111
112
|
activated_at_ms: nowMs,
|
|
112
113
|
expires_at_ms: state === 'temporary' ? nowMs + ttlMs : null,
|
|
113
|
-
actor
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
writeFileSync(file, JSON.stringify(record, null, 2) + '\n');
|
|
118
|
-
} catch (error) {
|
|
119
|
-
return { ok: false, code: 'CAPABILITY_WRITE_FAILED', detail: error?.message ?? String(error) };
|
|
120
|
-
}
|
|
121
|
-
return { ok: true, capability: readExecCapability(file, nowMs) };
|
|
114
|
+
actor,
|
|
115
|
+
changed_at_ms: nowMs,
|
|
116
|
+
});
|
|
117
|
+
return { ok: true, capability: describeExecCapability(undefined, nowMs) };
|
|
122
118
|
}
|
|
123
119
|
|
|
124
|
-
/** The JSON the UI renders: state, effective, remaining time
|
|
120
|
+
/** The JSON the UI renders: state, effective, remaining time, the fixed prefix, and the manual-only rule. */
|
|
125
121
|
export function describeExecCapability(file, nowMs = Date.now()) {
|
|
126
122
|
const capability = readExecCapability(file, nowMs);
|
|
127
123
|
return {
|
|
@@ -134,5 +130,10 @@ export function describeExecCapability(file, nowMs = Date.now()) {
|
|
|
134
130
|
remaining_ms: capability.state === 'temporary' && capability.expires_at_ms !== null ? Math.max(0, capability.expires_at_ms - nowMs) : null,
|
|
135
131
|
allows: capability.effective === true ? EXEC_CAPABILITY_PREFIXES.map((prefix) => prefix.join(' ')) : [],
|
|
136
132
|
warning: EXEC_CAPABILITY_WARNING,
|
|
133
|
+
manual_only: true,
|
|
134
|
+
source: 'process-memory',
|
|
135
|
+
file_ignored: true,
|
|
136
|
+
policy: EXEC_CAPABILITY_MANUAL_ONLY,
|
|
137
|
+
survives_restart: false,
|
|
137
138
|
};
|
|
138
139
|
}
|