dsh-perm-gate 2.1.0 → 3.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 +321 -0
- package/INSTALL.ja.md +70 -8
- package/INSTALL.ko.md +68 -9
- package/INSTALL.md +68 -8
- package/INSTALL.zh.md +59 -7
- package/README.ja.md +103 -16
- package/README.ko.md +101 -17
- package/README.md +152 -25
- package/README.zh.md +99 -12
- package/cordis.patch.yml +34 -3
- package/docs/baseline-guard.md +56 -0
- package/docs/plan-rules-to-settings.md +217 -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 +22 -0
- package/examples/permissions.example.yaml +16 -1
- package/lib/agent-identity.d.ts +43 -0
- package/lib/agent-identity.js +95 -0
- package/lib/allowlist.d.ts +27 -0
- package/lib/allowlist.js +103 -1
- package/lib/cli.js +11 -14
- package/lib/client.js +598 -6
- 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 +50 -0
- package/lib/compiler.js +122 -0
- package/lib/config.d.ts +161 -7
- package/lib/config.js +78 -11
- package/lib/dry-run.d.ts +87 -0
- package/lib/dry-run.js +110 -0
- package/lib/engine.js +61 -4
- package/lib/evaluate.d.ts +9 -0
- package/lib/evaluate.js +264 -1
- package/lib/events.d.ts +69 -2
- package/lib/events.js +123 -0
- package/lib/index.d.ts +1 -1
- package/lib/index.js +320 -11
- package/lib/network-lifecycle.d.ts +107 -0
- package/lib/network-lifecycle.js +180 -0
- package/lib/network.d.ts +109 -0
- package/lib/network.js +196 -0
- 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/proxy.d.ts +160 -0
- package/lib/proxy.js +529 -0
- package/lib/risk.d.ts +17 -3
- package/lib/risk.js +19 -6
- package/lib/rule-chain.d.ts +39 -0
- package/lib/rule-chain.js +213 -0
- package/lib/rule-dims.d.ts +174 -0
- package/lib/rule-dims.js +230 -0
- package/lib/rule.d.ts +60 -0
- package/lib/rule.js +69 -7
- package/lib/rules-view.d.ts +52 -0
- package/lib/rules-view.js +126 -0
- package/lib/runtime.d.ts +208 -7
- package/lib/runtime.js +433 -27
- package/lib/session-sweep.d.ts +45 -0
- package/lib/session-sweep.js +153 -0
- package/lib/shadow.d.ts +34 -0
- package/lib/shadow.js +143 -0
- package/lib/shell.d.ts +14 -0
- package/lib/shell.js +17 -0
- package/lib/watch.d.ts +42 -0
- package/lib/watch.js +170 -0
- package/package.json +15 -2
- package/scripts/patch-permission-glyph.mjs +209 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Network proxy lifecycle for dsh-perm-gate.
|
|
3
|
+
*
|
|
4
|
+
* Owns the proxy + the subprocess environment injection and exposes the
|
|
5
|
+
* three operations the host needs:
|
|
6
|
+
*
|
|
7
|
+
* attach() — mount per the current config (called once at apply)
|
|
8
|
+
* rebind() — re-read the config and re-mount (called on a settings change)
|
|
9
|
+
* detach() — close the proxy and restore the environment
|
|
10
|
+
*
|
|
11
|
+
* Keeping this out of `index.ts` matters: the proxy has a real lifecycle
|
|
12
|
+
* (bind, env rewrite, teardown) and every step must be owned by exactly one
|
|
13
|
+
* disposer. `index.ts` only wires it.
|
|
14
|
+
*
|
|
15
|
+
* Robustness contract:
|
|
16
|
+
* - One persistent teardown effect is registered at construction, so a
|
|
17
|
+
* dispose that races an in-flight bind still closes the proxy and restores
|
|
18
|
+
* the environment.
|
|
19
|
+
* - Mutating operations are serialized: rapid settings toggles cannot
|
|
20
|
+
* interleave attach/detach and leave an orphaned listener or a rewritten
|
|
21
|
+
* `process.env`.
|
|
22
|
+
* - Nothing here throws into the host: failures degrade to "no proxy" and
|
|
23
|
+
* surface through the optional logger.
|
|
24
|
+
*/
|
|
25
|
+
import { NetworkProxy, injectProxyEnv } from './proxy.js';
|
|
26
|
+
/**
|
|
27
|
+
* Owns the proxy and the environment injection across (re)binds.
|
|
28
|
+
*/
|
|
29
|
+
export class NetworkLifecycle {
|
|
30
|
+
options;
|
|
31
|
+
proxy;
|
|
32
|
+
envRestore;
|
|
33
|
+
disposed = false;
|
|
34
|
+
/** Serializes mutations so rapid toggles cannot interleave. */
|
|
35
|
+
queue = Promise.resolve();
|
|
36
|
+
constructor(options) {
|
|
37
|
+
this.options = options;
|
|
38
|
+
// Registered ONCE, before any bind: a dispose that races the first bind
|
|
39
|
+
// still tears the proxy down and restores the environment. `effect`
|
|
40
|
+
// expects a factory returning the disposer, so the disposer is returned.
|
|
41
|
+
options.effect(() => () => {
|
|
42
|
+
this.disposed = true;
|
|
43
|
+
void this.run(() => this.detachLocked());
|
|
44
|
+
}, 'dsh-perm-gate: network proxy');
|
|
45
|
+
}
|
|
46
|
+
/** Mount the proxy per the current config. No-op when disabled or live. */
|
|
47
|
+
attach() {
|
|
48
|
+
return this.run(() => this.attachLocked());
|
|
49
|
+
}
|
|
50
|
+
/** Close the proxy and restore the environment. */
|
|
51
|
+
detach() {
|
|
52
|
+
return this.run(() => this.detachLocked());
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Re-read the config and re-mount: the old proxy is closed and the old
|
|
56
|
+
* environment restored before the new one is installed. This is what makes
|
|
57
|
+
* the settings-card switches take effect without a plugin reload.
|
|
58
|
+
*/
|
|
59
|
+
rebind() {
|
|
60
|
+
return this.run(async () => {
|
|
61
|
+
await this.detachLocked();
|
|
62
|
+
await this.attachLocked();
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
/** The diagnostics snapshot (safe to call at any time). */
|
|
66
|
+
snapshot() {
|
|
67
|
+
const cfg = this.options.readConfig();
|
|
68
|
+
const proxy = this.proxy;
|
|
69
|
+
const stats = proxy?.blockStats() ?? { denied: 0, askBlocked: 0 };
|
|
70
|
+
const port = proxy?.port ?? 0;
|
|
71
|
+
return {
|
|
72
|
+
enabled: cfg.enabled,
|
|
73
|
+
mode: cfg.mode,
|
|
74
|
+
bind: cfg.bind,
|
|
75
|
+
port,
|
|
76
|
+
proxyActive: proxy !== undefined && port > 0,
|
|
77
|
+
envInjected: this.envRestore !== undefined,
|
|
78
|
+
denied: stats.denied,
|
|
79
|
+
askBlocked: stats.askBlocked,
|
|
80
|
+
recent: proxy?.recentBlocks() ?? [],
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
// ─── Internals (always called through the queue) ─────────────────────
|
|
84
|
+
run(work) {
|
|
85
|
+
const next = this.queue.then(() => work());
|
|
86
|
+
// Keep the chain alive through a rejected step; the step itself is
|
|
87
|
+
// responsible for degrading rather than throwing.
|
|
88
|
+
this.queue = next.then(() => undefined, () => undefined);
|
|
89
|
+
return next;
|
|
90
|
+
}
|
|
91
|
+
async attachLocked() {
|
|
92
|
+
if (this.disposed)
|
|
93
|
+
return;
|
|
94
|
+
const cfg = this.options.readConfig();
|
|
95
|
+
if (!cfg.enabled)
|
|
96
|
+
return;
|
|
97
|
+
// Already live: leave it alone (a rebind detaches first).
|
|
98
|
+
if (this.proxy !== undefined && this.proxy.port > 0)
|
|
99
|
+
return;
|
|
100
|
+
// A previous attempt may have left a proxy that failed to bind; drop it
|
|
101
|
+
// so the retry starts clean.
|
|
102
|
+
await this.closeProxyLocked();
|
|
103
|
+
this.restoreEnvLocked();
|
|
104
|
+
const proxy = new NetworkProxy({
|
|
105
|
+
bind: cfg.bind,
|
|
106
|
+
port: cfg.port,
|
|
107
|
+
maxRecent: 100,
|
|
108
|
+
decide: this.options.decide,
|
|
109
|
+
...(this.options.escalate !== undefined ? { escalate: this.options.escalate } : {}),
|
|
110
|
+
attribution: this.options.attribution,
|
|
111
|
+
...(this.options.onBlock !== undefined ? { onBlock: this.options.onBlock } : {}),
|
|
112
|
+
logger: this.options.logger,
|
|
113
|
+
});
|
|
114
|
+
this.proxy = proxy;
|
|
115
|
+
const port = await proxy.start();
|
|
116
|
+
// A dispose landed while binding: the effect already ran detach, but the
|
|
117
|
+
// proxy was created after it, so close it here.
|
|
118
|
+
if (this.disposed) {
|
|
119
|
+
await this.closeProxyLocked();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (port <= 0) {
|
|
123
|
+
// Bind failed (already logged by the proxy). Keep the handle so the
|
|
124
|
+
// snapshot can report `proxyActive: false`.
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (cfg.injectEnv) {
|
|
128
|
+
try {
|
|
129
|
+
this.envRestore = injectProxyEnv(port, cfg.noProxy);
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
this.safeWarn(`[dsh-perm-gate] proxy env injection failed: ${String(error)} — the proxy is live but subprocesses are not routed through it`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
this.safeInfo(`[dsh-perm-gate] network proxy listening on ${cfg.bind}:${port} (mode ${cfg.mode}${cfg.injectEnv ? ', env injected' : ', env not injected'})`);
|
|
136
|
+
}
|
|
137
|
+
async detachLocked() {
|
|
138
|
+
this.restoreEnvLocked();
|
|
139
|
+
await this.closeProxyLocked();
|
|
140
|
+
}
|
|
141
|
+
async closeProxyLocked() {
|
|
142
|
+
const proxy = this.proxy;
|
|
143
|
+
this.proxy = undefined;
|
|
144
|
+
if (proxy === undefined)
|
|
145
|
+
return;
|
|
146
|
+
try {
|
|
147
|
+
await proxy.close();
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
this.safeWarn(`[dsh-perm-gate] proxy close failed: ${String(error)}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
restoreEnvLocked() {
|
|
154
|
+
const restore = this.envRestore;
|
|
155
|
+
this.envRestore = undefined;
|
|
156
|
+
if (restore === undefined)
|
|
157
|
+
return;
|
|
158
|
+
try {
|
|
159
|
+
restore();
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
this.safeWarn(`[dsh-perm-gate] proxy env restore failed: ${String(error)}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
safeWarn(message) {
|
|
166
|
+
try {
|
|
167
|
+
this.options.logger.warn(message);
|
|
168
|
+
}
|
|
169
|
+
catch { /* never escalate */ }
|
|
170
|
+
}
|
|
171
|
+
safeInfo(message) {
|
|
172
|
+
const info = this.options.logger.info;
|
|
173
|
+
if (info === undefined)
|
|
174
|
+
return;
|
|
175
|
+
try {
|
|
176
|
+
info.call(this.options.logger, message);
|
|
177
|
+
}
|
|
178
|
+
catch { /* never escalate */ }
|
|
179
|
+
}
|
|
180
|
+
}
|
package/lib/network.d.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure network-policy orchestration for dsh-perm-gate.
|
|
3
|
+
*
|
|
4
|
+
* Shared by the `tools/pre-execute` gate (URL candidate static check) and
|
|
5
|
+
* the local HTTP/CONNECT proxy (real subprocess traffic). Three policy modes
|
|
6
|
+
* mapped onto DSH sandbox presets, first-match evaluation over the loaded
|
|
7
|
+
* CompiledRuleset, and mode-default fallback decision. No I/O, no process
|
|
8
|
+
* state — unit-testable and replayable.
|
|
9
|
+
*
|
|
10
|
+
* Network is a **parallel execution surface** (NOT a P-layer): it shares the
|
|
11
|
+
* same CompiledRuleset / verdict vocabulary as the P0–P4 chain but does not
|
|
12
|
+
* enter the prefix numbering. The proxy layer has no session context, so
|
|
13
|
+
* `ask` decisions degrade to block + audit.
|
|
14
|
+
*/
|
|
15
|
+
import type { CompiledRuleEntry, CompiledRuleset } from './rule.js';
|
|
16
|
+
/** The three network policy modes. */
|
|
17
|
+
export type NetworkMode = 'deny-all' | 'whitelist' | 'allow-all';
|
|
18
|
+
/** Closed mode list for runtime normalization. */
|
|
19
|
+
export declare const NETWORK_MODES: readonly NetworkMode[];
|
|
20
|
+
/** The official file-sandbox presets the mode maps onto. */
|
|
21
|
+
export type SandboxModeName = 'read-only' | 'workspace-write' | 'danger-full-access';
|
|
22
|
+
/** How a whitelist-mode unlisted target is handled. */
|
|
23
|
+
export type UnlistedAction = 'ask' | 'deny';
|
|
24
|
+
/**
|
|
25
|
+
* How traffic with **no in-flight shell attribution** is handled.
|
|
26
|
+
*
|
|
27
|
+
* - `'allow'` (default) — pass through unreviewed. Such traffic is not a shell
|
|
28
|
+
* subprocess: it is DSH's own client (a built-in network tool, the LLM
|
|
29
|
+
* transport). The proxy is a *subprocess* policy surface, so reviewing the
|
|
30
|
+
* host's own traffic risks the host blocking itself — a far worse failure
|
|
31
|
+
* than a missed block. This is what keeps the built-in network tools working.
|
|
32
|
+
* - `'deny'` — review it like any other connection. Stricter, but if a
|
|
33
|
+
* built-in client ever honors the proxy environment (e.g. Node 24+ with
|
|
34
|
+
* `NODE_USE_ENV_PROXY=1`), DSH's own calls would be blocked and the harness
|
|
35
|
+
* would stop working.
|
|
36
|
+
*/
|
|
37
|
+
export type UnattributedAction = 'allow' | 'deny';
|
|
38
|
+
/** A parsed network target for one connection/URL. */
|
|
39
|
+
export interface NetworkTarget {
|
|
40
|
+
readonly host: string;
|
|
41
|
+
readonly port?: number;
|
|
42
|
+
readonly scheme?: string;
|
|
43
|
+
/** Resolved IP addresses (populated after DNS lookup). */
|
|
44
|
+
readonly ips: readonly string[];
|
|
45
|
+
}
|
|
46
|
+
/** One network decision: a rule hit or the mode-default fallback. */
|
|
47
|
+
export interface NetworkDecision {
|
|
48
|
+
readonly action: 'allow' | 'deny' | 'ask';
|
|
49
|
+
/** Whether a rule (true) or the mode default (false) produced the decision. */
|
|
50
|
+
readonly matched: boolean;
|
|
51
|
+
readonly mode: NetworkMode;
|
|
52
|
+
readonly ruleIndex?: number;
|
|
53
|
+
readonly rule?: CompiledRuleEntry;
|
|
54
|
+
/** Source file path for audit attribution. */
|
|
55
|
+
readonly source?: string;
|
|
56
|
+
/** True when the connection carried no shell attribution and was exempted. */
|
|
57
|
+
readonly unattributed?: true;
|
|
58
|
+
}
|
|
59
|
+
/** Proxy-layer evaluation options. */
|
|
60
|
+
export interface NetworkDecisionOptions {
|
|
61
|
+
readonly mode: NetworkMode;
|
|
62
|
+
readonly unlisted: UnlistedAction;
|
|
63
|
+
/** `allow` short-circuits loopback targets before rules; `policy` evaluates them normally. */
|
|
64
|
+
readonly loopback: 'allow' | 'policy';
|
|
65
|
+
/**
|
|
66
|
+
* Whether an in-flight shell execution can be attributed to this
|
|
67
|
+
* connection. `false` means the connection did not come from a subprocess
|
|
68
|
+
* the gate is tracking. Omitted = treat as attributed (pre-change behavior).
|
|
69
|
+
*/
|
|
70
|
+
readonly attributed?: boolean;
|
|
71
|
+
/** Handling for unattributed traffic. Default `'allow'`. */
|
|
72
|
+
readonly unattributed?: UnattributedAction;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Map one official sandbox preset onto its network mode.
|
|
76
|
+
* `read-only` → deny-all, `workspace-write` → whitelist,
|
|
77
|
+
* `danger-full-access` → allow-all. Unknown → fallback.
|
|
78
|
+
*/
|
|
79
|
+
export declare function networkModeForSandbox(sandbox: string | undefined, fallback: NetworkMode): NetworkMode;
|
|
80
|
+
/** The default decision a mode yields for an unmatched target. */
|
|
81
|
+
export declare function defaultDecision(mode: NetworkMode, unlisted: UnlistedAction): NetworkDecision;
|
|
82
|
+
/**
|
|
83
|
+
* Evaluate the network policy for one target against a compiled ruleset.
|
|
84
|
+
* First-match wins across deny → allow → ask partitions (deny-first).
|
|
85
|
+
*
|
|
86
|
+
* Proxy-layer tool attribution: shell subprocess connections carry
|
|
87
|
+
* `bash`/`pwsh` identity; rules scoped to other tools never fire.
|
|
88
|
+
* Agent identity is unknown at the proxy → agent-scoped rules fail-closed.
|
|
89
|
+
*
|
|
90
|
+
* **Unattributed traffic is exempt by default.** A connection that cannot be
|
|
91
|
+
* tied to an in-flight shell execution did not come from a subprocess the gate
|
|
92
|
+
* manages — it is DSH's own client. Reviewing it would let the host block
|
|
93
|
+
* itself, so it passes through (see {@link UnattributedAction}).
|
|
94
|
+
*/
|
|
95
|
+
export declare function decideNetworkTarget(ruleset: CompiledRuleset, target: NetworkTarget, options: NetworkDecisionOptions): NetworkDecision;
|
|
96
|
+
/** Whether a target addresses the loopback range. */
|
|
97
|
+
export declare function isLoopbackTarget(target: NetworkTarget): boolean;
|
|
98
|
+
/** Whether a string is an IP literal (v4 or v6). */
|
|
99
|
+
export declare function isIpLiteral(host: string): boolean;
|
|
100
|
+
/**
|
|
101
|
+
* Parse a URL string into a NetworkTarget.
|
|
102
|
+
* Returns undefined for malformed URLs.
|
|
103
|
+
*/
|
|
104
|
+
export declare function parseUrlTarget(url: string): NetworkTarget | undefined;
|
|
105
|
+
/**
|
|
106
|
+
* The short structured message a blocked proxy connection receives.
|
|
107
|
+
* `ask` at proxy layer degrades to block + audit (no session context).
|
|
108
|
+
*/
|
|
109
|
+
export declare function blockMessage(decision: NetworkDecision): string;
|
package/lib/network.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { compileCidr, compilePortSpec, compileDomainPattern } from './compiler.js';
|
|
2
|
+
/** Closed mode list for runtime normalization. */
|
|
3
|
+
export const NETWORK_MODES = ['deny-all', 'whitelist', 'allow-all'];
|
|
4
|
+
// ─── Mode mapping ──────────────────────────────────────────────────────────
|
|
5
|
+
/**
|
|
6
|
+
* Map one official sandbox preset onto its network mode.
|
|
7
|
+
* `read-only` → deny-all, `workspace-write` → whitelist,
|
|
8
|
+
* `danger-full-access` → allow-all. Unknown → fallback.
|
|
9
|
+
*/
|
|
10
|
+
export function networkModeForSandbox(sandbox, fallback) {
|
|
11
|
+
switch (sandbox) {
|
|
12
|
+
case 'read-only': return 'deny-all';
|
|
13
|
+
case 'workspace-write': return 'whitelist';
|
|
14
|
+
case 'danger-full-access': return 'allow-all';
|
|
15
|
+
default: return fallback;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** The default decision a mode yields for an unmatched target. */
|
|
19
|
+
export function defaultDecision(mode, unlisted) {
|
|
20
|
+
switch (mode) {
|
|
21
|
+
case 'deny-all':
|
|
22
|
+
return { action: 'deny', matched: false, mode };
|
|
23
|
+
case 'whitelist':
|
|
24
|
+
return { action: unlisted, matched: false, mode };
|
|
25
|
+
case 'allow-all':
|
|
26
|
+
return { action: 'allow', matched: false, mode };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// ─── Target matching ───────────────────────────────────────────────────────
|
|
30
|
+
/**
|
|
31
|
+
* Evaluate the network policy for one target against a compiled ruleset.
|
|
32
|
+
* First-match wins across deny → allow → ask partitions (deny-first).
|
|
33
|
+
*
|
|
34
|
+
* Proxy-layer tool attribution: shell subprocess connections carry
|
|
35
|
+
* `bash`/`pwsh` identity; rules scoped to other tools never fire.
|
|
36
|
+
* Agent identity is unknown at the proxy → agent-scoped rules fail-closed.
|
|
37
|
+
*
|
|
38
|
+
* **Unattributed traffic is exempt by default.** A connection that cannot be
|
|
39
|
+
* tied to an in-flight shell execution did not come from a subprocess the gate
|
|
40
|
+
* manages — it is DSH's own client. Reviewing it would let the host block
|
|
41
|
+
* itself, so it passes through (see {@link UnattributedAction}).
|
|
42
|
+
*/
|
|
43
|
+
export function decideNetworkTarget(ruleset, target, options) {
|
|
44
|
+
if (options.loopback === 'allow' && isLoopbackTarget(target)) {
|
|
45
|
+
return { action: 'allow', matched: false, mode: options.mode };
|
|
46
|
+
}
|
|
47
|
+
// Not from a shell subprocess → not this surface's to police.
|
|
48
|
+
if (options.attributed === false && (options.unattributed ?? 'allow') === 'allow') {
|
|
49
|
+
return { action: 'allow', matched: false, mode: options.mode, unattributed: true };
|
|
50
|
+
}
|
|
51
|
+
// Scan deny → allow → ask (deny-first).
|
|
52
|
+
for (const partition of [ruleset.deny, ruleset.allow, ruleset.ask]) {
|
|
53
|
+
for (const rule of partition) {
|
|
54
|
+
if (!rule.enabled)
|
|
55
|
+
continue;
|
|
56
|
+
if (rule.network === undefined)
|
|
57
|
+
continue;
|
|
58
|
+
if (!matchToolsProxy(rule))
|
|
59
|
+
continue;
|
|
60
|
+
if (!targetMatchesNetwork(target, rule.network))
|
|
61
|
+
continue;
|
|
62
|
+
return {
|
|
63
|
+
action: rule.action,
|
|
64
|
+
matched: true,
|
|
65
|
+
mode: options.mode,
|
|
66
|
+
ruleIndex: rule.index,
|
|
67
|
+
rule,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return defaultDecision(options.mode, options.unlisted);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Tool-scope check for proxy traffic: shell subprocess connections carry
|
|
75
|
+
* `bash`/`pwsh` identity. Rules scoped to other tools never fire at proxy.
|
|
76
|
+
*/
|
|
77
|
+
function matchToolsProxy(rule) {
|
|
78
|
+
if (rule.tools.length === 0)
|
|
79
|
+
return true;
|
|
80
|
+
// Match if any tool pattern matches 'bash' or 'pwsh'.
|
|
81
|
+
return rule.tools.some((t) => t.re.test('bash') || t.re.test('pwsh'));
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Check if a network target matches a rule's network dimension.
|
|
85
|
+
* All present sub-dimensions must match (AND); within a sub-dimension, entries are OR.
|
|
86
|
+
*/
|
|
87
|
+
function targetMatchesNetwork(target, network) {
|
|
88
|
+
// domains
|
|
89
|
+
if (network.domains !== undefined && network.domains.length > 0) {
|
|
90
|
+
const matchers = network.domains.map((d) => compileDomainPattern(d));
|
|
91
|
+
if (!matchers.some((m) => m.re.test(target.host)))
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
// ips
|
|
95
|
+
if (network.ips !== undefined && network.ips.length > 0) {
|
|
96
|
+
const allIps = [...target.ips];
|
|
97
|
+
if (target.host !== undefined)
|
|
98
|
+
allIps.push(target.host);
|
|
99
|
+
const cidrs = network.ips.filter((s) => s.includes('/'));
|
|
100
|
+
const literals = network.ips.filter((s) => !s.includes('/'));
|
|
101
|
+
const ipMatch = allIps.some((ip) => literals.includes(ip) || cidrs.some((c) => {
|
|
102
|
+
try {
|
|
103
|
+
return compileCidr(c)(ip);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}));
|
|
109
|
+
if (!ipMatch)
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
// ports
|
|
113
|
+
if (network.ports !== undefined && network.ports.length > 0) {
|
|
114
|
+
if (target.port === undefined)
|
|
115
|
+
return false;
|
|
116
|
+
const matchers = network.ports.map((p) => {
|
|
117
|
+
try {
|
|
118
|
+
return compilePortSpec(p);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return (_) => false;
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
if (!matchers.some((m) => m(target.port)))
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
// schemes
|
|
128
|
+
if (network.schemes !== undefined && network.schemes.length > 0) {
|
|
129
|
+
if (target.scheme === undefined)
|
|
130
|
+
return false;
|
|
131
|
+
if (!network.schemes.includes(target.scheme))
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
137
|
+
/** Whether a target addresses the loopback range. */
|
|
138
|
+
export function isLoopbackTarget(target) {
|
|
139
|
+
const host = target.host;
|
|
140
|
+
if (host === 'localhost' || host === '::1' || host === '0:0:0:0:0:0:0:1')
|
|
141
|
+
return true;
|
|
142
|
+
const parts = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
143
|
+
if (parts === null)
|
|
144
|
+
return false;
|
|
145
|
+
return parts.slice(1).every((o) => Number(o) <= 255) && parts[1] === '127';
|
|
146
|
+
}
|
|
147
|
+
/** Whether a string is an IP literal (v4 or v6). */
|
|
148
|
+
export function isIpLiteral(host) {
|
|
149
|
+
return /^[\d.:a-fA-F]+$/.test(host) && (host.includes(':') || /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host));
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Parse a URL string into a NetworkTarget.
|
|
153
|
+
* Returns undefined for malformed URLs.
|
|
154
|
+
*/
|
|
155
|
+
export function parseUrlTarget(url) {
|
|
156
|
+
try {
|
|
157
|
+
const u = new URL(url);
|
|
158
|
+
const host = u.hostname.toLowerCase().replace(/\.+$/, '');
|
|
159
|
+
if (host.length === 0)
|
|
160
|
+
return undefined;
|
|
161
|
+
const port = u.port !== '' ? Number(u.port) : (u.protocol === 'https:' ? 443 : u.protocol === 'http:' ? 80 : undefined);
|
|
162
|
+
return {
|
|
163
|
+
host,
|
|
164
|
+
port: port !== undefined && Number.isFinite(port) ? port : undefined,
|
|
165
|
+
scheme: u.protocol.replace(/:$/, ''),
|
|
166
|
+
ips: isIpLiteral(host) ? [host] : [],
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* The short structured message a blocked proxy connection receives.
|
|
175
|
+
* `ask` at proxy layer degrades to block + audit (no session context).
|
|
176
|
+
*/
|
|
177
|
+
export function blockMessage(decision) {
|
|
178
|
+
const reason = decision.rule?.reason ?? '';
|
|
179
|
+
if (decision.matched) {
|
|
180
|
+
const rulePart = decision.ruleIndex !== undefined ? ` by rule ${decision.ruleIndex + 1}` : '';
|
|
181
|
+
if (decision.action === 'ask') {
|
|
182
|
+
return `[network: blocked pending approval${rulePart}] ${reason}\n(subprocess connections cannot ride the interactive approval seam — ask your user or add an allow rule for this target)`;
|
|
183
|
+
}
|
|
184
|
+
return `[network: denied${rulePart}] ${reason}`;
|
|
185
|
+
}
|
|
186
|
+
switch (decision.mode) {
|
|
187
|
+
case 'deny-all':
|
|
188
|
+
return '[network: denied] network mode deny-all (read-only sandbox preset): only whitelisted targets are reachable';
|
|
189
|
+
case 'whitelist':
|
|
190
|
+
return decision.action === 'ask'
|
|
191
|
+
? '[network: blocked pending approval] whitelist mode: the target is not matched by an allow rule'
|
|
192
|
+
: '[network: denied] whitelist mode: the target is not matched by an allow rule';
|
|
193
|
+
case 'allow-all':
|
|
194
|
+
return '[network: denied]';
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git command parser - extracts subcommand, branch, remote, flags, destructiveness.
|
|
3
|
+
* P0 priority - most critical for branch permissions.
|
|
4
|
+
*
|
|
5
|
+
* Handles:
|
|
6
|
+
* - git push/force push detection
|
|
7
|
+
* - git branch operations (delete, rename)
|
|
8
|
+
* - git checkout/switch with branch names
|
|
9
|
+
* - git merge/rebase
|
|
10
|
+
* - git reset (soft/hard/mixed)
|
|
11
|
+
* - git clean (destructive file deletion)
|
|
12
|
+
* - git stash operations
|
|
13
|
+
* - git refspec parsing (HEAD:main)
|
|
14
|
+
* - Remote vs branch name disambiguation
|
|
15
|
+
*/
|
|
16
|
+
import type { CommandParser, CommandSemantics } from '../command-semantics.js';
|
|
17
|
+
export declare class GitParser implements CommandParser {
|
|
18
|
+
readonly name = "git";
|
|
19
|
+
readonly priority = 10;
|
|
20
|
+
canParse(command: string): boolean;
|
|
21
|
+
parse(command: string): CommandSemantics | null;
|
|
22
|
+
private analyzeSubcommand;
|
|
23
|
+
private parseGitArgs;
|
|
24
|
+
private extractFlags;
|
|
25
|
+
private extractRemote;
|
|
26
|
+
private extractBranch;
|
|
27
|
+
private extractBranchName;
|
|
28
|
+
private extractRefs;
|
|
29
|
+
/**
|
|
30
|
+
* 目标是否落在保护分支上。
|
|
31
|
+
*
|
|
32
|
+
* **只剥已知的 ref 前缀,不对任意 `/` 取末段。** 早先的写法是
|
|
33
|
+
* `branch.split('/').pop()`,于是:
|
|
34
|
+
* - `refs/heads/main` → `main` ✅ 期望如此
|
|
35
|
+
* - `backup/main` → `main` ❌ **误判**(它是含斜杠的普通分支名)
|
|
36
|
+
* - `feat/release` → `release` ❌ **误判**
|
|
37
|
+
*
|
|
38
|
+
* 误判方向不是"更安全"。这条判据喂给 P0 硬拒——而 P0 是**不可配置撤销**的
|
|
39
|
+
* (见 engine.ts 顶部:"deterministic, monotonic, never negotiated")。
|
|
40
|
+
* 漏判还有关键词层与 LLM 层兜底,误判则是"合法工作流被永久挡住且无出口"。
|
|
41
|
+
*/
|
|
42
|
+
private isProtectedBranch;
|
|
43
|
+
}
|