@elinpf/dsh-ops-access-gate 0.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/README.i18n.yaml +4 -0
- package/README.md +41 -0
- package/README.zh.md +41 -0
- package/cordis.patch.yml +1 -0
- package/lib/index.d.ts +49 -0
- package/lib/index.js +861 -0
- package/lib/invariant.d.ts +16 -0
- package/lib/invariant.js +27 -0
- package/lib/types.d.ts +122 -0
- package/lib/types.js +11 -0
- package/package.json +66 -0
package/README.i18n.yaml
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# @elinpf/dsh-ops-access-gate
|
|
2
|
+
|
|
3
|
+
Per-session credential brokering with human approval for DeepSeek Harness ops mode — owns the authorization ledger and decides ro/rw/deny on every credential resolve.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
The gate sits between the ops-access registry (`@elinpf/dsh-ops-access`) and every credential resolve: it registers a **pure-decision broker** into that seam that answers `rw` when the calling session holds an unexpired grant for the profile, `ro` otherwise, and `deny` for approval-required kinds (ssh) without a grant or for operator-locked profiles.
|
|
8
|
+
|
|
9
|
+
- **`request_access` tool** — the model asks for a time-limited grant; the call parks in a pending-request queue until a human decides it in the access panel, and the result reports the TTL actually granted (the human may dial it down).
|
|
10
|
+
- **Access panel backend** — 9 HTTP routes (`/ops-access/grants*`, `/ops-access/access-requests*`, `/ops-access/deny|undeny`) plus the `/access` and `/access-all` slash commands. Panel grants are identical in shape and lifetime to request grants.
|
|
11
|
+
- **Audit log** — every request, decision, grant, expiry, revoke, lockdown, and ledger reset lands in an append-only JSONL file.
|
|
12
|
+
- **Lockdowns** — operator denies persist to `deniedFile` and survive restarts (an incident freeze that silently lifts on restart is no freeze).
|
|
13
|
+
|
|
14
|
+
## Design notes
|
|
15
|
+
|
|
16
|
+
- **The gate never sees credential fields.** Kind, profile name, and session id are its entire world — secret material cannot leak through authorization.
|
|
17
|
+
- **Session-keyed ledger, lazy expiry.** The preset-plane instance is shared, so grants key on `agent.id`; a lapsed grant is evicted the first time it is consulted (a web session has no dependable end event — TTL is the only reliable boundary).
|
|
18
|
+
- **The access panel is the approval channel** (ADR-0004), not dsh's native approval: the native outcome vocabulary cannot carry a human-adjusted TTL. Headless deployments (no web server) fail `request_access` fast with out-of-band guidance.
|
|
19
|
+
- **types.ts is pure types** (zero runtime); `src/invariant.ts` is the invariant companion — it reserves package ownership but installs nothing, because the gate owns no session-event shape (its state is the in-process ledger plus the JSONL audit file).
|
|
20
|
+
- **Every registration is an effect**, so fiber disposal / HMR unload removes the tool, both commands, all routes, the notice listener, the broker, and the provided `opsAccessGate` service, and settles parked requests as cancelled.
|
|
21
|
+
|
|
22
|
+
## Configuration
|
|
23
|
+
|
|
24
|
+
| Key | Default | Meaning |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| `approvalRequiredKinds` | `['ssh']` | Kinds with no ro tier — any use requires a grant |
|
|
27
|
+
| `defaultTtlMinutes` | `30` | Grant lifetime when `request_access` omits `ttlMinutes` |
|
|
28
|
+
| `maxTtlMinutes` | `480` | Upper bound for a requested grant lifetime |
|
|
29
|
+
| `auditFile` | `~/.dsh-ops/audit.log` | JSONL audit log path (`~` expands) |
|
|
30
|
+
| `grantTtlOptions` | `[5, 10, 30]` | TTL choices the access panel offers |
|
|
31
|
+
| `pendingRequestTimeoutMinutes` | `5` | How long a parked request awaits a human before auto-rejecting |
|
|
32
|
+
| `deniedFile` | `~/.dsh-ops/denied.json` | Persisted lockdown state (survives restarts) |
|
|
33
|
+
|
|
34
|
+
## Testing
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
npm run build
|
|
38
|
+
npx vitest run
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The spec mounts the gate together with the real ops-access core against a mock cordis context (real tmp registry and audit files) and drives the externally observable seams: resolve serves ro vs rw by the ledger, `request_access` parks until the decide route settles it, panel routes grant/revoke directly, TTL expiry and notices behave, and every transition is audited. An HMR-unload suite disposes the gate fiber and asserts every registration surface (tool, commands, routes, listener, broker, service) is removed while core stays mounted.
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# @elinpf/dsh-ops-access-gate
|
|
2
|
+
|
|
3
|
+
DeepSeek Harness 运维模式的按会话凭据代理(credential brokering)与人工审批 — 拥有授权台账,在每次凭据解析时裁决 ro/rw/deny。
|
|
4
|
+
|
|
5
|
+
## 功能
|
|
6
|
+
|
|
7
|
+
闸门位于 ops-access 注册表(`@elinpf/dsh-ops-access`)与每一次凭据解析之间:它向该 seam 注册一个**纯决策 broker**——调用会话持有该档案的未过期授权时答 `rw`,否则答 `ro`;无授权访问审批必需类型(ssh)或档案被运维封禁时答 `deny`。
|
|
8
|
+
|
|
9
|
+
- **`request_access` 工具** — 模型申请限时授权;调用驻留在待决请求队列中,直到人在授权面板里裁决;结果报告实际授予的 TTL(人可下调)。
|
|
10
|
+
- **授权面板后端** — 9 条 HTTP 路由(`/ops-access/grants*`、`/ops-access/access-requests*`、`/ops-access/deny|undeny`)加 `/access`、`/access-all` 两个斜杠命令。面板授权与请求授权同形同寿命。
|
|
11
|
+
- **审计日志** — 每个请求、裁决、授权、过期、收回、封禁和台账重置都落追加式 JSONL 文件。
|
|
12
|
+
- **封禁(lockdown)** — 运维 deny 持久化到 `deniedFile`,重启不丢(重启后悄悄解除的事件冻结不算冻结)。
|
|
13
|
+
|
|
14
|
+
## 设计要点
|
|
15
|
+
|
|
16
|
+
- **闸门永远看不到凭据字段。** 类型、档案名、会话 id 就是它的全部世界——密钥材料不可能经授权路径泄漏。
|
|
17
|
+
- **按会话键控的台账,惰性过期。** preset 平面实例共享,授权按 `agent.id` 键控;过期授权在首次被查询时驱逐(web 会话没有可靠的结束事件——TTL 是唯一可靠边界)。
|
|
18
|
+
- **授权面板就是审批通道**(ADR-0004),不用 dsh 原生审批:原生结果词汇表带不了人调整过的 TTL。无头部署(无 web server)下 `request_access` 快速失败并给出带外指引。
|
|
19
|
+
- **types.ts 是纯类型**(零运行时);`src/invariant.ts` 是 invariant 伴随插件——只预留包归属、不安装任何检查,因为闸门不拥有任何 session 事件形态(其状态是进程内台账加 JSONL 审计文件)。
|
|
20
|
+
- **每个注册都是 effect**,因此 fiber 销毁 / HMR 卸载会移除工具、两个命令、全部路由、通知监听器、broker 和 `opsAccessGate` 服务,并把驻留请求按 cancelled 了结。
|
|
21
|
+
|
|
22
|
+
## 配置项
|
|
23
|
+
|
|
24
|
+
| 键 | 默认值 | 含义 |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| `approvalRequiredKinds` | `['ssh']` | 无 ro 层的类型——任何使用都需授权 |
|
|
27
|
+
| `defaultTtlMinutes` | `30` | `request_access` 省略 `ttlMinutes` 时的授权时长 |
|
|
28
|
+
| `maxTtlMinutes` | `480` | 申请授权时长的上限 |
|
|
29
|
+
| `auditFile` | `~/.dsh-ops/audit.log` | JSONL 审计日志路径(`~` 展开) |
|
|
30
|
+
| `grantTtlOptions` | `[5, 10, 30]` | 授权面板提供的 TTL 选项 |
|
|
31
|
+
| `pendingRequestTimeoutMinutes` | `5` | 驻留请求等待人工裁决的超时(超时自动拒绝) |
|
|
32
|
+
| `deniedFile` | `~/.dsh-ops/denied.json` | 持久化封禁状态(重启保留) |
|
|
33
|
+
|
|
34
|
+
## 测试
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
npm run build
|
|
38
|
+
npx vitest run
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
测试把闸门与真实的 ops-access core 一起挂载到 mock cordis 上下文(真实临时注册表与审计文件),驱动外部可观察的 seam:resolve 按台账给 ro/rw、`request_access` 驻留直到 decide 路由了结、面板路由直接授权/收回、TTL 过期与通知行为、每次转移都有审计。HMR 卸载套件销毁闸门 fiber 并断言每个注册面(工具、命令、路由、监听器、broker、服务)都被移除,同时 core 保持挂载。
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[]
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ops access gate — per-session credential brokering with human approval.
|
|
3
|
+
*
|
|
4
|
+
* This plugin owns the **authorization ledger**: an in-process map keyed by
|
|
5
|
+
* session id (exec.agent.id). It registers a **pure-decision broker** into
|
|
6
|
+
* the ops-access seam via registerAccessBroker; that broker answers 'rw'
|
|
7
|
+
* when the calling session holds an unexpired grant for the profile, 'ro'
|
|
8
|
+
* otherwise, and { deny } for approval-required kinds (ssh) without a grant.
|
|
9
|
+
* Calls without an agent (system-internal) are ruled here too: tiered kinds
|
|
10
|
+
* fail closed to ro, approval-required kinds deny outright. The gate never
|
|
11
|
+
* sees credential fields — kind, profile name, and session id are its world.
|
|
12
|
+
*
|
|
13
|
+
* Human interaction runs through the **access panel** (ADR-0004), not dsh's
|
|
14
|
+
* native approval channel: the native outcome vocabulary (allowed-once /
|
|
15
|
+
* rejected / cancelled / unavailable) cannot carry a human-adjusted TTL, and
|
|
16
|
+
* answerer ordering is not a priority mechanism. Two flows share one queue:
|
|
17
|
+
*
|
|
18
|
+
* - **Request flow**: the model calls request_access, the gate parks the tool
|
|
19
|
+
* promise in the **pending-request queue**, the panel's decide route
|
|
20
|
+
* settles it (the human may adjust the TTL to any configured option), and
|
|
21
|
+
* the tool result reports the TTL actually granted.
|
|
22
|
+
* - **Panel flow**: the human opens the panel with /access and grants or
|
|
23
|
+
* revokes directly through the routes below. Panel grants are identical in
|
|
24
|
+
* shape and lifetime to request grants — only the origin differs.
|
|
25
|
+
*
|
|
26
|
+
* Grants carry a TTL — expiry is the only reliable fallback boundary (a web
|
|
27
|
+
* session has no dependable end event). Panel actions notify the agent by
|
|
28
|
+
* queueing a model-visible message drained at the next agent/pre-step (the
|
|
29
|
+
* command surface never enters model history). Every request, decision,
|
|
30
|
+
* grant, expiry, revoke, elevated issue, and ledger reset lands in a JSONL
|
|
31
|
+
* audit file. Headless deployments (no webServer) fail request_access fast
|
|
32
|
+
* with out-of-band guidance.
|
|
33
|
+
*
|
|
34
|
+
* @module @elinpf/dsh-ops-access-gate
|
|
35
|
+
*/
|
|
36
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
37
|
+
import z from '@deepseek-ai/schemastery';
|
|
38
|
+
import type { Config as ConfigShape, DeniedEntry } from './types.js';
|
|
39
|
+
export type { ActiveGrant, DeniedEntry, Grant, OpsAccessGate, PendingRequest, RequestDecision } from './types.js';
|
|
40
|
+
export declare const name = "ops-access-gate";
|
|
41
|
+
export declare const inject: string[];
|
|
42
|
+
export interface Config extends ConfigShape {
|
|
43
|
+
}
|
|
44
|
+
export declare const Config: z<Config>;
|
|
45
|
+
/** Load persisted lockdowns. A missing or corrupt file yields an empty set — the audit log records what happened. */
|
|
46
|
+
export declare function loadDenied(file: string): DeniedEntry[];
|
|
47
|
+
/** Persist lockdowns synchronously (same crash-ordering discipline as the audit log). A failure shouts, never throws. */
|
|
48
|
+
export declare function saveDenied(file: string, entries: DeniedEntry[]): void;
|
|
49
|
+
export declare function apply(ctx: Context, config: Config): void;
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,861 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ops access gate — per-session credential brokering with human approval.
|
|
3
|
+
*
|
|
4
|
+
* This plugin owns the **authorization ledger**: an in-process map keyed by
|
|
5
|
+
* session id (exec.agent.id). It registers a **pure-decision broker** into
|
|
6
|
+
* the ops-access seam via registerAccessBroker; that broker answers 'rw'
|
|
7
|
+
* when the calling session holds an unexpired grant for the profile, 'ro'
|
|
8
|
+
* otherwise, and { deny } for approval-required kinds (ssh) without a grant.
|
|
9
|
+
* Calls without an agent (system-internal) are ruled here too: tiered kinds
|
|
10
|
+
* fail closed to ro, approval-required kinds deny outright. The gate never
|
|
11
|
+
* sees credential fields — kind, profile name, and session id are its world.
|
|
12
|
+
*
|
|
13
|
+
* Human interaction runs through the **access panel** (ADR-0004), not dsh's
|
|
14
|
+
* native approval channel: the native outcome vocabulary (allowed-once /
|
|
15
|
+
* rejected / cancelled / unavailable) cannot carry a human-adjusted TTL, and
|
|
16
|
+
* answerer ordering is not a priority mechanism. Two flows share one queue:
|
|
17
|
+
*
|
|
18
|
+
* - **Request flow**: the model calls request_access, the gate parks the tool
|
|
19
|
+
* promise in the **pending-request queue**, the panel's decide route
|
|
20
|
+
* settles it (the human may adjust the TTL to any configured option), and
|
|
21
|
+
* the tool result reports the TTL actually granted.
|
|
22
|
+
* - **Panel flow**: the human opens the panel with /access and grants or
|
|
23
|
+
* revokes directly through the routes below. Panel grants are identical in
|
|
24
|
+
* shape and lifetime to request grants — only the origin differs.
|
|
25
|
+
*
|
|
26
|
+
* Grants carry a TTL — expiry is the only reliable fallback boundary (a web
|
|
27
|
+
* session has no dependable end event). Panel actions notify the agent by
|
|
28
|
+
* queueing a model-visible message drained at the next agent/pre-step (the
|
|
29
|
+
* command surface never enters model history). Every request, decision,
|
|
30
|
+
* grant, expiry, revoke, elevated issue, and ledger reset lands in a JSONL
|
|
31
|
+
* audit file. Headless deployments (no webServer) fail request_access fast
|
|
32
|
+
* with out-of-band guidance.
|
|
33
|
+
*
|
|
34
|
+
* @module @elinpf/dsh-ops-access-gate
|
|
35
|
+
*/
|
|
36
|
+
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
37
|
+
import { dirname } from 'node:path';
|
|
38
|
+
import z from '@deepseek-ai/schemastery';
|
|
39
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
40
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
41
|
+
import { expandHome, registerAccessBroker } from '@elinpf/dsh-ops-access';
|
|
42
|
+
import { registerPanelCommand } from '@elinpf/dsh-ops-panel';
|
|
43
|
+
// ── Plugin identity ───────────────────────────────────────────────────────────
|
|
44
|
+
export const name = 'ops-access-gate';
|
|
45
|
+
export const inject = ['tools'];
|
|
46
|
+
export const Config = z.object({
|
|
47
|
+
approvalRequiredKinds: z.array(z.string()).default(['ssh']),
|
|
48
|
+
defaultTtlMinutes: z.number().default(30),
|
|
49
|
+
maxTtlMinutes: z.number().default(480),
|
|
50
|
+
auditFile: z.string().default('~/.dsh-ops/audit.log'),
|
|
51
|
+
grantTtlOptions: z.array(z.number()).default([5, 10, 30]),
|
|
52
|
+
pendingRequestTimeoutMinutes: z.number().default(5),
|
|
53
|
+
deniedFile: z.string().default('~/.dsh-ops/denied.json'),
|
|
54
|
+
});
|
|
55
|
+
/**
|
|
56
|
+
* The pending-request queue: an in-process map of parked tool promises.
|
|
57
|
+
* Each entry carries an unref'd timer that settles it as a timeout — an
|
|
58
|
+
* unanswered request never hangs the agent forever. The queue dies with the
|
|
59
|
+
* plugin fiber (cancelAll on dispose), same lifetime as the ledger.
|
|
60
|
+
*/
|
|
61
|
+
function makePendingQueue(now, onSettle) {
|
|
62
|
+
const entries = new Map();
|
|
63
|
+
let counter = 0;
|
|
64
|
+
function finish(id, decision) {
|
|
65
|
+
const entry = entries.get(id);
|
|
66
|
+
if (!entry)
|
|
67
|
+
return undefined;
|
|
68
|
+
entries.delete(id);
|
|
69
|
+
clearTimeout(entry.timer);
|
|
70
|
+
onSettle(entry.req, decision);
|
|
71
|
+
entry.settle(decision);
|
|
72
|
+
return entry.req;
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
/** Park one request; the returned promise settles exactly once. */
|
|
76
|
+
add(input, timeoutMinutes) {
|
|
77
|
+
const createdAt = now();
|
|
78
|
+
const req = {
|
|
79
|
+
...input,
|
|
80
|
+
id: 'gr-' + createdAt.toString(36) + '-' + (++counter),
|
|
81
|
+
createdAt,
|
|
82
|
+
decidesAt: createdAt + timeoutMinutes * 60000,
|
|
83
|
+
};
|
|
84
|
+
let settle;
|
|
85
|
+
const decision = new Promise((resolve) => { settle = resolve; });
|
|
86
|
+
const timer = setTimeout(() => { finish(req.id, { approved: false, outcome: 'timeout' }); }, timeoutMinutes * 60000);
|
|
87
|
+
// Never hold the process (or a test runner) open for a parked request.
|
|
88
|
+
if (typeof timer === 'object' && timer && 'unref' in timer)
|
|
89
|
+
timer.unref();
|
|
90
|
+
entries.set(req.id, { req, settle, timer });
|
|
91
|
+
return { req, decision };
|
|
92
|
+
},
|
|
93
|
+
/** Settle a request by id; undefined when it already settled. */
|
|
94
|
+
decide(id, decision) {
|
|
95
|
+
return finish(id, decision);
|
|
96
|
+
},
|
|
97
|
+
/** Live requests, optionally narrowed to one session. */
|
|
98
|
+
list(session) {
|
|
99
|
+
return [...entries.values()]
|
|
100
|
+
.map((e) => e.req)
|
|
101
|
+
.filter((r) => session === undefined || r.session === session);
|
|
102
|
+
},
|
|
103
|
+
/** Settle every parked request (plugin disposal): outcome 'cancelled'. */
|
|
104
|
+
cancelAll() {
|
|
105
|
+
for (const id of [...entries.keys()])
|
|
106
|
+
finish(id, { approved: false, outcome: 'cancelled' });
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Append-only JSONL audit sink. Synchronous so a grant and its audit line can
|
|
112
|
+
* never be reordered by a crash in between, and so tests read deterministic
|
|
113
|
+
* output. A write failure must not break authorization — but an audit gap
|
|
114
|
+
* matters, so it shouts on the server log.
|
|
115
|
+
*/
|
|
116
|
+
function makeAudit(file) {
|
|
117
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
118
|
+
return (e) => {
|
|
119
|
+
try {
|
|
120
|
+
appendFileSync(file, JSON.stringify({ ts: new Date().toISOString(), ...e }) + '\n');
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
console.error('ops-access-gate: audit write failed for ' + file + ' (authorization continues, audit has a gap):', err);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/** Load persisted lockdowns. A missing or corrupt file yields an empty set — the audit log records what happened. */
|
|
128
|
+
export function loadDenied(file) {
|
|
129
|
+
let raw;
|
|
130
|
+
try {
|
|
131
|
+
raw = JSON.parse(readFileSync(file, 'utf8'));
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return [];
|
|
135
|
+
}
|
|
136
|
+
if (!Array.isArray(raw.entries))
|
|
137
|
+
return [];
|
|
138
|
+
return raw.entries.filter((e) => typeof e === 'object' && e !== null
|
|
139
|
+
&& typeof e.kind === 'string'
|
|
140
|
+
&& typeof e.name === 'string'
|
|
141
|
+
&& typeof e.deniedAt === 'number'
|
|
142
|
+
&& typeof e.reason === 'string'
|
|
143
|
+
&& typeof e.deniedBy === 'string');
|
|
144
|
+
}
|
|
145
|
+
/** Persist lockdowns synchronously (same crash-ordering discipline as the audit log). A failure shouts, never throws. */
|
|
146
|
+
export function saveDenied(file, entries) {
|
|
147
|
+
try {
|
|
148
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
149
|
+
writeFileSync(file, JSON.stringify({ version: 1, entries }, null, 2) + '\n', 'utf8');
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
console.error('ops-access-gate: failed to persist lockdowns to ' + file + ' (the in-memory state still holds):', err);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/** Stable key for a (kind, name) pair within one session's grant set. */
|
|
156
|
+
function grantKey(kind, name) {
|
|
157
|
+
return kind + ' ' + name;
|
|
158
|
+
}
|
|
159
|
+
function makeGate(ledger, audit, now, deniedStore = {}) {
|
|
160
|
+
const denied = new Map();
|
|
161
|
+
for (const d of deniedStore.initial ?? [])
|
|
162
|
+
denied.set(grantKey(d.kind, d.name), d);
|
|
163
|
+
function persistDenied() { deniedStore.onChange?.([...denied.values()]); }
|
|
164
|
+
/** Fetch a grant, evicting + audit-logging it when lapsed. */
|
|
165
|
+
function live(session, kind, name) {
|
|
166
|
+
const grant = ledger.get(session)?.get(grantKey(kind, name));
|
|
167
|
+
if (!grant)
|
|
168
|
+
return undefined;
|
|
169
|
+
if (grant.expiresAt <= now()) {
|
|
170
|
+
ledger.get(session).delete(grantKey(kind, name));
|
|
171
|
+
audit({ event: 'expire', session, kind, name });
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
return grant;
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
authorize(grant) {
|
|
178
|
+
let set = ledger.get(grant.session);
|
|
179
|
+
if (!set)
|
|
180
|
+
ledger.set(grant.session, set = new Map());
|
|
181
|
+
set.set(grantKey(grant.kind, grant.name), grant);
|
|
182
|
+
},
|
|
183
|
+
isAuthorized(session, kind, name) {
|
|
184
|
+
return live(session, kind, name) !== undefined;
|
|
185
|
+
},
|
|
186
|
+
revoke(session, kind, name) {
|
|
187
|
+
return ledger.get(session)?.delete(grantKey(kind, name)) ?? false;
|
|
188
|
+
},
|
|
189
|
+
listAll() {
|
|
190
|
+
const out = [];
|
|
191
|
+
for (const [session, set] of ledger) {
|
|
192
|
+
for (const grant of set.values()) {
|
|
193
|
+
if (live(session, grant.kind, grant.name) === undefined)
|
|
194
|
+
continue;
|
|
195
|
+
out.push({ session, kind: grant.kind, name: grant.name, expiresAt: grant.expiresAt, reason: grant.reason, approvedBy: grant.approvedBy });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
},
|
|
200
|
+
list(session) {
|
|
201
|
+
const out = [];
|
|
202
|
+
for (const grant of ledger.get(session)?.values() ?? []) {
|
|
203
|
+
if (live(session, grant.kind, grant.name) === undefined)
|
|
204
|
+
continue;
|
|
205
|
+
out.push({ kind: grant.kind, name: grant.name, expiresAt: grant.expiresAt, reason: grant.reason, approvedBy: grant.approvedBy });
|
|
206
|
+
}
|
|
207
|
+
return out;
|
|
208
|
+
},
|
|
209
|
+
deny(kind, name, reason, deniedBy) {
|
|
210
|
+
denied.set(grantKey(kind, name), { kind, name, deniedAt: now(), reason, deniedBy });
|
|
211
|
+
persistDenied();
|
|
212
|
+
audit({ event: 'deny', kind, name, reason, approvedBy: deniedBy });
|
|
213
|
+
// A lockdown kills live elevation everywhere, not just future
|
|
214
|
+
// resolves — revoke every session's grant for this profile.
|
|
215
|
+
const affected = [];
|
|
216
|
+
for (const [session, set] of ledger) {
|
|
217
|
+
if (set.delete(grantKey(kind, name))) {
|
|
218
|
+
affected.push(session);
|
|
219
|
+
audit({ event: 'revoke', session, kind, name, source: 'deny' });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return affected;
|
|
223
|
+
},
|
|
224
|
+
undeny(kind, name) {
|
|
225
|
+
if (!denied.delete(grantKey(kind, name)))
|
|
226
|
+
return false;
|
|
227
|
+
persistDenied();
|
|
228
|
+
audit({ event: 'undeny', kind, name });
|
|
229
|
+
return true;
|
|
230
|
+
},
|
|
231
|
+
isDenied(kind, name) {
|
|
232
|
+
return denied.has(grantKey(kind, name));
|
|
233
|
+
},
|
|
234
|
+
listDenied() {
|
|
235
|
+
return [...denied.values()].sort((a, b) => a.deniedAt - b.deniedAt);
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
// ── HTTP helpers (copies of core's tiny helpers — core does not export them) ─
|
|
240
|
+
function readRequestBody(req) {
|
|
241
|
+
return new Promise((resolve, reject) => {
|
|
242
|
+
let body = '';
|
|
243
|
+
req.on('data', (chunk) => { body += chunk; });
|
|
244
|
+
req.on('end', () => resolve(body));
|
|
245
|
+
req.on('error', () => reject(new Error('failed to read request body')));
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
function sendJson(res, status, value) {
|
|
249
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
250
|
+
res.end(JSON.stringify(value));
|
|
251
|
+
}
|
|
252
|
+
function sendJsonError(res, status, err) {
|
|
253
|
+
sendJson(res, status, { ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
254
|
+
}
|
|
255
|
+
// ── request_access tool ──────────────────────────────────────────────────────
|
|
256
|
+
const REQUEST_ACCESS = 'request_access';
|
|
257
|
+
/** Split "kind/name" on the FIRST slash — profile names may themselves contain '@' etc. */
|
|
258
|
+
function parseProfile(raw) {
|
|
259
|
+
if (typeof raw !== 'string')
|
|
260
|
+
return undefined;
|
|
261
|
+
const slash = raw.indexOf('/');
|
|
262
|
+
if (slash <= 0 || slash === raw.length - 1)
|
|
263
|
+
return undefined;
|
|
264
|
+
return { kind: raw.slice(0, slash), profileName: raw.slice(slash + 1) };
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Structural read of the caller's dispatching session (sub-agent lineage).
|
|
268
|
+
* dsh records it on the session header as `parentSession`; AccessAgent stays
|
|
269
|
+
* narrow ({ id }) on purpose, so the gate reads the rest structurally and
|
|
270
|
+
* tolerates runtimes that expose nothing.
|
|
271
|
+
*/
|
|
272
|
+
function parentSessionOf(agent) {
|
|
273
|
+
const header = agent?.session?.header;
|
|
274
|
+
return typeof header?.parentSession === 'string' && header.parentSession !== '' ? header.parentSession : undefined;
|
|
275
|
+
}
|
|
276
|
+
// ── Plugin apply ─────────────────────────────────────────────────────────────
|
|
277
|
+
export function apply(ctx, config) {
|
|
278
|
+
const audit = makeAudit(expandHome(config.auditFile));
|
|
279
|
+
const ledger = new Map();
|
|
280
|
+
// Lockdowns persist (ticket 12): an incident freeze must survive a restart.
|
|
281
|
+
const deniedFile = expandHome(config.deniedFile);
|
|
282
|
+
const gate = makeGate(ledger, audit, () => Date.now(), {
|
|
283
|
+
initial: loadDenied(deniedFile),
|
|
284
|
+
onChange: (entries) => saveDenied(deniedFile, entries),
|
|
285
|
+
});
|
|
286
|
+
ctx.provide('opsAccessGate', gate);
|
|
287
|
+
/** Per-session model-visible notices from panel actions, drained at pre-step. */
|
|
288
|
+
const notices = new Map();
|
|
289
|
+
function queueNotice(session, text) {
|
|
290
|
+
const list = notices.get(session);
|
|
291
|
+
if (list)
|
|
292
|
+
list.push(text);
|
|
293
|
+
else
|
|
294
|
+
notices.set(session, [text]);
|
|
295
|
+
}
|
|
296
|
+
const pending = makePendingQueue(() => Date.now(), (req, decision) => audit({
|
|
297
|
+
event: 'request-decide',
|
|
298
|
+
session: req.session,
|
|
299
|
+
kind: req.kind,
|
|
300
|
+
name: req.name,
|
|
301
|
+
outcome: decision.approved ? 'approved' : decision.outcome,
|
|
302
|
+
...(decision.approved ? { ttlMinutes: decision.ttlMinutes } : {}),
|
|
303
|
+
}));
|
|
304
|
+
// The queue shares the ledger's lifetime: plugin disposal (HMR, shutdown)
|
|
305
|
+
// settles every parked request as cancelled instead of leaking promises.
|
|
306
|
+
ctx.effect(() => () => pending.cancelAll());
|
|
307
|
+
// Set by the webServer block below; false in headless deployments, where
|
|
308
|
+
// request_access must fail fast with out-of-band guidance.
|
|
309
|
+
let humanChannelAvailable = false;
|
|
310
|
+
// apply runs on boot and on every HMR reload — both start a fresh, empty
|
|
311
|
+
// ledger. Record the reset so an auditor can tell "grants were cleared" from
|
|
312
|
+
// "nothing was ever granted".
|
|
313
|
+
audit({ event: 'ledger-reset' });
|
|
314
|
+
// The broker is a pure decision function consulted on EVERY resolve once
|
|
315
|
+
// registered — including calls without an agent. The no-agent ruling is
|
|
316
|
+
// policy and lives here, not in core: tiered kinds fail closed to ro (rw is
|
|
317
|
+
// never issued without a session to key the grant on); approval-required
|
|
318
|
+
// kinds deny outright — their credential is effectively rw (there is no
|
|
319
|
+
// read-only shell), so an untracked caller must not get it at all.
|
|
320
|
+
const broker = (kind, profileName, agent) => {
|
|
321
|
+
// Deny (ticket 12) outranks every other state: a locked profile refuses
|
|
322
|
+
// even ro, for session and internal callers alike.
|
|
323
|
+
if (gate.isDenied(kind, profileName)) {
|
|
324
|
+
audit({ event: 'deny-block', ...(agent ? { session: agent.id } : {}), kind, name: profileName });
|
|
325
|
+
return { deny: kind + '/' + profileName + ' is locked by the operator — even read-only access is refused until the lockdown is lifted' };
|
|
326
|
+
}
|
|
327
|
+
if (!agent) {
|
|
328
|
+
if (config.approvalRequiredKinds.includes(kind)) {
|
|
329
|
+
return { deny: kind + ' requires an approved grant, and grants need a session — internal calls without one cannot use it. Call it from a session and request timed access via the ' + REQUEST_ACCESS + ' tool' };
|
|
330
|
+
}
|
|
331
|
+
return 'ro';
|
|
332
|
+
}
|
|
333
|
+
const authorized = gate.isAuthorized(agent.id, kind, profileName);
|
|
334
|
+
// Approval-required kinds (ssh): the credential lives in the ro registry —
|
|
335
|
+
// the grant is a timed pass to use it at all.
|
|
336
|
+
if (config.approvalRequiredKinds.includes(kind)) {
|
|
337
|
+
if (authorized) {
|
|
338
|
+
audit({ event: 'gated-issue', session: agent.id, parentSession: parentSessionOf(agent), kind, name: profileName });
|
|
339
|
+
return 'ro';
|
|
340
|
+
}
|
|
341
|
+
return { deny: kind + ' has no read-only tier; request timed access via the ' + REQUEST_ACCESS + ' tool (profile "' + kind + '/' + profileName + '", with a reason)' };
|
|
342
|
+
}
|
|
343
|
+
if (authorized) {
|
|
344
|
+
audit({ event: 'rw-issue', session: agent.id, parentSession: parentSessionOf(agent), kind, name: profileName });
|
|
345
|
+
return 'rw';
|
|
346
|
+
}
|
|
347
|
+
return 'ro';
|
|
348
|
+
};
|
|
349
|
+
registerAccessBroker(ctx, broker);
|
|
350
|
+
// The /access slash command opens the access panel (ops-panel seam). The
|
|
351
|
+
// command is agent-scoped: it exists only in presets that mount this plugin.
|
|
352
|
+
// Registered via deferred inject — deployments without the commands service
|
|
353
|
+
// (headless) simply never get the command instead of failing to load.
|
|
354
|
+
ctx.inject(['commands'], (commandCtx) => {
|
|
355
|
+
registerPanelCommand(commandCtx, { name: 'access', description: '打开授权面板 — 授予 / 调整 / 收回本会话的提权访问' });
|
|
356
|
+
registerPanelCommand(commandCtx, { name: 'access-all', description: '打开授权总览 — 跨会话查看与收回提权授权(含待决申请与封禁列表)' });
|
|
357
|
+
});
|
|
358
|
+
// ── Human-side routes (the access panel's backend) ─────────────────────────
|
|
359
|
+
// Preset-plane registration of host webServer routes, same discipline as
|
|
360
|
+
// core's /ops-access/list: the route lives next to the data, plain HTTP, no
|
|
361
|
+
// TypertRemoteService (dual-module-instance lesson). The webServer matches
|
|
362
|
+
// by path only, so handlers dispatch on req.method.
|
|
363
|
+
ctx.inject(['webServer'], (wctx) => {
|
|
364
|
+
wctx.effect(() => {
|
|
365
|
+
humanChannelAvailable = true;
|
|
366
|
+
return () => { humanChannelAvailable = false; };
|
|
367
|
+
});
|
|
368
|
+
const ws = wctx;
|
|
369
|
+
/** Shared deliverability check: the tier a grant would issue must resolve. */
|
|
370
|
+
async function checkDeliverable(kind, profileName) {
|
|
371
|
+
const opsAccess = ctx.get('opsAccess');
|
|
372
|
+
if (!opsAccess)
|
|
373
|
+
return 'ops-access service unavailable — is the ops-access plugin mounted in this preset?';
|
|
374
|
+
if (config.approvalRequiredKinds.includes(kind)) {
|
|
375
|
+
return (await opsAccess.canResolve(kind, profileName, 'ro')).ok
|
|
376
|
+
? null
|
|
377
|
+
: 'no resolvable profile "' + kind + '/' + profileName + '" in the access registry';
|
|
378
|
+
}
|
|
379
|
+
return (await opsAccess.canResolve(kind, profileName, 'rw')).ok
|
|
380
|
+
? null
|
|
381
|
+
: kind + '/' + profileName + ' has no usable rw tier registered, so a grant could not be fulfilled';
|
|
382
|
+
}
|
|
383
|
+
/** Validate a TTL choice against the configured panel options. */
|
|
384
|
+
function ttlOptionError(raw) {
|
|
385
|
+
if (typeof raw !== 'number' || !config.grantTtlOptions.includes(raw))
|
|
386
|
+
return null;
|
|
387
|
+
return raw;
|
|
388
|
+
}
|
|
389
|
+
const ttlOptionsMessage = 'ttlMinutes must be one of the configured options: ' + config.grantTtlOptions.join(', ');
|
|
390
|
+
// GET /ops-access/grants?session=<id> — the session's live grants.
|
|
391
|
+
// POST /ops-access/grants — a panel grant {session, kind, name, ttlMinutes, reason?}.
|
|
392
|
+
wctx.effect(() => ws.webServer.register({
|
|
393
|
+
kind: 'exact',
|
|
394
|
+
path: '/ops-access/grants',
|
|
395
|
+
handler: async (req, res) => {
|
|
396
|
+
try {
|
|
397
|
+
if (req.method === 'GET') {
|
|
398
|
+
const session = new URL(req.url, 'http://localhost').searchParams.get('session') ?? '';
|
|
399
|
+
if (session === '') {
|
|
400
|
+
sendJsonError(res, 400, new Error('session query parameter is required'));
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
const grants = gate.list(session).map((g) => ({
|
|
404
|
+
...g,
|
|
405
|
+
remainingMinutes: Math.max(0, Math.round((g.expiresAt - Date.now()) / 60000)),
|
|
406
|
+
}));
|
|
407
|
+
// The TTL choices ride along so the panel never hardcodes them.
|
|
408
|
+
sendJson(res, 200, { ok: true, grants, ttlOptions: config.grantTtlOptions, denied: gate.listDenied() });
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
if (req.method !== 'POST') {
|
|
412
|
+
sendJsonError(res, 405, new Error('method not allowed'));
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
const body = JSON.parse(await readRequestBody(req));
|
|
416
|
+
const { session, kind, name } = body;
|
|
417
|
+
if (typeof session !== 'string' || session === '' || typeof kind !== 'string' || kind === '' || typeof name !== 'string' || name === '') {
|
|
418
|
+
sendJsonError(res, 400, new Error('session, kind, and name (non-empty strings) are required'));
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
const ttl = ttlOptionError(body.ttlMinutes);
|
|
422
|
+
if (ttl === null) {
|
|
423
|
+
sendJsonError(res, 400, new Error(ttlOptionsMessage));
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
if (gate.isDenied(kind, name)) {
|
|
427
|
+
sendJsonError(res, 400, new Error(kind + '/' + name + ' is locked (deny) — lift the lockdown first'));
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
const undeliverable = await checkDeliverable(kind, name);
|
|
431
|
+
if (undeliverable !== null) {
|
|
432
|
+
sendJsonError(res, 400, new Error(undeliverable));
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const reason = typeof body.reason === 'string' && body.reason.trim() !== '' ? body.reason.trim() : 'operator panel grant';
|
|
436
|
+
const expiresAt = Date.now() + ttl * 60000;
|
|
437
|
+
gate.authorize({ session, kind, name, expiresAt, reason, approvedBy: 'panel' });
|
|
438
|
+
audit({ event: 'grant', session, kind, name, reason, approvedBy: 'panel', expiresAt, ttlMinutes: ttl });
|
|
439
|
+
const approvalRequired = config.approvalRequiredKinds.includes(kind);
|
|
440
|
+
queueNotice(session, '<access-grant>运维通过授权面板授予本会话 ' + kind + '/' + name
|
|
441
|
+
+ (approvalRequired ? ' 的限时使用权限' : ' 的 rw 权限')
|
|
442
|
+
+ ',至 ' + new Date(expiresAt).toISOString() + '(' + ttl + ' 分钟)</access-grant>');
|
|
443
|
+
sendJson(res, 200, { ok: true, expiresAt });
|
|
444
|
+
}
|
|
445
|
+
catch (err) {
|
|
446
|
+
sendJsonError(res, 500, err);
|
|
447
|
+
}
|
|
448
|
+
},
|
|
449
|
+
}));
|
|
450
|
+
// POST /ops-access/grants/revoke — drop one grant {session, kind, name}.
|
|
451
|
+
wctx.effect(() => ws.webServer.register({
|
|
452
|
+
kind: 'exact',
|
|
453
|
+
path: '/ops-access/grants/revoke',
|
|
454
|
+
handler: async (req, res) => {
|
|
455
|
+
try {
|
|
456
|
+
if (req.method !== 'POST') {
|
|
457
|
+
sendJsonError(res, 405, new Error('method not allowed'));
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
const body = JSON.parse(await readRequestBody(req));
|
|
461
|
+
const { session, kind, name } = body;
|
|
462
|
+
if (typeof session !== 'string' || session === '' || typeof kind !== 'string' || kind === '' || typeof name !== 'string' || name === '') {
|
|
463
|
+
sendJsonError(res, 400, new Error('session, kind, and name (non-empty strings) are required'));
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
const removed = gate.revoke(session, kind, name);
|
|
467
|
+
if (!removed) {
|
|
468
|
+
sendJsonError(res, 400, new Error('no active grant for ' + kind + '/' + name + ' in this session'));
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
audit({ event: 'revoke', session, kind, name, source: 'panel' });
|
|
472
|
+
queueNotice(session, '<access-revoked>运维收回了本会话 ' + kind + '/' + name + ' 的提权权限,已回落只读</access-revoked>');
|
|
473
|
+
sendJson(res, 200, { ok: true });
|
|
474
|
+
}
|
|
475
|
+
catch (err) {
|
|
476
|
+
sendJsonError(res, 500, err);
|
|
477
|
+
}
|
|
478
|
+
},
|
|
479
|
+
}));
|
|
480
|
+
// POST /ops-access/grants/extend — renew one active grant {session, kind, name, ttlMinutes}.
|
|
481
|
+
wctx.effect(() => ws.webServer.register({
|
|
482
|
+
kind: 'exact',
|
|
483
|
+
path: '/ops-access/grants/extend',
|
|
484
|
+
handler: async (req, res) => {
|
|
485
|
+
try {
|
|
486
|
+
if (req.method !== 'POST') {
|
|
487
|
+
sendJsonError(res, 405, new Error('method not allowed'));
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
const body = JSON.parse(await readRequestBody(req));
|
|
491
|
+
const { session, kind, name } = body;
|
|
492
|
+
if (typeof session !== 'string' || session === '' || typeof kind !== 'string' || kind === '' || typeof name !== 'string' || name === '') {
|
|
493
|
+
sendJsonError(res, 400, new Error('session, kind, and name (non-empty strings) are required'));
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
const ttl = ttlOptionError(body.ttlMinutes);
|
|
497
|
+
if (ttl === null) {
|
|
498
|
+
sendJsonError(res, 400, new Error(ttlOptionsMessage));
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
// gate.list filters expired grants — an expired grant cannot be
|
|
502
|
+
// extended; grant it anew instead.
|
|
503
|
+
const active = gate.list(session).find((g) => g.kind === kind && g.name === name);
|
|
504
|
+
if (!active) {
|
|
505
|
+
sendJsonError(res, 400, new Error('no active grant for ' + kind + '/' + name + ' in this session — expired grants cannot be extended'));
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
// Renew from NOW, not from the old expiry: repeated extends must
|
|
509
|
+
// never accumulate past one TTL tier — the ceiling discipline holds.
|
|
510
|
+
const expiresAt = Date.now() + ttl * 60000;
|
|
511
|
+
gate.authorize({ session, kind, name, expiresAt, reason: active.reason, approvedBy: active.approvedBy });
|
|
512
|
+
audit({ event: 'grant-extend', session, kind, name, expiresAt, ttlMinutes: ttl, previousExpiresAt: active.expiresAt });
|
|
513
|
+
queueNotice(session, '<access-grant>运维延长了本会话 ' + kind + '/' + name + ' 的授权,新到期时间 '
|
|
514
|
+
+ new Date(expiresAt).toISOString() + '(' + ttl + ' 分钟)</access-grant>');
|
|
515
|
+
sendJson(res, 200, { ok: true, expiresAt });
|
|
516
|
+
}
|
|
517
|
+
catch (err) {
|
|
518
|
+
sendJsonError(res, 500, err);
|
|
519
|
+
}
|
|
520
|
+
},
|
|
521
|
+
}));
|
|
522
|
+
// GET /ops-access/grants/all — every session's live grants + parked requests + lockdowns (ticket 13's overview).
|
|
523
|
+
// Revocation reuses the per-session routes; request decisions reuse the decide route.
|
|
524
|
+
// The TTL choices ride along so the overview can approve with an adjusted lifetime.
|
|
525
|
+
wctx.effect(() => ws.webServer.register({
|
|
526
|
+
kind: 'exact',
|
|
527
|
+
path: '/ops-access/grants/all',
|
|
528
|
+
handler: async (req, res) => {
|
|
529
|
+
try {
|
|
530
|
+
if (req.method !== 'GET') {
|
|
531
|
+
sendJsonError(res, 405, new Error('method not allowed'));
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
const grants = gate.listAll().map((g) => ({
|
|
535
|
+
...g,
|
|
536
|
+
remainingMinutes: Math.max(0, Math.round((g.expiresAt - Date.now()) / 60000)),
|
|
537
|
+
}));
|
|
538
|
+
sendJson(res, 200, { ok: true, grants, requests: pending.list(), denied: gate.listDenied(), ttlOptions: config.grantTtlOptions });
|
|
539
|
+
}
|
|
540
|
+
catch (err) {
|
|
541
|
+
sendJsonError(res, 500, err);
|
|
542
|
+
}
|
|
543
|
+
},
|
|
544
|
+
}));
|
|
545
|
+
// POST /ops-access/deny — lock a profile outright {kind, name, reason?}:
|
|
546
|
+
// even ro is refused, and its live grants die in every session.
|
|
547
|
+
wctx.effect(() => ws.webServer.register({
|
|
548
|
+
kind: 'exact',
|
|
549
|
+
path: '/ops-access/deny',
|
|
550
|
+
handler: async (req, res) => {
|
|
551
|
+
try {
|
|
552
|
+
if (req.method !== 'POST') {
|
|
553
|
+
sendJsonError(res, 405, new Error('method not allowed'));
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
const body = JSON.parse(await readRequestBody(req));
|
|
557
|
+
const { kind, name } = body;
|
|
558
|
+
if (typeof kind !== 'string' || kind === '' || typeof name !== 'string' || name === '') {
|
|
559
|
+
sendJsonError(res, 400, new Error('kind and name (non-empty strings) are required'));
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
const reason = typeof body.reason === 'string' && body.reason.trim() !== '' ? body.reason.trim() : 'operator lockdown';
|
|
563
|
+
const affected = gate.deny(kind, name, reason, 'panel');
|
|
564
|
+
for (const session of affected) {
|
|
565
|
+
queueNotice(session, '<access-revoked>运维封禁了 ' + kind + '/' + name + '(' + reason + '):本会话的相关授权已收回,该档案连只读访问也已锁定</access-revoked>');
|
|
566
|
+
}
|
|
567
|
+
sendJson(res, 200, { ok: true, revokedSessions: affected.length });
|
|
568
|
+
}
|
|
569
|
+
catch (err) {
|
|
570
|
+
sendJsonError(res, 500, err);
|
|
571
|
+
}
|
|
572
|
+
},
|
|
573
|
+
}));
|
|
574
|
+
// POST /ops-access/undeny — lift a lockdown {kind, name}.
|
|
575
|
+
wctx.effect(() => ws.webServer.register({
|
|
576
|
+
kind: 'exact',
|
|
577
|
+
path: '/ops-access/undeny',
|
|
578
|
+
handler: async (req, res) => {
|
|
579
|
+
try {
|
|
580
|
+
if (req.method !== 'POST') {
|
|
581
|
+
sendJsonError(res, 405, new Error('method not allowed'));
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
const body = JSON.parse(await readRequestBody(req));
|
|
585
|
+
const { kind, name } = body;
|
|
586
|
+
if (typeof kind !== 'string' || kind === '' || typeof name !== 'string' || name === '') {
|
|
587
|
+
sendJsonError(res, 400, new Error('kind and name (non-empty strings) are required'));
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
if (!gate.undeny(kind, name)) {
|
|
591
|
+
sendJsonError(res, 400, new Error(kind + '/' + name + ' is not locked'));
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
sendJson(res, 200, { ok: true });
|
|
595
|
+
}
|
|
596
|
+
catch (err) {
|
|
597
|
+
sendJsonError(res, 500, err);
|
|
598
|
+
}
|
|
599
|
+
},
|
|
600
|
+
}));
|
|
601
|
+
// POST /ops-access/grants/revoke-all — drop every grant of one session.
|
|
602
|
+
wctx.effect(() => ws.webServer.register({
|
|
603
|
+
kind: 'exact',
|
|
604
|
+
path: '/ops-access/grants/revoke-all',
|
|
605
|
+
handler: async (req, res) => {
|
|
606
|
+
try {
|
|
607
|
+
if (req.method !== 'POST') {
|
|
608
|
+
sendJsonError(res, 405, new Error('method not allowed'));
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
const body = JSON.parse(await readRequestBody(req));
|
|
612
|
+
const session = body.session;
|
|
613
|
+
if (typeof session !== 'string' || session === '') {
|
|
614
|
+
sendJsonError(res, 400, new Error('session (non-empty string) is required'));
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
const grants = gate.list(session);
|
|
618
|
+
for (const g of grants) {
|
|
619
|
+
gate.revoke(session, g.kind, g.name);
|
|
620
|
+
audit({ event: 'revoke', session, kind: g.kind, name: g.name, source: 'panel' });
|
|
621
|
+
}
|
|
622
|
+
if (grants.length > 0) {
|
|
623
|
+
queueNotice(session, '<access-revoked>运维收回了本会话的全部提权授权(' + grants.length + ' 项),已回落只读</access-revoked>');
|
|
624
|
+
}
|
|
625
|
+
sendJson(res, 200, { ok: true, revoked: grants.length });
|
|
626
|
+
}
|
|
627
|
+
catch (err) {
|
|
628
|
+
sendJsonError(res, 500, err);
|
|
629
|
+
}
|
|
630
|
+
},
|
|
631
|
+
}));
|
|
632
|
+
// GET /ops-access/access-requests?session=<id> — pending requests (polled).
|
|
633
|
+
// Returns the session's own requests PLUS its delegated children's: a
|
|
634
|
+
// sub-agent's parked request rides the sub-session id, but the operator
|
|
635
|
+
// working in the parent session must see and decide it there (血缘).
|
|
636
|
+
// POST /ops-access/access-requests — unused; requests come from the tool.
|
|
637
|
+
wctx.effect(() => ws.webServer.register({
|
|
638
|
+
kind: 'exact',
|
|
639
|
+
path: '/ops-access/access-requests',
|
|
640
|
+
handler: async (req, res) => {
|
|
641
|
+
try {
|
|
642
|
+
if (req.method !== 'GET') {
|
|
643
|
+
sendJsonError(res, 405, new Error('method not allowed'));
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
const session = new URL(req.url, 'http://localhost').searchParams.get('session') ?? '';
|
|
647
|
+
if (session === '') {
|
|
648
|
+
sendJsonError(res, 400, new Error('session query parameter is required'));
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
const own = pending.list(session);
|
|
652
|
+
const delegated = pending.list().filter((r) => r.parentSession === session);
|
|
653
|
+
sendJson(res, 200, { ok: true, requests: [...own, ...delegated] });
|
|
654
|
+
}
|
|
655
|
+
catch (err) {
|
|
656
|
+
sendJsonError(res, 500, err);
|
|
657
|
+
}
|
|
658
|
+
},
|
|
659
|
+
}));
|
|
660
|
+
// POST /ops-access/access-requests/decide — {id, approved, ttlMinutes?}.
|
|
661
|
+
wctx.effect(() => ws.webServer.register({
|
|
662
|
+
kind: 'exact',
|
|
663
|
+
path: '/ops-access/access-requests/decide',
|
|
664
|
+
handler: async (req, res) => {
|
|
665
|
+
try {
|
|
666
|
+
if (req.method !== 'POST') {
|
|
667
|
+
sendJsonError(res, 405, new Error('method not allowed'));
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
const body = JSON.parse(await readRequestBody(req));
|
|
671
|
+
const { id, approved } = body;
|
|
672
|
+
if (typeof id !== 'string' || id === '' || typeof approved !== 'boolean') {
|
|
673
|
+
sendJsonError(res, 400, new Error('id (string) and approved (boolean) are required'));
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
let decision;
|
|
677
|
+
if (approved) {
|
|
678
|
+
const ttl = ttlOptionError(body.ttlMinutes);
|
|
679
|
+
if (ttl === null) {
|
|
680
|
+
sendJsonError(res, 400, new Error(ttlOptionsMessage));
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
decision = { approved: true, ttlMinutes: ttl };
|
|
684
|
+
}
|
|
685
|
+
else {
|
|
686
|
+
decision = { approved: false, outcome: 'rejected' };
|
|
687
|
+
}
|
|
688
|
+
const settled = pending.decide(id, decision);
|
|
689
|
+
if (!settled) {
|
|
690
|
+
sendJsonError(res, 404, new Error('no pending request ' + id + ' (already decided or expired)'));
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
sendJson(res, 200, { ok: true });
|
|
694
|
+
}
|
|
695
|
+
catch (err) {
|
|
696
|
+
sendJsonError(res, 500, err);
|
|
697
|
+
}
|
|
698
|
+
},
|
|
699
|
+
}));
|
|
700
|
+
});
|
|
701
|
+
ctx.on('agent/pre-step', async (payload, next) => {
|
|
702
|
+
const decision = await next();
|
|
703
|
+
if (decision.kind === 'reject')
|
|
704
|
+
return decision;
|
|
705
|
+
const agent = payload?.agent;
|
|
706
|
+
if (!agent || typeof agent.id !== 'string' || typeof agent.inject !== 'function')
|
|
707
|
+
return decision;
|
|
708
|
+
const queued = notices.get(agent.id);
|
|
709
|
+
if (!queued || queued.length === 0)
|
|
710
|
+
return decision;
|
|
711
|
+
notices.delete(agent.id);
|
|
712
|
+
agent.inject(createUserMessage({
|
|
713
|
+
content: [{ type: 'text', text: queued.join('\n') }],
|
|
714
|
+
source: { kind: 'plugin', plugin: name, form: 'notice', summary: 'access grant change' },
|
|
715
|
+
}));
|
|
716
|
+
return decision;
|
|
717
|
+
}, { prepend: true });
|
|
718
|
+
ctx.effect(() => ctx.tools.register(defineTool({
|
|
719
|
+
name: REQUEST_ACCESS,
|
|
720
|
+
description: 'Request time-limited elevated access to an ops profile, list this ' +
|
|
721
|
+
'session\'s active grants, or revoke one. A human decides each request in ' +
|
|
722
|
+
'the access panel and may adjust the requested lifetime to a shorter ' +
|
|
723
|
+
'option; the result reports the lifetime actually granted. Elevated (rw) ' +
|
|
724
|
+
'credentials lapse automatically at the TTL.',
|
|
725
|
+
parameters: {
|
|
726
|
+
action: { type: 'string', enum: ['request', 'list', 'revoke'], required: true, description: 'request: ask a human for a timed grant; list: show this session\'s active grants; revoke: drop a grant immediately.' },
|
|
727
|
+
profile: { type: 'string', description: '"kind/name", e.g. "k8s/prod". Required for request and revoke.' },
|
|
728
|
+
reason: { type: 'string', description: 'Why the access is needed — shown verbatim to the human approver. Required for request.' },
|
|
729
|
+
ttlMinutes: { type: 'number', description: 'Requested grant lifetime in minutes (default ' + config.defaultTtlMinutes + ', max ' + config.maxTtlMinutes + '). The human may approve a shorter lifetime from the panel options: ' + config.grantTtlOptions.join(', ') + '.' },
|
|
730
|
+
},
|
|
731
|
+
output: {
|
|
732
|
+
schema: {
|
|
733
|
+
type: 'object',
|
|
734
|
+
additionalProperties: false,
|
|
735
|
+
properties: {
|
|
736
|
+
ok: { type: 'boolean', required: true },
|
|
737
|
+
message: { type: 'string', required: true },
|
|
738
|
+
},
|
|
739
|
+
},
|
|
740
|
+
// Pure function of (args, value): same inputs, same text, no state touched.
|
|
741
|
+
render: (_args, value) => [{ type: 'text', text: value.message }],
|
|
742
|
+
},
|
|
743
|
+
async execute(args, exec) {
|
|
744
|
+
const action = args.action;
|
|
745
|
+
const agent = exec.agent;
|
|
746
|
+
// Fail closed: no agent means an internal (non-session) caller, and
|
|
747
|
+
// grants have nothing to key on.
|
|
748
|
+
if (!agent) {
|
|
749
|
+
return { ok: false, message: REQUEST_ACCESS + ' requires a session context; internal calls cannot hold grants' };
|
|
750
|
+
}
|
|
751
|
+
if (action === 'list') {
|
|
752
|
+
const grants = gate.list(agent.id);
|
|
753
|
+
if (grants.length === 0)
|
|
754
|
+
return { ok: true, message: 'No active grants in this session.' };
|
|
755
|
+
const lines = grants.map((g) => {
|
|
756
|
+
const remaining = Math.max(0, Math.round((g.expiresAt - Date.now()) / 60000));
|
|
757
|
+
return '- ' + g.kind + '/' + g.name + ' — ' + remaining + ' min left (approved by ' + g.approvedBy + ') — ' + g.reason;
|
|
758
|
+
});
|
|
759
|
+
return { ok: true, message: 'Active grants:\n' + lines.join('\n') };
|
|
760
|
+
}
|
|
761
|
+
const parsed = parseProfile(args.profile);
|
|
762
|
+
if (!parsed) {
|
|
763
|
+
return { ok: false, message: 'profile must be "kind/name", e.g. "k8s/prod"' };
|
|
764
|
+
}
|
|
765
|
+
const { kind, profileName } = parsed;
|
|
766
|
+
if (action === 'revoke') {
|
|
767
|
+
const removed = gate.revoke(agent.id, kind, profileName);
|
|
768
|
+
if (removed)
|
|
769
|
+
audit({ event: 'revoke', session: agent.id, kind, name: profileName, source: 'agent' });
|
|
770
|
+
return removed
|
|
771
|
+
? { ok: true, message: 'Revoked ' + kind + '/' + profileName + '; this session is back to read-only for it.' }
|
|
772
|
+
: { ok: false, message: 'No active grant for ' + kind + '/' + profileName + ' in this session.' };
|
|
773
|
+
}
|
|
774
|
+
// action === 'request'
|
|
775
|
+
// A locked profile can never be granted — fail fast instead of parking.
|
|
776
|
+
if (gate.isDenied(kind, profileName)) {
|
|
777
|
+
return { ok: false, message: kind + '/' + profileName + ' is locked by the operator (deny) — no access can be granted until the lockdown is lifted. The request was not sent.' };
|
|
778
|
+
}
|
|
779
|
+
const reason = typeof args.reason === 'string' ? args.reason.trim() : '';
|
|
780
|
+
if (reason === '') {
|
|
781
|
+
return { ok: false, message: 'request requires a non-empty reason — it is shown to the human approver' };
|
|
782
|
+
}
|
|
783
|
+
// Headless fast-fail: the access panel is the approval channel, and its
|
|
784
|
+
// routes live on the web server. No web server → nobody can ever decide
|
|
785
|
+
// — error immediately instead of parking until the timeout.
|
|
786
|
+
if (!humanChannelAvailable) {
|
|
787
|
+
return { ok: false, message: 'No approval channel in this deployment (headless — the access panel needs the web server). Ask the operator to grant access out of band.' };
|
|
788
|
+
}
|
|
789
|
+
// Deliverability checks BEFORE parking the request. All go through
|
|
790
|
+
// canResolve — an explicit metadata query that validates like resolve
|
|
791
|
+
// but never consults the broker and never returns fields. The tier that
|
|
792
|
+
// must resolve is the tier the grant would ISSUE:
|
|
793
|
+
// - Approval-required kinds (ssh): the credential lives in the ro tier.
|
|
794
|
+
// - Tiered kinds: the grant issues rw, so only rw must resolve. ro is
|
|
795
|
+
// deliberately NOT required — an rw-only profile is exactly the
|
|
796
|
+
// bootstrap case where the agent derives and registers ro itself
|
|
797
|
+
// (register_access), and refusing the grant would deadlock that flow.
|
|
798
|
+
const opsAccess = ctx.get('opsAccess');
|
|
799
|
+
if (!opsAccess) {
|
|
800
|
+
return { ok: false, message: 'ops-access service unavailable — is the ops-access plugin mounted in this preset?' };
|
|
801
|
+
}
|
|
802
|
+
if (config.approvalRequiredKinds.includes(kind)) {
|
|
803
|
+
if (!(await opsAccess.canResolve(kind, profileName, 'ro')).ok) {
|
|
804
|
+
return { ok: false, message: 'no resolvable profile "' + kind + '/' + profileName + '" in the access registry (unknown kind, unknown name, or invalid entry). Run list_access to see available profiles. The request was not sent.' };
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
else if (!(await opsAccess.canResolve(kind, profileName, 'rw')).ok) {
|
|
808
|
+
// Distinguish "entry exists but its rw tier is missing/broken" from
|
|
809
|
+
// "no such entry at all" — the guidance differs.
|
|
810
|
+
if ((await opsAccess.canResolve(kind, profileName, 'ro')).ok) {
|
|
811
|
+
return { ok: false, message: kind + '/' + profileName + ' has no usable rw tier registered (missing or invalid entry in the rw registry), so a grant could not be fulfilled. Ask the operator to fix the rw credential first. The request was not sent.' };
|
|
812
|
+
}
|
|
813
|
+
return { ok: false, message: 'no resolvable profile "' + kind + '/' + profileName + '" in the access registry (unknown kind, unknown name, or invalid entry). Run list_access to see available profiles. The request was not sent.' };
|
|
814
|
+
}
|
|
815
|
+
const requested = typeof args.ttlMinutes === 'number' ? args.ttlMinutes : config.defaultTtlMinutes;
|
|
816
|
+
const ttl = Math.min(Math.max(1, Math.round(requested)), config.maxTtlMinutes);
|
|
817
|
+
// Park the request for a human decision in the access panel. The
|
|
818
|
+
// dispatching session rides along (血缘): the approver sees when a
|
|
819
|
+
// request comes from a spawned sub-agent, and whose it is.
|
|
820
|
+
const parentSession = parentSessionOf(agent);
|
|
821
|
+
audit({ event: 'grant-request', session: agent.id, parentSession, kind, name: profileName, reason, requestedTtlMinutes: ttl });
|
|
822
|
+
const { req, decision } = pending.add({ session: agent.id, parentSession, kind, name: profileName, requestedTtlMinutes: ttl, reason }, config.pendingRequestTimeoutMinutes);
|
|
823
|
+
// A dead tool call (turn aborted) settles the request as cancelled.
|
|
824
|
+
const onAbort = () => pending.decide(req.id, { approved: false, outcome: 'cancelled' });
|
|
825
|
+
exec.signal?.addEventListener('abort', onAbort);
|
|
826
|
+
let settled;
|
|
827
|
+
try {
|
|
828
|
+
settled = await decision;
|
|
829
|
+
}
|
|
830
|
+
finally {
|
|
831
|
+
exec.signal?.removeEventListener('abort', onAbort);
|
|
832
|
+
}
|
|
833
|
+
if (!settled.approved) {
|
|
834
|
+
const why = settled.outcome === 'timeout'
|
|
835
|
+
? 'no operator decision within ' + config.pendingRequestTimeoutMinutes + ' min'
|
|
836
|
+
: settled.outcome === 'cancelled'
|
|
837
|
+
? 'cancelled'
|
|
838
|
+
: 'rejected by the operator';
|
|
839
|
+
return { ok: false, message: 'Access to ' + kind + '/' + profileName + ' was not granted (' + why + ').' };
|
|
840
|
+
}
|
|
841
|
+
const expiresAt = Date.now() + settled.ttlMinutes * 60000;
|
|
842
|
+
gate.authorize({ session: agent.id, kind, name: profileName, expiresAt, reason, approvedBy: 'user' });
|
|
843
|
+
audit({
|
|
844
|
+
event: 'grant',
|
|
845
|
+
session: agent.id,
|
|
846
|
+
parentSession,
|
|
847
|
+
kind,
|
|
848
|
+
name: profileName,
|
|
849
|
+
reason,
|
|
850
|
+
approvedBy: 'user',
|
|
851
|
+
expiresAt,
|
|
852
|
+
ttlMinutes: settled.ttlMinutes,
|
|
853
|
+
...(settled.ttlMinutes !== ttl ? { requestedTtlMinutes: ttl } : {}),
|
|
854
|
+
});
|
|
855
|
+
const adjusted = settled.ttlMinutes !== ttl
|
|
856
|
+
? ' — the operator adjusted your requested ' + ttl + ' min to ' + settled.ttlMinutes + ' min'
|
|
857
|
+
: '';
|
|
858
|
+
return { ok: true, message: 'Granted ' + kind + '/' + profileName + ' until ' + new Date(expiresAt).toISOString() + ' (' + settled.ttlMinutes + ' min' + adjusted + '). Elevated credentials apply to this session only.' };
|
|
859
|
+
},
|
|
860
|
+
})));
|
|
861
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Invariant companion for @elinpf/dsh-ops-access-gate.
|
|
3
|
+
*
|
|
4
|
+
* @module @elinpf/dsh-ops-access-gate/invariant
|
|
5
|
+
*/
|
|
6
|
+
/** Cordis companion plugin name. */
|
|
7
|
+
declare const name = "ops-access-gate-invariant";
|
|
8
|
+
/** Service required before the companion can reserve package ownership. */
|
|
9
|
+
declare const inject: string[];
|
|
10
|
+
/**
|
|
11
|
+
* Register this package's invariant companion.
|
|
12
|
+
* @param ctx - Context carrying the invariant service.
|
|
13
|
+
* @returns a promise resolving after registration.
|
|
14
|
+
*/
|
|
15
|
+
declare const apply: (ctx: any) => Promise<void>;
|
|
16
|
+
export { apply, inject, name };
|
package/lib/invariant.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Invariant companion for @elinpf/dsh-ops-access-gate.
|
|
3
|
+
*
|
|
4
|
+
* @module @elinpf/dsh-ops-access-gate/invariant
|
|
5
|
+
*/
|
|
6
|
+
const PACKAGE_NAME = '@elinpf/dsh-ops-access-gate';
|
|
7
|
+
/** Cordis companion plugin name. */
|
|
8
|
+
const name = 'ops-access-gate-invariant';
|
|
9
|
+
/** Service required before the companion can reserve package ownership. */
|
|
10
|
+
const inject = ['invariants'];
|
|
11
|
+
/**
|
|
12
|
+
* No runtime invariant: the gate owns no session-event shape. Its state is
|
|
13
|
+
* an in-process grant ledger plus an append-only JSONL audit file (and the
|
|
14
|
+
* persisted lockdown set), all outside the session log — there is no fold
|
|
15
|
+
* to validate, and every authorization decision is a pure function of the
|
|
16
|
+
* ledger at resolve time.
|
|
17
|
+
*/
|
|
18
|
+
const install = () => { };
|
|
19
|
+
/**
|
|
20
|
+
* Register this package's invariant companion.
|
|
21
|
+
* @param ctx - Context carrying the invariant service.
|
|
22
|
+
* @returns a promise resolving after registration.
|
|
23
|
+
*/
|
|
24
|
+
const apply = async (ctx) => {
|
|
25
|
+
ctx.invariants.register(PACKAGE_NAME, install);
|
|
26
|
+
};
|
|
27
|
+
export { apply, inject, name };
|
package/lib/types.d.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure type definitions for @elinpf/dsh-ops-access-gate.
|
|
3
|
+
*
|
|
4
|
+
* Types only — no runtime values live here. The schemastery Config schema,
|
|
5
|
+
* the denied-file persistence helpers, and the plugin apply stay in
|
|
6
|
+
* index.ts; this module holds the contracts they operate on so consumers
|
|
7
|
+
* can import them without pulling the plugin's runtime.
|
|
8
|
+
*
|
|
9
|
+
* @module @elinpf/dsh-ops-access-gate/types
|
|
10
|
+
*/
|
|
11
|
+
export interface Config {
|
|
12
|
+
/** Kinds that require a grant for ANY use (no ro tier exists, e.g. ssh). */
|
|
13
|
+
approvalRequiredKinds: string[];
|
|
14
|
+
/** Default grant lifetime when request_access omits ttlMinutes. */
|
|
15
|
+
defaultTtlMinutes: number;
|
|
16
|
+
/** Upper bound for a requested grant lifetime. */
|
|
17
|
+
maxTtlMinutes: number;
|
|
18
|
+
/** JSONL audit log path; a leading ~ expands to $HOME. */
|
|
19
|
+
auditFile: string;
|
|
20
|
+
/** TTL choices (minutes) the access panel offers — proactive grants and request decisions share this list. */
|
|
21
|
+
grantTtlOptions: number[];
|
|
22
|
+
/** Minutes a pending request awaits a human decision before auto-rejecting. */
|
|
23
|
+
pendingRequestTimeoutMinutes: number;
|
|
24
|
+
/** Lockdown (deny) state file path; a leading ~ expands to $HOME. Unlike grants, lockdowns SURVIVE restarts — an incident freeze that silently lifts on restart is no freeze. */
|
|
25
|
+
deniedFile: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* One authorization: session S may use elevated credentials for kind/name
|
|
29
|
+
* until expiresAt. Grants are in-process only — a dsh restart clears them,
|
|
30
|
+
* which is acceptable: they are short-lived by design.
|
|
31
|
+
*/
|
|
32
|
+
export interface Grant {
|
|
33
|
+
/** Session id (exec.agent.id) this grant is scoped to. */
|
|
34
|
+
readonly session: string;
|
|
35
|
+
readonly kind: string;
|
|
36
|
+
readonly name: string;
|
|
37
|
+
/** Epoch ms when the grant lapses. */
|
|
38
|
+
readonly expiresAt: number;
|
|
39
|
+
/** The reason the model stated and the human approved. */
|
|
40
|
+
readonly reason: string;
|
|
41
|
+
/** Who approved; 'user' via a request decision, 'panel' via the access panel. */
|
|
42
|
+
readonly approvedBy: string;
|
|
43
|
+
}
|
|
44
|
+
/** A live grant as reported by list (session key omitted — it is the query). */
|
|
45
|
+
export interface ActiveGrant {
|
|
46
|
+
readonly kind: string;
|
|
47
|
+
readonly name: string;
|
|
48
|
+
readonly expiresAt: number;
|
|
49
|
+
readonly reason: string;
|
|
50
|
+
readonly approvedBy: string;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* One operator lockdown (ticket 12, the broker's fourth state): the
|
|
54
|
+
* profile is refused ENTIRELY — even ro — until lifted. Scenarios:
|
|
55
|
+
* leaked credential, maintenance window, incident freeze. Lockdowns are
|
|
56
|
+
* process-wide (not session-scoped) and persisted to deniedFile, so a
|
|
57
|
+
* restart does not silently lift a freeze.
|
|
58
|
+
*/
|
|
59
|
+
export interface DeniedEntry {
|
|
60
|
+
readonly kind: string;
|
|
61
|
+
readonly name: string;
|
|
62
|
+
/** Epoch ms of the lockdown. */
|
|
63
|
+
readonly deniedAt: number;
|
|
64
|
+
readonly reason: string;
|
|
65
|
+
readonly deniedBy: string;
|
|
66
|
+
}
|
|
67
|
+
/** The gate handle exposed via ctx.get('opsAccessGate'). */
|
|
68
|
+
export interface OpsAccessGate {
|
|
69
|
+
/** Record a grant. Re-authorizing the same (session, kind, name) replaces the entry. */
|
|
70
|
+
authorize(grant: Grant): void;
|
|
71
|
+
/** Whether this session holds an unexpired grant for the profile (the broker's query). */
|
|
72
|
+
isAuthorized(session: string, kind: string, name: string): boolean;
|
|
73
|
+
/** Drop a grant immediately. Returns false when no such grant existed. */
|
|
74
|
+
revoke(session: string, kind: string, name: string): boolean;
|
|
75
|
+
/** This session's live (unexpired) grants. */
|
|
76
|
+
list(session: string): ActiveGrant[];
|
|
77
|
+
/** Every session's live grants, for the cross-session overview (ticket 13). */
|
|
78
|
+
listAll(): Array<ActiveGrant & {
|
|
79
|
+
session: string;
|
|
80
|
+
}>;
|
|
81
|
+
/**
|
|
82
|
+
* Lock a profile outright: even ro resolution is refused until lifted.
|
|
83
|
+
* Also revokes every live grant for it across ALL sessions (a leaked
|
|
84
|
+
* credential's elevation must die now) and returns the affected
|
|
85
|
+
* session ids so the caller can notify them. Re-denying replaces the
|
|
86
|
+
* entry (fresh reason/timestamp).
|
|
87
|
+
*/
|
|
88
|
+
deny(kind: string, name: string, reason: string, deniedBy: string): string[];
|
|
89
|
+
/** Lift a lockdown. Returns false when the profile was not locked. */
|
|
90
|
+
undeny(kind: string, name: string): boolean;
|
|
91
|
+
/** Whether the profile is operator-locked (the broker's first check). */
|
|
92
|
+
isDenied(kind: string, name: string): boolean;
|
|
93
|
+
/** All active lockdowns, for the access panel. */
|
|
94
|
+
listDenied(): DeniedEntry[];
|
|
95
|
+
}
|
|
96
|
+
declare module '@deepseek-ai/cordis' {
|
|
97
|
+
interface Context {
|
|
98
|
+
opsAccessGate?: OpsAccessGate;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** One parked request_access call awaiting a human decision in the panel. */
|
|
102
|
+
export interface PendingRequest {
|
|
103
|
+
readonly id: string;
|
|
104
|
+
readonly session: string;
|
|
105
|
+
/** The dispatching session, when the requester is a spawned sub-agent (血缘). */
|
|
106
|
+
readonly parentSession?: string;
|
|
107
|
+
readonly kind: string;
|
|
108
|
+
readonly name: string;
|
|
109
|
+
readonly requestedTtlMinutes: number;
|
|
110
|
+
readonly reason: string;
|
|
111
|
+
readonly createdAt: number;
|
|
112
|
+
/** Epoch ms when the request auto-rejects (timeout). */
|
|
113
|
+
readonly decidesAt: number;
|
|
114
|
+
}
|
|
115
|
+
/** How a pending request settles. Approved carries the human-chosen TTL. */
|
|
116
|
+
export type RequestDecision = {
|
|
117
|
+
readonly approved: true;
|
|
118
|
+
readonly ttlMinutes: number;
|
|
119
|
+
} | {
|
|
120
|
+
readonly approved: false;
|
|
121
|
+
readonly outcome: 'rejected' | 'timeout' | 'cancelled';
|
|
122
|
+
};
|
package/lib/types.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure type definitions for @elinpf/dsh-ops-access-gate.
|
|
3
|
+
*
|
|
4
|
+
* Types only — no runtime values live here. The schemastery Config schema,
|
|
5
|
+
* the denied-file persistence helpers, and the plugin apply stay in
|
|
6
|
+
* index.ts; this module holds the contracts they operate on so consumers
|
|
7
|
+
* can import them without pulling the plugin's runtime.
|
|
8
|
+
*
|
|
9
|
+
* @module @elinpf/dsh-ops-access-gate/types
|
|
10
|
+
*/
|
|
11
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@elinpf/dsh-ops-access-gate",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Ops access gate — per-session credential brokering. Owns an in-process grant ledger keyed by agent.id and registers a pure-decision broker (ro/rw) into the ops-access seam. Never touches credential fields.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"types": "lib/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./lib/index.d.ts",
|
|
11
|
+
"default": "./lib/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./invariant": {
|
|
14
|
+
"types": "./lib/invariant.d.ts",
|
|
15
|
+
"default": "./lib/invariant.js"
|
|
16
|
+
},
|
|
17
|
+
"./types": {
|
|
18
|
+
"types": "./lib/types.d.ts",
|
|
19
|
+
"default": "./lib/types.js"
|
|
20
|
+
},
|
|
21
|
+
"./package.json": "./package.json"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"lib/index.js",
|
|
25
|
+
"lib/invariant.js",
|
|
26
|
+
"lib/types.js",
|
|
27
|
+
"lib/**/*.d.ts",
|
|
28
|
+
"cordis.patch.yml"
|
|
29
|
+
],
|
|
30
|
+
"dsh": {
|
|
31
|
+
"bundle": {
|
|
32
|
+
"patch": "./cordis.patch.yml"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@deepseek-ai/dsh-llm": "^0.1.0-rc.8",
|
|
37
|
+
"@deepseek-ai/dsh-tools": "^0.0.1-rc.1",
|
|
38
|
+
"@deepseek-ai/schemastery": "^3.18.1",
|
|
39
|
+
"@elinpf/dsh-ops-panel": "^0.1.0"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
43
|
+
"@deepseek-ai/dsh-invariants": "^0.0.1-rc.1",
|
|
44
|
+
"@elinpf/dsh-ops-access": "^0.1.0"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@deepseek-ai/cordis": "4.0.1",
|
|
48
|
+
"@deepseek-ai/dsh-llm": "0.1.0-rc.8",
|
|
49
|
+
"@deepseek-ai/dsh-tools": "0.0.1-rc.1",
|
|
50
|
+
"@types/node": "^22.0.0",
|
|
51
|
+
"typescript": "^5.4.0",
|
|
52
|
+
"vitest": "^4.1.11",
|
|
53
|
+
"zod": "^4.4.3",
|
|
54
|
+
"@elinpf/dsh-ops-panel": "0.1.0",
|
|
55
|
+
"@elinpf/dsh-ops-access": "0.1.0"
|
|
56
|
+
},
|
|
57
|
+
"license": "MIT",
|
|
58
|
+
"publishConfig": {
|
|
59
|
+
"access": "public"
|
|
60
|
+
},
|
|
61
|
+
"scripts": {
|
|
62
|
+
"build": "tsc",
|
|
63
|
+
"typecheck": "tsc --noEmit",
|
|
64
|
+
"test": "vitest run"
|
|
65
|
+
}
|
|
66
|
+
}
|