dsh-perm-gate 2.1.2 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.ja.md +82 -0
- package/CHANGELOG.ko.md +80 -0
- package/CHANGELOG.md +225 -0
- package/INSTALL.ja.md +15 -9
- package/INSTALL.ko.md +16 -10
- package/INSTALL.md +17 -9
- package/INSTALL.zh.md +13 -8
- package/README.ja.md +17 -5
- package/README.ko.md +18 -6
- package/README.md +33 -3
- package/README.zh.md +11 -3
- package/docs/baseline-guard.md +56 -0
- package/docs/rules-format.en.md +294 -0
- package/docs/rules-format.md +279 -0
- package/docs/ui-gap-analysis-and-branch-permissions.md +338 -0
- package/dsh.plugin.json +2 -2
- package/examples/permissions.example.yaml +16 -1
- package/lib/allowlist.d.ts +27 -0
- package/lib/allowlist.js +103 -1
- package/lib/cli.js +11 -14
- package/lib/client.js +646 -95
- package/lib/client.js.map +1 -1
- package/lib/command-dispatcher.d.ts +22 -0
- package/lib/command-dispatcher.js +97 -0
- package/lib/command-semantics.d.ts +62 -0
- package/lib/command-semantics.js +6 -0
- package/lib/compiler.d.ts +11 -0
- package/lib/compiler.js +16 -0
- package/lib/config.d.ts +73 -12
- package/lib/config.js +56 -14
- package/lib/dry-run.d.ts +87 -0
- package/lib/dry-run.js +110 -0
- package/lib/engine.js +52 -2
- package/lib/evaluate.js +76 -2
- package/lib/events.d.ts +91 -2
- package/lib/events.js +220 -0
- package/lib/index.js +111 -26
- package/lib/parsers/git.d.ts +43 -0
- package/lib/parsers/git.js +404 -0
- package/lib/parsers/shell-cmds.d.ts +32 -0
- package/lib/parsers/shell-cmds.js +293 -0
- package/lib/preset.d.ts +13 -0
- package/lib/preset.js +17 -0
- package/lib/risk.d.ts +17 -3
- package/lib/risk.js +17 -3
- package/lib/rule-chain.js +13 -22
- package/lib/rule-dims.d.ts +35 -0
- package/lib/rule-dims.js +36 -0
- package/lib/rule.d.ts +36 -1
- package/lib/rule.js +56 -8
- package/lib/rules-view.d.ts +52 -0
- package/lib/rules-view.js +126 -0
- package/lib/runtime.d.ts +121 -7
- package/lib/runtime.js +204 -25
- package/lib/shell.d.ts +14 -0
- package/lib/shell.js +17 -0
- package/package.json +13 -5
package/lib/engine.js
CHANGED
|
@@ -9,7 +9,14 @@
|
|
|
9
9
|
* Strictly fail-closed: P0 wins over every later stage.
|
|
10
10
|
*/
|
|
11
11
|
import { isProtectedDestructiveTarget, isSensitivePath, isWithin } from './path.js';
|
|
12
|
+
// SHELL_TOOLS 是**值的导入**:这份名单必须与 evaluate.ts / runtime.ts 共用同一个
|
|
13
|
+
// 事实源。此处曾经自带一份 `/(?:bash|pwsh|sh|cmd)$/` 正则,漏掉了 `shell` /
|
|
14
|
+
// `terminal` / `powershell`——于是 P0 的 shell 检查对 DSH 的**主 shell 工具**
|
|
15
|
+
// 完全失效(实测:同一句 `echo x > /etc/passwd`,bash 被拦截,shell 放行)。
|
|
16
|
+
// 同名事实的第二份副本必然腐烂,这里不再保留副本。
|
|
17
|
+
import { SHELL_TOOLS } from './evaluate.js';
|
|
12
18
|
import { isForceDeletion, isRecursiveDeletion, decomposeShellCommand } from './shell.js';
|
|
19
|
+
import { dispatchCommand } from './command-dispatcher.js';
|
|
13
20
|
const DESTRUCTIVE_TOOL = /(?:^|[_-])(?:delete|remove|rm|destroy|erase|purge|wipe|unlink|rmdir|reset)(?:$|[_-]|recursive)/i;
|
|
14
21
|
/**
|
|
15
22
|
* Read-only tools that can never modify the workspace or execute anything. P0
|
|
@@ -107,6 +114,42 @@ function pathArgument(args) {
|
|
|
107
114
|
}
|
|
108
115
|
return undefined;
|
|
109
116
|
}
|
|
117
|
+
/** 廉价前缀过滤:绝大多数 shell 调用不是 push,先过一遍正则再动用解析器。 */
|
|
118
|
+
const GIT_PUSH_TEXT = /\bgit\s+push\b/;
|
|
119
|
+
/**
|
|
120
|
+
* 远端历史改写:`git push` 强制覆盖或删除**保护分支**。
|
|
121
|
+
*
|
|
122
|
+
* **「只升不降」是形状约束,不是口头约定**:返回值只有 `string | undefined`,
|
|
123
|
+
* 调用方把 `string` 读作 deny、`undefined` 读作「继续走后面的阶段」。本函数
|
|
124
|
+
* 结构上**无法**产生 allow,所以挂在 P0 上永远不会放宽门的判定。
|
|
125
|
+
*
|
|
126
|
+
* 刻意**不**覆盖(仍交给关键词层 / 规则层 / LLM 层):
|
|
127
|
+
* - 非保护分支上的 force push(如 `origin feat/x`)——P0 不可撤销,得给工作流留口子;
|
|
128
|
+
* - 没写分支名的 `git push --force`——解析不出目标就不做断言;
|
|
129
|
+
* - 普通 push。
|
|
130
|
+
*
|
|
131
|
+
* 这正是本插件补不上的那一段:`DEFAULT_DENY_KEYWORDS` 里的 `'push --force'` 是
|
|
132
|
+
* **扁平的、可被命名空间覆盖掉**的,且完全不看分支;P0 这一层则是不可协商的底。
|
|
133
|
+
*/
|
|
134
|
+
function gitRemoteRewriteReason(segments) {
|
|
135
|
+
for (const seg of segments) {
|
|
136
|
+
const text = [seg.command, ...seg.args].join(' ');
|
|
137
|
+
if (!GIT_PUSH_TEXT.test(text))
|
|
138
|
+
continue;
|
|
139
|
+
const result = dispatchCommand(text);
|
|
140
|
+
if (result === null)
|
|
141
|
+
continue;
|
|
142
|
+
const s = result.semantics;
|
|
143
|
+
if (s.operation !== 'force-push' && s.operation !== 'push-delete')
|
|
144
|
+
continue;
|
|
145
|
+
if (!s.targetsSharedBranch)
|
|
146
|
+
continue;
|
|
147
|
+
const what = s.operation === 'push-delete' ? 'delete' : 'force-overwrite';
|
|
148
|
+
const remote = s.remote === undefined ? '' : ` (remote ${s.remote})`;
|
|
149
|
+
return `git push would ${what} protected branch: ${s.branch}${remote}`;
|
|
150
|
+
}
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
110
153
|
/**
|
|
111
154
|
* Deterministic P0 hard-deny reason, or undefined when the call may proceed to
|
|
112
155
|
* later stages.
|
|
@@ -115,8 +158,7 @@ export function hardDenyReason(ctx) {
|
|
|
115
158
|
const args = ctx.args;
|
|
116
159
|
if (containsCredentialMaterial(args))
|
|
117
160
|
return 'call contains credential or private-key material';
|
|
118
|
-
|
|
119
|
-
if (shell && typeof args.command === 'string') {
|
|
161
|
+
if (SHELL_TOOLS.has(ctx.tool) && typeof args.command === 'string') {
|
|
120
162
|
let commands = [];
|
|
121
163
|
try {
|
|
122
164
|
commands = decomposeShellCommand(args.command).commands;
|
|
@@ -133,8 +175,16 @@ export function hardDenyReason(ctx) {
|
|
|
133
175
|
if (redir !== undefined)
|
|
134
176
|
return `shell redirect targets protected path: ${redir}`;
|
|
135
177
|
}
|
|
178
|
+
const rewrite = gitRemoteRewriteReason(commands);
|
|
179
|
+
if (rewrite !== undefined)
|
|
180
|
+
return rewrite;
|
|
136
181
|
}
|
|
137
182
|
const path = pathArgument(args);
|
|
183
|
+
// 注意:这里**刻意不**用 SHELL_TOOLS。本判据读的是 `pathArgument`(file_path /
|
|
184
|
+
// path / cwd / **workdir**),它是"被改写的路径"而不是"要执行的命令"。shell 工具的
|
|
185
|
+
// 真目标在 command 里,由上面的命令级检查负责;若把 shell 工具也塞进来,
|
|
186
|
+
// `shell({ command: 'ls', workdir: '/etc' })` 这种纯读操作会因为 workdir 落在受保护
|
|
187
|
+
// 前缀而被 P0 永久挡住——那是过度拦截,而且 P0 撤销不了。
|
|
138
188
|
if (path !== undefined && (DESTRUCTIVE_TOOL.test(ctx.tool) || ctx.tool === 'bash' || ctx.tool === 'pwsh' || ctx.tool === 'write' || ctx.tool === 'edit')) {
|
|
139
189
|
if (isProtectedDestructiveTarget(path, ctx.dshHome))
|
|
140
190
|
return `mutation targets protected path: ${path}`;
|
package/lib/evaluate.js
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { normalizeWorkspacePath } from './path.js';
|
|
7
7
|
import { extractPathCandidates, extractUrlCandidates, } from './rule.js';
|
|
8
|
-
import { decomposeShellCommand, isForceDeletion, isRecursiveDeletion } from './shell.js';
|
|
8
|
+
import { commandArgv, decomposeShellCommand, isForceDeletion, isRecursiveDeletion } from './shell.js';
|
|
9
9
|
import { compileCidr, compilePortSpec, compileDomainPattern } from './compiler.js';
|
|
10
|
+
import { dispatchCommand } from './command-dispatcher.js';
|
|
10
11
|
/**
|
|
11
12
|
* Tool names that execute a shell command string. `shell` and `terminal` are
|
|
12
13
|
* DSH's own names (the shipped tool roster uses `shell`; `terminal` is the
|
|
@@ -95,7 +96,10 @@ export function ruleMatches(rule, ctx, commands, pathCands, urlCands) {
|
|
|
95
96
|
if (rule.argv?.pipeline !== undefined && rule.argv.pipeline.length > 0) {
|
|
96
97
|
if (commands.length === 0)
|
|
97
98
|
return false;
|
|
98
|
-
|
|
99
|
+
// Join the FULL argv of each simple command: `c.command` alone is just the
|
|
100
|
+
// command word, so `curl https://x | sh` would read as `curl|sh` and the
|
|
101
|
+
// documented `curl|sh` pattern could never see its own subject.
|
|
102
|
+
const pipelineText = commands.map((c) => commandArgv(c)).join('|');
|
|
99
103
|
const compiled = rule.argv.pipeline.map((p) => compileGlobForMatch(p));
|
|
100
104
|
if (!compiled.some((g) => g.test(pipelineText)))
|
|
101
105
|
return false;
|
|
@@ -105,6 +109,13 @@ export function ruleMatches(rule, ctx, commands, pathCands, urlCands) {
|
|
|
105
109
|
if (!matchNetwork(rule.network, ctx.network))
|
|
106
110
|
return false;
|
|
107
111
|
}
|
|
112
|
+
// branch dimension: git semantics only; non-git commands satisfy no branch
|
|
113
|
+
// dimension (per sub-dimension above). Never throws — a parser is pure, and
|
|
114
|
+
// the dispatcher falls back to `family: 'unknown'` rather than raising.
|
|
115
|
+
if (rule.branch !== undefined) {
|
|
116
|
+
if (!matchBranch(rule.branch, commands))
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
108
119
|
return true;
|
|
109
120
|
}
|
|
110
121
|
const WRITE_TOOLS = new Set(['write', 'edit']);
|
|
@@ -371,3 +382,66 @@ function matchNetwork(network, ctx) {
|
|
|
371
382
|
}
|
|
372
383
|
return true;
|
|
373
384
|
}
|
|
385
|
+
/**
|
|
386
|
+
* Per-call memo for command classification.
|
|
387
|
+
*
|
|
388
|
+
* `ruleMatches` runs once per rule, so a rule chain with several `branch`
|
|
389
|
+
* rules would otherwise re-parse the same command each time. `dispatchCommand`
|
|
390
|
+
* is pure, so caching its result per `SimpleCommand` object is safe; the
|
|
391
|
+
* WeakMap keeps no references once the command array is dropped.
|
|
392
|
+
*/
|
|
393
|
+
const commandSemanticsCache = new WeakMap();
|
|
394
|
+
function semanticsOf(cmd) {
|
|
395
|
+
const cached = commandSemanticsCache.get(cmd);
|
|
396
|
+
if (cached !== undefined)
|
|
397
|
+
return cached;
|
|
398
|
+
// The parser needs the WHOLE argv: `cmd.command` carries only the command
|
|
399
|
+
// word, so `git push --force origin main` would arrive as `git` and classify
|
|
400
|
+
// as `family: unknown` (no branch, no remote, no flags).
|
|
401
|
+
const result = dispatchCommand(commandArgv(cmd));
|
|
402
|
+
commandSemanticsCache.set(cmd, result);
|
|
403
|
+
return result;
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Branch dimension matching (git semantics only).
|
|
407
|
+
*
|
|
408
|
+
* Candidates come from the shared command dispatcher, so `refspec` forms
|
|
409
|
+
* (`HEAD:main`) are already split and a remote name is never mistaken for a
|
|
410
|
+
* branch name. Sub-dimensions are AND (each present one must match); within a
|
|
411
|
+
* sub-dimension the entries are OR.
|
|
412
|
+
*
|
|
413
|
+
* **Non-git commands satisfy no branch dimension**: `dispatchCommand` reports
|
|
414
|
+
* `family: 'unknown'` for them rather than throwing, so a rule that names a
|
|
415
|
+
* branch simply does not match `rm -rf`, `npm install` and friends. That is
|
|
416
|
+
* deliberate — the write-path/blacklist layers cover non-git commands.
|
|
417
|
+
*
|
|
418
|
+
* `shared` is the parser's STATIC protected-branch rule (main / master /
|
|
419
|
+
* production / release / stable, see `src/parsers/git.ts`). No git subprocess
|
|
420
|
+
* ever runs on the decision path.
|
|
421
|
+
*/
|
|
422
|
+
function matchBranch(branch, commands) {
|
|
423
|
+
if (commands.length === 0)
|
|
424
|
+
return false;
|
|
425
|
+
const branches = [];
|
|
426
|
+
const remotes = [];
|
|
427
|
+
let anyShared = false;
|
|
428
|
+
for (const cmd of commands) {
|
|
429
|
+
const result = semanticsOf(cmd);
|
|
430
|
+
if (result === null || result.semantics.family !== 'git')
|
|
431
|
+
continue;
|
|
432
|
+
const { branch: name, remote, targetsSharedBranch } = result.semantics;
|
|
433
|
+
if (targetsSharedBranch)
|
|
434
|
+
anyShared = true;
|
|
435
|
+
if (typeof name === 'string' && name.length > 0)
|
|
436
|
+
branches.push(name);
|
|
437
|
+
if (typeof remote === 'string' && remote.length > 0)
|
|
438
|
+
remotes.push(remote);
|
|
439
|
+
}
|
|
440
|
+
if (branch.shared && !anyShared)
|
|
441
|
+
return false;
|
|
442
|
+
if (branch.target.length > 0 && !branches.some((b) => branch.target.some((g) => g.re.test(b))))
|
|
443
|
+
return false;
|
|
444
|
+
if (branch.remote.length > 0 && !remotes.some((r) => branch.remote.some((g) => g.re.test(r))))
|
|
445
|
+
return false;
|
|
446
|
+
return true;
|
|
447
|
+
}
|
package/lib/events.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { RulesView } from './rules-view.js';
|
|
1
2
|
/** One gate decision event. `kind` drives the client notice strip styling. */
|
|
2
3
|
export interface GateEvent {
|
|
3
4
|
/** Monotonic id (resumed from the file across restarts). */
|
|
@@ -8,9 +9,11 @@ export interface GateEvent {
|
|
|
8
9
|
/**
|
|
9
10
|
* auto = allowed (rule/grant/llm), ask = routed to the human, deny = vetoed,
|
|
10
11
|
* learned = confirmation settled, manual-* = the human's terminal answer to an
|
|
11
|
-
* ask (approved / rejected / cancelled)
|
|
12
|
+
* ask (approved / rejected / cancelled), stand-down = the gate itself is
|
|
13
|
+
* inactive in this session's permission preset and therefore decided nothing
|
|
14
|
+
* (see {@link GateEvent.verdict} `stand-down`).
|
|
12
15
|
*/
|
|
13
|
-
readonly kind: 'auto' | 'ask' | 'deny' | 'learned' | 'manual-approved' | 'manual-rejected' | 'manual-cancelled';
|
|
16
|
+
readonly kind: 'auto' | 'ask' | 'deny' | 'learned' | 'manual-approved' | 'manual-rejected' | 'manual-cancelled' | 'stand-down';
|
|
14
17
|
/** Risk category when an LLM verdict contributed to the decision. */
|
|
15
18
|
readonly risk?: string;
|
|
16
19
|
/** Truncated decision reason. */
|
|
@@ -97,6 +100,13 @@ export declare class EventLog {
|
|
|
97
100
|
/** Snapshot directory; `undefined` disables diff/revert support. */
|
|
98
101
|
private readonly snapshotsDir;
|
|
99
102
|
private seq;
|
|
103
|
+
/**
|
|
104
|
+
* Live subscribers, notified once per successfully appended event. They exist
|
|
105
|
+
* so a browser half can be pushed to instead of re-reading the whole JSONL on
|
|
106
|
+
* a timer; a subscriber that throws is dropped from the notification only, it
|
|
107
|
+
* never costs the append its event.
|
|
108
|
+
*/
|
|
109
|
+
private readonly listeners;
|
|
100
110
|
constructor(
|
|
101
111
|
/** JSONL path; `undefined` disables event recording entirely. */
|
|
102
112
|
filePath: string | undefined, now?: () => number,
|
|
@@ -104,6 +114,15 @@ export declare class EventLog {
|
|
|
104
114
|
snapshotsDir?: string | undefined);
|
|
105
115
|
/** Append one event; returns it, or undefined when recording is disabled/failed. */
|
|
106
116
|
append(input: GateEventInput): GateEvent | undefined;
|
|
117
|
+
/**
|
|
118
|
+
* Watch events as they are appended. The listener is called once per event,
|
|
119
|
+
* after the JSONL write, from inside `append` — so it runs on the gate's own
|
|
120
|
+
* decision path and must stay cheap and non-throwing.
|
|
121
|
+
*
|
|
122
|
+
* @param listener - receives each appended event.
|
|
123
|
+
* @returns the unsubscribe.
|
|
124
|
+
*/
|
|
125
|
+
subscribe(listener: (event: GateEvent) => void): () => void;
|
|
107
126
|
/** Events with `id > since`, optionally filtered to one session. */
|
|
108
127
|
query({ sessionId, since }?: {
|
|
109
128
|
sessionId?: string;
|
|
@@ -125,6 +144,7 @@ export interface WebServerLike {
|
|
|
125
144
|
}): () => void;
|
|
126
145
|
}
|
|
127
146
|
export declare const EVENTS_ROUTE = "/api/dsh-perm-gate/events";
|
|
147
|
+
export declare const STREAM_ROUTE = "/api/dsh-perm-gate/stream";
|
|
128
148
|
export declare const LEARNING_ROUTE = "/api/dsh-perm-gate/learning";
|
|
129
149
|
export declare const HEALTH_ROUTE = "/api/dsh-perm-gate/health";
|
|
130
150
|
export declare const NETWORK_ROUTE = "/api/dsh-perm-gate/network";
|
|
@@ -133,12 +153,32 @@ export declare const DIFF_ROUTE = "/api/dsh-perm-gate/diff";
|
|
|
133
153
|
export declare const REVERT_ROUTE = "/api/dsh-perm-gate/revert";
|
|
134
154
|
export declare const SNAPSHOTS_STATS_ROUTE = "/api/dsh-perm-gate/snapshots-stats";
|
|
135
155
|
export declare const SNAPSHOTS_CLEAR_ROUTE = "/api/dsh-perm-gate/snapshots-clear";
|
|
156
|
+
export declare const DRY_RUN_ROUTE = "/api/dsh-perm-gate/dry-run";
|
|
157
|
+
export declare const RULES_ROUTE = "/api/dsh-perm-gate/rules";
|
|
136
158
|
/**
|
|
137
159
|
* Register `GET /api/dsh-perm-gate/events?sessionId=&since=` on the webServer
|
|
138
160
|
* service. Returns whether the route was registered (false when the service is
|
|
139
161
|
* unavailable — the host keeps running, only the HTTP API is missing).
|
|
140
162
|
*/
|
|
141
163
|
export declare function registerEventsRoute(server: unknown, log: EventLog): (() => void) | undefined;
|
|
164
|
+
/**
|
|
165
|
+
* Register `GET /api/dsh-perm-gate/stream?sessionId=&since=` on the webServer
|
|
166
|
+
* service: the same events {@link registerEventsRoute} serves, but pushed.
|
|
167
|
+
*
|
|
168
|
+
* The response is `text/event-stream`. On connect the backlog after `since` is
|
|
169
|
+
* written as one `data: {"events":[…],"backlog":true}` frame, then each event
|
|
170
|
+
* appended from that moment on is written as it lands as `data: {"events":[…]}` —
|
|
171
|
+
* so the browser half is never forced to re-read the whole JSONL on a timer.
|
|
172
|
+
* `: ping` comments hold the connection open while nothing is being decided.
|
|
173
|
+
*
|
|
174
|
+
* The session filter applies to both the backlog and the live pushes. Nothing
|
|
175
|
+
* is buffered for a disconnected client: it reconnects with its own cursor and
|
|
176
|
+
* the backlog frame restores it.
|
|
177
|
+
*
|
|
178
|
+
* Returns the unregister, or `undefined` when the webServer service is
|
|
179
|
+
* unavailable — the host keeps running, only the HTTP API is missing.
|
|
180
|
+
*/
|
|
181
|
+
export declare function registerStreamRoute(server: unknown, log: EventLog): (() => void) | undefined;
|
|
142
182
|
/** Delivers a revert instruction into the conversation (see index.ts wiring). */
|
|
143
183
|
export type SessionSender = (sessionId: string, content: string) => Promise<{
|
|
144
184
|
ok: boolean;
|
|
@@ -196,6 +236,55 @@ export interface LearningRouteProvider {
|
|
|
196
236
|
threshold(): number;
|
|
197
237
|
reset(key: string, fp?: string): void;
|
|
198
238
|
}
|
|
239
|
+
/** One rule-test request: the call to evaluate, nothing else. */
|
|
240
|
+
export interface DryRunRequest {
|
|
241
|
+
readonly tool: string;
|
|
242
|
+
readonly args: Record<string, unknown>;
|
|
243
|
+
readonly permissive?: boolean;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* The rule-test face the settings card calls. `run` evaluates one call against
|
|
247
|
+
* the ruleset the gate currently has loaded and returns the report from
|
|
248
|
+
* `dryRunResult`. It must not change any state — see {@link registerDryRunRoute}.
|
|
249
|
+
*/
|
|
250
|
+
export interface DryRunRouteProvider {
|
|
251
|
+
run(request: DryRunRequest): unknown;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Register `POST /api/dsh-perm-gate/dry-run` — the rule-test panel's endpoint.
|
|
255
|
+
* Body `{ tool, args?, permissive? }`; the response carries the effective
|
|
256
|
+
* verdict plus the rule layer's own answer.
|
|
257
|
+
*
|
|
258
|
+
* **Read-only by construction.** Unlike `/learning` (GET reads, POST resets), this
|
|
259
|
+
* route has no write form: it never edits rules, grants, learning state or the
|
|
260
|
+
* settings namespace, and the provider it delegates to evaluates against a
|
|
261
|
+
* throwaway or live read path only. Testing a rule must not be able to change
|
|
262
|
+
* the ruleset — otherwise "test it first" would itself be the risky action.
|
|
263
|
+
*
|
|
264
|
+
* Returns whether the route was registered.
|
|
265
|
+
*/
|
|
266
|
+
export declare function registerDryRunRoute(server: unknown, provider: DryRunRouteProvider): (() => void) | undefined;
|
|
267
|
+
/**
|
|
268
|
+
* The permissions-file face the settings card calls. `view` reads the rules file
|
|
269
|
+
* the gate is loading; it must never write it.
|
|
270
|
+
*/
|
|
271
|
+
export interface RulesRouteProvider {
|
|
272
|
+
view(): RulesView;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Register `GET /api/dsh-perm-gate/rules` — the panel's view of the permissions
|
|
276
|
+
* YAML the gate actually loads.
|
|
277
|
+
*
|
|
278
|
+
* **Read-only by construction**, like {@link registerDryRunRoute}: there is no
|
|
279
|
+
* write form. The card could already *edit* this file — the allowlist section
|
|
280
|
+
* appends and replaces patterns — but had no way to display it, so an operator
|
|
281
|
+
* changing rules through the panel could not see the document being changed.
|
|
282
|
+
* Reading must not be the risky act, and a viewer that could write would make
|
|
283
|
+
* "let me just look at the rules" a mutation.
|
|
284
|
+
*
|
|
285
|
+
* Returns whether the route was registered.
|
|
286
|
+
*/
|
|
287
|
+
export declare function registerRulesRoute(server: unknown, provider: RulesRouteProvider): (() => void) | undefined;
|
|
199
288
|
/**
|
|
200
289
|
* Register the learning-store routes on the webServer service:
|
|
201
290
|
* `GET /api/dsh-perm-gate/learning` → the store snapshot + live threshold,
|
package/lib/events.js
CHANGED
|
@@ -224,6 +224,13 @@ export class EventLog {
|
|
|
224
224
|
now;
|
|
225
225
|
snapshotsDir;
|
|
226
226
|
seq = -1; // lazily restored from the file on first append
|
|
227
|
+
/**
|
|
228
|
+
* Live subscribers, notified once per successfully appended event. They exist
|
|
229
|
+
* so a browser half can be pushed to instead of re-reading the whole JSONL on
|
|
230
|
+
* a timer; a subscriber that throws is dropped from the notification only, it
|
|
231
|
+
* never costs the append its event.
|
|
232
|
+
*/
|
|
233
|
+
listeners = new Set();
|
|
227
234
|
constructor(
|
|
228
235
|
/** JSONL path; `undefined` disables event recording entirely. */
|
|
229
236
|
filePath, now = Date.now,
|
|
@@ -266,12 +273,34 @@ export class EventLog {
|
|
|
266
273
|
if ((ev.kind === 'auto' || ev.kind === 'ask') && this.snapshotsDir !== undefined && files !== undefined) {
|
|
267
274
|
saveEventSnapshots(this.snapshotsDir, ev.id, files, input.baseDir, ev.sessionId, this.now);
|
|
268
275
|
}
|
|
276
|
+
for (const listener of this.listeners) {
|
|
277
|
+
try {
|
|
278
|
+
listener(ev);
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
// A dead subscriber must not fail the append, and must not stop the others.
|
|
282
|
+
}
|
|
283
|
+
}
|
|
269
284
|
return ev;
|
|
270
285
|
}
|
|
271
286
|
catch {
|
|
272
287
|
return undefined; // best-effort only
|
|
273
288
|
}
|
|
274
289
|
}
|
|
290
|
+
/**
|
|
291
|
+
* Watch events as they are appended. The listener is called once per event,
|
|
292
|
+
* after the JSONL write, from inside `append` — so it runs on the gate's own
|
|
293
|
+
* decision path and must stay cheap and non-throwing.
|
|
294
|
+
*
|
|
295
|
+
* @param listener - receives each appended event.
|
|
296
|
+
* @returns the unsubscribe.
|
|
297
|
+
*/
|
|
298
|
+
subscribe(listener) {
|
|
299
|
+
this.listeners.add(listener);
|
|
300
|
+
return () => {
|
|
301
|
+
this.listeners.delete(listener);
|
|
302
|
+
};
|
|
303
|
+
}
|
|
275
304
|
/** Events with `id > since`, optionally filtered to one session. */
|
|
276
305
|
query({ sessionId, since } = {}) {
|
|
277
306
|
if (this.filePath === undefined)
|
|
@@ -349,6 +378,7 @@ function readBody(req, limit = 1024 * 1024) {
|
|
|
349
378
|
});
|
|
350
379
|
}
|
|
351
380
|
export const EVENTS_ROUTE = '/api/dsh-perm-gate/events';
|
|
381
|
+
export const STREAM_ROUTE = '/api/dsh-perm-gate/stream';
|
|
352
382
|
export const LEARNING_ROUTE = '/api/dsh-perm-gate/learning';
|
|
353
383
|
export const HEALTH_ROUTE = '/api/dsh-perm-gate/health';
|
|
354
384
|
export const NETWORK_ROUTE = '/api/dsh-perm-gate/network';
|
|
@@ -357,6 +387,8 @@ export const DIFF_ROUTE = '/api/dsh-perm-gate/diff';
|
|
|
357
387
|
export const REVERT_ROUTE = '/api/dsh-perm-gate/revert';
|
|
358
388
|
export const SNAPSHOTS_STATS_ROUTE = '/api/dsh-perm-gate/snapshots-stats';
|
|
359
389
|
export const SNAPSHOTS_CLEAR_ROUTE = '/api/dsh-perm-gate/snapshots-clear';
|
|
390
|
+
export const DRY_RUN_ROUTE = '/api/dsh-perm-gate/dry-run';
|
|
391
|
+
export const RULES_ROUTE = '/api/dsh-perm-gate/rules';
|
|
360
392
|
/**
|
|
361
393
|
* Register `GET /api/dsh-perm-gate/events?sessionId=&since=` on the webServer
|
|
362
394
|
* service. Returns whether the route was registered (false when the service is
|
|
@@ -393,6 +425,104 @@ export function registerEventsRoute(server, log) {
|
|
|
393
425
|
},
|
|
394
426
|
});
|
|
395
427
|
}
|
|
428
|
+
/** Keep-alive cadence of {@link registerStreamRoute}: a comment line that keeps an idle stream from being reaped. */
|
|
429
|
+
const STREAM_PING_MS = 15_000;
|
|
430
|
+
/**
|
|
431
|
+
* Register `GET /api/dsh-perm-gate/stream?sessionId=&since=` on the webServer
|
|
432
|
+
* service: the same events {@link registerEventsRoute} serves, but pushed.
|
|
433
|
+
*
|
|
434
|
+
* The response is `text/event-stream`. On connect the backlog after `since` is
|
|
435
|
+
* written as one `data: {"events":[…],"backlog":true}` frame, then each event
|
|
436
|
+
* appended from that moment on is written as it lands as `data: {"events":[…]}` —
|
|
437
|
+
* so the browser half is never forced to re-read the whole JSONL on a timer.
|
|
438
|
+
* `: ping` comments hold the connection open while nothing is being decided.
|
|
439
|
+
*
|
|
440
|
+
* The session filter applies to both the backlog and the live pushes. Nothing
|
|
441
|
+
* is buffered for a disconnected client: it reconnects with its own cursor and
|
|
442
|
+
* the backlog frame restores it.
|
|
443
|
+
*
|
|
444
|
+
* Returns the unregister, or `undefined` when the webServer service is
|
|
445
|
+
* unavailable — the host keeps running, only the HTTP API is missing.
|
|
446
|
+
*/
|
|
447
|
+
export function registerStreamRoute(server, log) {
|
|
448
|
+
const ws = routeServer(server);
|
|
449
|
+
if (ws === undefined)
|
|
450
|
+
return undefined;
|
|
451
|
+
return ws.register({
|
|
452
|
+
kind: 'exact',
|
|
453
|
+
path: STREAM_ROUTE,
|
|
454
|
+
handler: (rawReq, rawRes) => {
|
|
455
|
+
const req = rawReq;
|
|
456
|
+
const res = rawRes;
|
|
457
|
+
if (req.method !== 'GET') {
|
|
458
|
+
res.writeHead(405);
|
|
459
|
+
res.end();
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
if (res.write === undefined) {
|
|
463
|
+
// A server that cannot stream: fail the handshake instead of hanging the browser's EventSource.
|
|
464
|
+
res.writeHead(500);
|
|
465
|
+
res.end();
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
let sessionId;
|
|
469
|
+
let since;
|
|
470
|
+
try {
|
|
471
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
472
|
+
sessionId = url.searchParams.get('sessionId') ?? undefined;
|
|
473
|
+
const raw = url.searchParams.get('since');
|
|
474
|
+
if (raw !== null && raw !== '')
|
|
475
|
+
since = Number.parseInt(raw, 10) || 0;
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
// unparseable URL: stream the unfiltered tail
|
|
479
|
+
}
|
|
480
|
+
const scoped = sessionId !== undefined && sessionId !== '';
|
|
481
|
+
res.writeHead(200, {
|
|
482
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
483
|
+
'cache-control': 'no-cache, no-transform',
|
|
484
|
+
connection: 'keep-alive',
|
|
485
|
+
'x-accel-buffering': 'no',
|
|
486
|
+
});
|
|
487
|
+
const push = (events, backlog = false) => {
|
|
488
|
+
// The backlog frame is written even when it is empty: it marks where
|
|
489
|
+
// history stops, so the consumer never mistakes a live event for a replay.
|
|
490
|
+
if (events.length === 0 && !backlog)
|
|
491
|
+
return;
|
|
492
|
+
try {
|
|
493
|
+
res.write?.(`data: ${JSON.stringify(backlog ? { events, backlog: true } : { events })}\n\n`);
|
|
494
|
+
}
|
|
495
|
+
catch {
|
|
496
|
+
// The socket died between the close event and this write; `close` owns the teardown.
|
|
497
|
+
}
|
|
498
|
+
};
|
|
499
|
+
push(log.query({ sessionId, since }), true);
|
|
500
|
+
const unsubscribe = log.subscribe((event) => {
|
|
501
|
+
if (scoped && event.sessionId !== sessionId)
|
|
502
|
+
return;
|
|
503
|
+
push([event]);
|
|
504
|
+
});
|
|
505
|
+
const keepAlive = setInterval(() => {
|
|
506
|
+
try {
|
|
507
|
+
res.write?.(': ping\n\n');
|
|
508
|
+
}
|
|
509
|
+
catch {
|
|
510
|
+
// ignore: `close` owns the teardown
|
|
511
|
+
}
|
|
512
|
+
}, STREAM_PING_MS);
|
|
513
|
+
let closed = false;
|
|
514
|
+
const close = () => {
|
|
515
|
+
if (closed)
|
|
516
|
+
return;
|
|
517
|
+
closed = true;
|
|
518
|
+
clearInterval(keepAlive);
|
|
519
|
+
unsubscribe();
|
|
520
|
+
};
|
|
521
|
+
res.on?.('close', close);
|
|
522
|
+
res.on?.('error', close);
|
|
523
|
+
},
|
|
524
|
+
});
|
|
525
|
+
}
|
|
396
526
|
/**
|
|
397
527
|
* Register the review-page routes:
|
|
398
528
|
* `GET /diff?eventId=&path=` → before/after line diff for one file
|
|
@@ -682,6 +812,96 @@ export function registerNetworkRoute(server, provider) {
|
|
|
682
812
|
},
|
|
683
813
|
});
|
|
684
814
|
}
|
|
815
|
+
/**
|
|
816
|
+
* Register `POST /api/dsh-perm-gate/dry-run` — the rule-test panel's endpoint.
|
|
817
|
+
* Body `{ tool, args?, permissive? }`; the response carries the effective
|
|
818
|
+
* verdict plus the rule layer's own answer.
|
|
819
|
+
*
|
|
820
|
+
* **Read-only by construction.** Unlike `/learning` (GET reads, POST resets), this
|
|
821
|
+
* route has no write form: it never edits rules, grants, learning state or the
|
|
822
|
+
* settings namespace, and the provider it delegates to evaluates against a
|
|
823
|
+
* throwaway or live read path only. Testing a rule must not be able to change
|
|
824
|
+
* the ruleset — otherwise "test it first" would itself be the risky action.
|
|
825
|
+
*
|
|
826
|
+
* Returns whether the route was registered.
|
|
827
|
+
*/
|
|
828
|
+
export function registerDryRunRoute(server, provider) {
|
|
829
|
+
const ws = routeServer(server);
|
|
830
|
+
if (ws === undefined)
|
|
831
|
+
return undefined;
|
|
832
|
+
return ws.register({
|
|
833
|
+
kind: 'exact',
|
|
834
|
+
path: DRY_RUN_ROUTE,
|
|
835
|
+
handler: (rawReq, rawRes) => {
|
|
836
|
+
const req = rawReq;
|
|
837
|
+
const res = rawRes;
|
|
838
|
+
if (req.method !== 'POST') {
|
|
839
|
+
json(res, 405, { ok: false, error: 'method not allowed' });
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
void (async () => {
|
|
843
|
+
try {
|
|
844
|
+
const parsed = await readBody(req);
|
|
845
|
+
const tool = parsed.tool;
|
|
846
|
+
if (typeof tool !== 'string' || tool === '') {
|
|
847
|
+
json(res, 400, { ok: false, error: 'missing tool' });
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
const args = parsed.args;
|
|
851
|
+
if (args !== undefined && (typeof args !== 'object' || args === null || Array.isArray(args))) {
|
|
852
|
+
json(res, 400, { ok: false, error: 'args must be an object' });
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
const result = provider.run({
|
|
856
|
+
tool,
|
|
857
|
+
args: (args ?? {}),
|
|
858
|
+
...(typeof parsed.permissive === 'boolean' ? { permissive: parsed.permissive } : {}),
|
|
859
|
+
});
|
|
860
|
+
json(res, 200, { ok: true, result });
|
|
861
|
+
}
|
|
862
|
+
catch (e) {
|
|
863
|
+
json(res, 500, { ok: false, error: String(e?.message ?? e) });
|
|
864
|
+
}
|
|
865
|
+
})();
|
|
866
|
+
},
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
/**
|
|
870
|
+
* Register `GET /api/dsh-perm-gate/rules` — the panel's view of the permissions
|
|
871
|
+
* YAML the gate actually loads.
|
|
872
|
+
*
|
|
873
|
+
* **Read-only by construction**, like {@link registerDryRunRoute}: there is no
|
|
874
|
+
* write form. The card could already *edit* this file — the allowlist section
|
|
875
|
+
* appends and replaces patterns — but had no way to display it, so an operator
|
|
876
|
+
* changing rules through the panel could not see the document being changed.
|
|
877
|
+
* Reading must not be the risky act, and a viewer that could write would make
|
|
878
|
+
* "let me just look at the rules" a mutation.
|
|
879
|
+
*
|
|
880
|
+
* Returns whether the route was registered.
|
|
881
|
+
*/
|
|
882
|
+
export function registerRulesRoute(server, provider) {
|
|
883
|
+
const ws = routeServer(server);
|
|
884
|
+
if (ws === undefined)
|
|
885
|
+
return undefined;
|
|
886
|
+
return ws.register({
|
|
887
|
+
kind: 'exact',
|
|
888
|
+
path: RULES_ROUTE,
|
|
889
|
+
handler: (rawReq, rawRes) => {
|
|
890
|
+
const req = rawReq;
|
|
891
|
+
const res = rawRes;
|
|
892
|
+
if (req.method !== 'GET') {
|
|
893
|
+
json(res, 405, { ok: false, error: 'method not allowed' });
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
try {
|
|
897
|
+
json(res, 200, { ok: true, result: provider.view() });
|
|
898
|
+
}
|
|
899
|
+
catch (e) {
|
|
900
|
+
json(res, 500, { ok: false, error: String(e?.message ?? e) });
|
|
901
|
+
}
|
|
902
|
+
},
|
|
903
|
+
});
|
|
904
|
+
}
|
|
685
905
|
/**
|
|
686
906
|
* Register the learning-store routes on the webServer service:
|
|
687
907
|
* `GET /api/dsh-perm-gate/learning` → the store snapshot + live threshold,
|