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.
Files changed (77) hide show
  1. package/CHANGELOG.ja.md +82 -0
  2. package/CHANGELOG.ko.md +80 -0
  3. package/CHANGELOG.md +321 -0
  4. package/INSTALL.ja.md +70 -8
  5. package/INSTALL.ko.md +68 -9
  6. package/INSTALL.md +68 -8
  7. package/INSTALL.zh.md +59 -7
  8. package/README.ja.md +103 -16
  9. package/README.ko.md +101 -17
  10. package/README.md +152 -25
  11. package/README.zh.md +99 -12
  12. package/cordis.patch.yml +34 -3
  13. package/docs/baseline-guard.md +56 -0
  14. package/docs/plan-rules-to-settings.md +217 -0
  15. package/docs/rules-format.en.md +294 -0
  16. package/docs/rules-format.md +279 -0
  17. package/docs/ui-gap-analysis-and-branch-permissions.md +338 -0
  18. package/dsh.plugin.json +22 -0
  19. package/examples/permissions.example.yaml +16 -1
  20. package/lib/agent-identity.d.ts +43 -0
  21. package/lib/agent-identity.js +95 -0
  22. package/lib/allowlist.d.ts +27 -0
  23. package/lib/allowlist.js +103 -1
  24. package/lib/cli.js +11 -14
  25. package/lib/client.js +598 -6
  26. package/lib/client.js.map +1 -1
  27. package/lib/command-dispatcher.d.ts +22 -0
  28. package/lib/command-dispatcher.js +97 -0
  29. package/lib/command-semantics.d.ts +62 -0
  30. package/lib/command-semantics.js +6 -0
  31. package/lib/compiler.d.ts +50 -0
  32. package/lib/compiler.js +122 -0
  33. package/lib/config.d.ts +161 -7
  34. package/lib/config.js +78 -11
  35. package/lib/dry-run.d.ts +87 -0
  36. package/lib/dry-run.js +110 -0
  37. package/lib/engine.js +61 -4
  38. package/lib/evaluate.d.ts +9 -0
  39. package/lib/evaluate.js +264 -1
  40. package/lib/events.d.ts +69 -2
  41. package/lib/events.js +123 -0
  42. package/lib/index.d.ts +1 -1
  43. package/lib/index.js +320 -11
  44. package/lib/network-lifecycle.d.ts +107 -0
  45. package/lib/network-lifecycle.js +180 -0
  46. package/lib/network.d.ts +109 -0
  47. package/lib/network.js +196 -0
  48. package/lib/parsers/git.d.ts +43 -0
  49. package/lib/parsers/git.js +404 -0
  50. package/lib/parsers/shell-cmds.d.ts +32 -0
  51. package/lib/parsers/shell-cmds.js +293 -0
  52. package/lib/preset.d.ts +13 -0
  53. package/lib/preset.js +17 -0
  54. package/lib/proxy.d.ts +160 -0
  55. package/lib/proxy.js +529 -0
  56. package/lib/risk.d.ts +17 -3
  57. package/lib/risk.js +19 -6
  58. package/lib/rule-chain.d.ts +39 -0
  59. package/lib/rule-chain.js +213 -0
  60. package/lib/rule-dims.d.ts +174 -0
  61. package/lib/rule-dims.js +230 -0
  62. package/lib/rule.d.ts +60 -0
  63. package/lib/rule.js +69 -7
  64. package/lib/rules-view.d.ts +52 -0
  65. package/lib/rules-view.js +126 -0
  66. package/lib/runtime.d.ts +208 -7
  67. package/lib/runtime.js +433 -27
  68. package/lib/session-sweep.d.ts +45 -0
  69. package/lib/session-sweep.js +153 -0
  70. package/lib/shadow.d.ts +34 -0
  71. package/lib/shadow.js +143 -0
  72. package/lib/shell.d.ts +14 -0
  73. package/lib/shell.js +17 -0
  74. package/lib/watch.d.ts +42 -0
  75. package/lib/watch.js +170 -0
  76. package/package.json +15 -2
  77. 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
+ }
@@ -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
+ }