dsh-perm-gate 2.1.2 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.ja.md +82 -0
- package/CHANGELOG.ko.md +80 -0
- package/CHANGELOG.md +210 -0
- package/INSTALL.ja.md +14 -8
- package/INSTALL.ko.md +15 -9
- package/INSTALL.md +16 -8
- package/INSTALL.zh.md +12 -7
- package/README.ja.md +19 -6
- package/README.ko.md +20 -7
- package/README.md +35 -4
- package/README.zh.md +13 -4
- 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 +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 +484 -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 +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 +56 -2
- package/lib/events.js +92 -0
- package/lib/index.js +106 -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 +19 -2
package/lib/rule.js
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* nothing matches, `defaultAction` applies.
|
|
10
10
|
*/
|
|
11
11
|
import { parse } from 'yaml';
|
|
12
|
-
import { compileGlob, hashText } from './compiler.js';
|
|
13
|
-
import { parseParamsDimension, parseAbsentDimension, parseAgentsDimension, parseWhenDimension, parseArgvDimension, parseNetworkDimension, } from './rule-dims.js';
|
|
12
|
+
import { compileGlob, compileBranchPattern, hashText } from './compiler.js';
|
|
13
|
+
import { parseParamsDimension, parseAbsentDimension, parseAgentsDimension, parseWhenDimension, parseArgvDimension, parseNetworkDimension, parseBranchDimension, } from './rule-dims.js';
|
|
14
14
|
const VALID_ACTIONS = ['allow', 'ask', 'deny'];
|
|
15
15
|
const PATH_CANDIDATE_KEYS = [
|
|
16
16
|
'path', 'paths', 'file', 'files', 'file_path', 'dir', 'directory', 'directories',
|
|
@@ -54,6 +54,17 @@ export function parsePermissionsDocument(text) {
|
|
|
54
54
|
catch (error) {
|
|
55
55
|
throw new RuleError(`invalid YAML: ${String(error)}`);
|
|
56
56
|
}
|
|
57
|
+
return parsePermissionsObject(raw);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Parse a permissions document given as an already-parsed object — the shape
|
|
61
|
+
* the DSH settings namespace stores (the JSON twin of the YAML file). Accepts
|
|
62
|
+
* both the bare `{ defaultAction, deny, allow, ask }` form and the file's
|
|
63
|
+
* `permissions:`-wrapped form. Malformed input fails loud, exactly like the
|
|
64
|
+
* YAML path, so a settings doc that cannot compile is a state the operator
|
|
65
|
+
* sees, never a silent ruleset change.
|
|
66
|
+
*/
|
|
67
|
+
export function parsePermissionsObject(raw) {
|
|
57
68
|
if (raw === null || raw === undefined) {
|
|
58
69
|
return { defaultAction: 'ask', deny: [], allow: [], ask: [] };
|
|
59
70
|
}
|
|
@@ -83,7 +94,7 @@ export function parsePermissionsDocument(text) {
|
|
|
83
94
|
function parseRuleEntry(raw, action, at) {
|
|
84
95
|
if (!isRecord(raw))
|
|
85
96
|
throw new RuleError(`${at} must be a mapping`);
|
|
86
|
-
const VALID_KEYS = ['tools', 'command', 'args', 'paths', 'params', 'absent', 'agents', 'when', 'argv', 'network', 'action', 'reason', 'enabled'];
|
|
97
|
+
const VALID_KEYS = ['tools', 'command', 'args', 'paths', 'params', 'absent', 'agents', 'when', 'argv', 'network', 'branch', 'action', 'reason', 'enabled'];
|
|
87
98
|
const unknown = Object.keys(raw).filter((k) => !VALID_KEYS.includes(k));
|
|
88
99
|
if (unknown.length > 0) {
|
|
89
100
|
throw new RuleError(`${at} unknown field${unknown.length > 1 ? 's' : ''} ${unknown.map((k) => JSON.stringify(k)).join(', ')}`);
|
|
@@ -106,6 +117,7 @@ function parseRuleEntry(raw, action, at) {
|
|
|
106
117
|
when: parseWhenDimension(raw.when, `${at}.when`),
|
|
107
118
|
argv: parseArgvDimension(raw.argv, `${at}.argv`),
|
|
108
119
|
network: parseNetworkDimension(raw.network, `${at}.network`),
|
|
120
|
+
branch: parseBranchDimension(raw.branch, `${at}.branch`),
|
|
109
121
|
action,
|
|
110
122
|
reason: reason === undefined ? `${action}` : reason,
|
|
111
123
|
enabled: raw.enabled === undefined ? true : typeof raw.enabled === 'boolean' ? raw.enabled : (() => { throw new RuleError(`${at}.enabled must be a boolean`); })(),
|
|
@@ -141,10 +153,32 @@ function compileCommand(entry, opts) {
|
|
|
141
153
|
}
|
|
142
154
|
return { word: compileGlob(word, { segments: false, maxStars: opts.maxGlobStars ?? 2 }), flag };
|
|
143
155
|
}
|
|
144
|
-
/**
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
156
|
+
/**
|
|
157
|
+
* Compile the `branch` dimension (undefined = the rule does not constrain on it).
|
|
158
|
+
*
|
|
159
|
+
* Glob degree is bounded inside `compileBranchPattern` (DEFAULT_MAX_STARS), so
|
|
160
|
+
* this takes no options: branch patterns are never path-segmented.
|
|
161
|
+
*/
|
|
162
|
+
function compileBranchDimension(dim) {
|
|
163
|
+
if (dim === undefined)
|
|
164
|
+
return undefined;
|
|
165
|
+
return {
|
|
166
|
+
target: (dim.target ?? []).map((p) => compileBranchPattern(p)),
|
|
167
|
+
remote: (dim.remote ?? []).map((p) => compileBranchPattern(p)),
|
|
168
|
+
shared: dim.shared === true,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Compile one parsed entry into a hot-path rule.
|
|
173
|
+
*
|
|
174
|
+
* Exported because the multi-file rule chain (`rule-chain.ts`) merges entries
|
|
175
|
+
* from several documents into one ruleset and must produce the SAME shape here
|
|
176
|
+
* — an entry built by hand would silently drop every compiled dimension, and an
|
|
177
|
+
* empty dimension means "no constraint", i.e. "matches everything".
|
|
178
|
+
*/
|
|
179
|
+
export function compileRuleEntry(entry, action, index, opts = {}) {
|
|
180
|
+
return {
|
|
181
|
+
index,
|
|
148
182
|
action,
|
|
149
183
|
reason: entry.reason,
|
|
150
184
|
enabled: entry.enabled,
|
|
@@ -158,8 +192,13 @@ export function compileDocument(doc, opts = {}) {
|
|
|
158
192
|
when: entry.when,
|
|
159
193
|
argv: entry.argv,
|
|
160
194
|
network: entry.network,
|
|
195
|
+
branch: compileBranchDimension(entry.branch),
|
|
161
196
|
source: entry,
|
|
162
|
-
}
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
/** Compile a validated document into hot-path rules. */
|
|
200
|
+
export function compileDocument(doc, opts = {}) {
|
|
201
|
+
const comp = (list, action, offset) => list.map((entry, i) => compileRuleEntry(entry, action, offset + i, opts));
|
|
163
202
|
return {
|
|
164
203
|
defaultAction: doc.defaultAction,
|
|
165
204
|
deny: comp(doc.deny, 'deny', 0),
|
|
@@ -168,6 +207,15 @@ export function compileDocument(doc, opts = {}) {
|
|
|
168
207
|
caseInsensitivePaths: opts.caseInsensitivePaths ?? false,
|
|
169
208
|
};
|
|
170
209
|
}
|
|
210
|
+
/**
|
|
211
|
+
* Compile rules given as a structured settings object (already parsed — no
|
|
212
|
+
* YAML in the loop). The settings-first half of the dual-source read path:
|
|
213
|
+
* `parsePermissionsObject` validates the JSON form exactly as strictly as the
|
|
214
|
+
* YAML path, then the standard `compileDocument` runs.
|
|
215
|
+
*/
|
|
216
|
+
export function compileRulesObject(root, opts = {}) {
|
|
217
|
+
return compileDocument(parsePermissionsObject(root), opts);
|
|
218
|
+
}
|
|
171
219
|
/** SHA-256 hash of the raw document (compile-cache key without recompiling). */
|
|
172
220
|
export function documentHash(text) {
|
|
173
221
|
return hashText(text);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { type RuleAction } from './rule.js';
|
|
2
|
+
/** Rules per action, so the panel can say "3 deny / 7 allow / 1 ask" at a glance. */
|
|
3
|
+
export interface RulesViewCounts {
|
|
4
|
+
readonly allow: number;
|
|
5
|
+
readonly deny: number;
|
|
6
|
+
readonly ask: number;
|
|
7
|
+
}
|
|
8
|
+
/** The whole read-only face of one permissions document. */
|
|
9
|
+
export interface RulesView {
|
|
10
|
+
/** Absolute path, or `''` when the gate has no rules file configured. */
|
|
11
|
+
readonly path: string;
|
|
12
|
+
readonly exists: boolean;
|
|
13
|
+
/** File size on disk, not the length of `raw` — a truncated view still reports the truth. */
|
|
14
|
+
readonly bytes: number;
|
|
15
|
+
/** Content hash of the text the gate would load, for correlating with a reload. */
|
|
16
|
+
readonly hash: string;
|
|
17
|
+
readonly lines: number;
|
|
18
|
+
/** The document itself. Empty when absent, unreadable, or truncated away. */
|
|
19
|
+
readonly raw: string;
|
|
20
|
+
readonly truncated: boolean;
|
|
21
|
+
/** Parsed only when the document compiled; absent when it did not. */
|
|
22
|
+
readonly defaultAction?: RuleAction;
|
|
23
|
+
readonly counts: RulesViewCounts;
|
|
24
|
+
/** Why the document could not be shown or parsed. Absent when all is well. */
|
|
25
|
+
readonly error?: string;
|
|
26
|
+
/** Where the rules were read from. Absent on views produced before the settings migration. */
|
|
27
|
+
readonly source?: 'file' | 'settings';
|
|
28
|
+
/** The structured rules object the settings namespace stores (settings source only). */
|
|
29
|
+
readonly rules?: unknown;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Read one permissions document for display.
|
|
33
|
+
*
|
|
34
|
+
* Never throws: a missing, unreadable or malformed file is a state the panel has
|
|
35
|
+
* to render, not an error that should blank the section. Every failure mode is
|
|
36
|
+
* reported in the returned value instead.
|
|
37
|
+
*/
|
|
38
|
+
export declare function readRulesView(rulesFile: string): RulesView;
|
|
39
|
+
/** The pseudo-path a settings view reports (there is no file to name). */
|
|
40
|
+
export declare const SETTINGS_RULES_VIEW_PATH = "settings:dsh-perm-gate-rules";
|
|
41
|
+
/**
|
|
42
|
+
* Render the settings-sourced rules for display — the read-only face of the
|
|
43
|
+
* `dsh-perm-gate-rules` namespace, the twin of {@link readRulesView}.
|
|
44
|
+
*
|
|
45
|
+
* The YAML rendering of the structured object fills `raw`, so the settings card
|
|
46
|
+
* shows the document in the same format the file view used; `rules` carries the
|
|
47
|
+
* structured object itself and `source: 'settings'` states the provenance.
|
|
48
|
+
*
|
|
49
|
+
* Never throws: a doc that fails `compileRulesObject` is a state the panel has
|
|
50
|
+
* to render (the `error` field), not an error that should blank the section.
|
|
51
|
+
*/
|
|
52
|
+
export declare function readRulesViewFromSettings(rules: unknown): RulesView;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading the permissions YAML, for the panel that shows it.
|
|
3
|
+
*
|
|
4
|
+
* The card could already WRITE the rules file — the allowlist section appends
|
|
5
|
+
* and replaces patterns, and `index.ts` mirrors those edits back into the
|
|
6
|
+
* document. Nothing could read it back, so an operator editing patterns through
|
|
7
|
+
* the panel had no way to see the document they were editing: the file the gate
|
|
8
|
+
* actually loads was invisible from the UI.
|
|
9
|
+
*
|
|
10
|
+
* This is the missing read half. It is deliberately **read-only** — a view must
|
|
11
|
+
* never be able to change the ruleset it is describing, or "let me look at the
|
|
12
|
+
* rules" becomes a risky action. Writing stays where it already lives
|
|
13
|
+
* (`allowlist.ts`, the settings namespace).
|
|
14
|
+
*/
|
|
15
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
16
|
+
import { stringify } from 'yaml';
|
|
17
|
+
import { RuleError, compileDocument, compileRulesObject, documentHash, parsePermissionsDocument } from './rule.js';
|
|
18
|
+
/** Beyond this the panel would be rendering a log, not a rules file. */
|
|
19
|
+
const MAX_VIEW_BYTES = 512 * 1024;
|
|
20
|
+
const NO_COUNTS = { allow: 0, deny: 0, ask: 0 };
|
|
21
|
+
/**
|
|
22
|
+
* Read one permissions document for display.
|
|
23
|
+
*
|
|
24
|
+
* Never throws: a missing, unreadable or malformed file is a state the panel has
|
|
25
|
+
* to render, not an error that should blank the section. Every failure mode is
|
|
26
|
+
* reported in the returned value instead.
|
|
27
|
+
*/
|
|
28
|
+
export function readRulesView(rulesFile) {
|
|
29
|
+
if (rulesFile === '') {
|
|
30
|
+
return {
|
|
31
|
+
path: '', exists: false, bytes: 0, hash: '', lines: 0, raw: '', truncated: false,
|
|
32
|
+
counts: NO_COUNTS, error: 'no rules file is configured',
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
let bytes = 0;
|
|
36
|
+
let exists = true;
|
|
37
|
+
try {
|
|
38
|
+
bytes = statSync(rulesFile).size;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
exists = false;
|
|
42
|
+
}
|
|
43
|
+
let raw = '';
|
|
44
|
+
try {
|
|
45
|
+
raw = readFileSync(rulesFile, 'utf8');
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
return {
|
|
49
|
+
path: rulesFile, exists, bytes, hash: '', lines: 0, raw: '', truncated: false,
|
|
50
|
+
counts: NO_COUNTS,
|
|
51
|
+
error: exists ? `unreadable: ${String(e?.message ?? e)}` : 'file does not exist',
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const lines = raw === '' ? 0 : raw.split('\n').length;
|
|
55
|
+
const truncated = raw.length > MAX_VIEW_BYTES;
|
|
56
|
+
const shown = truncated ? raw.slice(0, MAX_VIEW_BYTES) : raw;
|
|
57
|
+
// Parsing is what makes the view worth having: a YAML file that loads as text
|
|
58
|
+
// but not as rules is exactly the failure the panel exists to expose.
|
|
59
|
+
try {
|
|
60
|
+
const doc = parsePermissionsDocument(raw);
|
|
61
|
+
const ruleset = compileDocument(doc);
|
|
62
|
+
return {
|
|
63
|
+
path: rulesFile, exists: true, bytes, hash: documentHash(raw), lines,
|
|
64
|
+
raw: shown, truncated,
|
|
65
|
+
defaultAction: ruleset.defaultAction,
|
|
66
|
+
counts: { allow: ruleset.allow.length, deny: ruleset.deny.length, ask: ruleset.ask.length },
|
|
67
|
+
source: 'file',
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
const detail = e instanceof RuleError ? e.message : String(e?.message ?? e);
|
|
72
|
+
return {
|
|
73
|
+
path: rulesFile, exists: true, bytes, hash: documentHash(raw), lines,
|
|
74
|
+
raw: shown, truncated,
|
|
75
|
+
counts: NO_COUNTS,
|
|
76
|
+
error: `does not compile: ${detail}`,
|
|
77
|
+
source: 'file',
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** The pseudo-path a settings view reports (there is no file to name). */
|
|
82
|
+
export const SETTINGS_RULES_VIEW_PATH = 'settings:dsh-perm-gate-rules';
|
|
83
|
+
/**
|
|
84
|
+
* Render the settings-sourced rules for display — the read-only face of the
|
|
85
|
+
* `dsh-perm-gate-rules` namespace, the twin of {@link readRulesView}.
|
|
86
|
+
*
|
|
87
|
+
* The YAML rendering of the structured object fills `raw`, so the settings card
|
|
88
|
+
* shows the document in the same format the file view used; `rules` carries the
|
|
89
|
+
* structured object itself and `source: 'settings'` states the provenance.
|
|
90
|
+
*
|
|
91
|
+
* Never throws: a doc that fails `compileRulesObject` is a state the panel has
|
|
92
|
+
* to render (the `error` field), not an error that should blank the section.
|
|
93
|
+
*/
|
|
94
|
+
export function readRulesViewFromSettings(rules) {
|
|
95
|
+
let raw = '';
|
|
96
|
+
try {
|
|
97
|
+
raw = stringify(rules ?? {});
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
raw = '';
|
|
101
|
+
}
|
|
102
|
+
const lines = raw === '' ? 0 : raw.split('\n').length;
|
|
103
|
+
const hash = documentHash(JSON.stringify(rules ?? {}));
|
|
104
|
+
try {
|
|
105
|
+
const ruleset = compileRulesObject(rules);
|
|
106
|
+
return {
|
|
107
|
+
path: SETTINGS_RULES_VIEW_PATH, exists: true, bytes: raw.length, hash, lines,
|
|
108
|
+
raw, truncated: false,
|
|
109
|
+
defaultAction: ruleset.defaultAction,
|
|
110
|
+
counts: { allow: ruleset.allow.length, deny: ruleset.deny.length, ask: ruleset.ask.length },
|
|
111
|
+
source: 'settings',
|
|
112
|
+
rules,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
catch (e) {
|
|
116
|
+
const detail = e instanceof RuleError ? e.message : String(e?.message ?? e);
|
|
117
|
+
return {
|
|
118
|
+
path: SETTINGS_RULES_VIEW_PATH, exists: true, bytes: raw.length, hash, lines,
|
|
119
|
+
raw, truncated: false,
|
|
120
|
+
counts: NO_COUNTS,
|
|
121
|
+
error: `does not compile: ${detail}`,
|
|
122
|
+
source: 'settings',
|
|
123
|
+
rules,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|
package/lib/runtime.d.ts
CHANGED
|
@@ -1,17 +1,42 @@
|
|
|
1
|
-
import { type AuditEntry } from './audit.js';
|
|
2
|
-
import { type PermGateConfig, type PermissiveStrategies } from './config.js';
|
|
1
|
+
import { type AuditEntry, type DecisionSource } from './audit.js';
|
|
2
|
+
import { type PermGateConfig, type PermissiveStrategies, type RulesConfig } from './config.js';
|
|
3
3
|
import type { ClassifierConfig } from './classifier.js';
|
|
4
4
|
import { EventLog } from './events.js';
|
|
5
5
|
import { RiskLearning } from './learning.js';
|
|
6
6
|
import { type RiskRequest, type RiskVerdict } from './risk.js';
|
|
7
7
|
import { type HostLlmLike, type HostModelSelection } from './host-llm.js';
|
|
8
|
-
import { type CompiledRuleset } from './rule.js';
|
|
8
|
+
import { type CompiledRuleEntry, type CompiledRuleset, type RuleAction } from './rule.js';
|
|
9
9
|
import type { NetworkTarget } from './network.js';
|
|
10
10
|
import { type SessionEventLike } from './preset.js';
|
|
11
11
|
export interface PreToolDecisionLike {
|
|
12
12
|
kind: 'deny' | 'ask';
|
|
13
13
|
reason: string;
|
|
14
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* The P2 rule chain's own verdict for one call, as reported by
|
|
17
|
+
* {@link PermGateRuntime.explainRules}. Distinct from the gate's effective
|
|
18
|
+
* verdict: a P0 hard-deny or a preset deny-keyword fires *before* this layer and
|
|
19
|
+
* never leaves a rule index behind, so a differing pair is information rather
|
|
20
|
+
* than a contradiction.
|
|
21
|
+
*/
|
|
22
|
+
export interface RuleExplanation {
|
|
23
|
+
readonly action: RuleAction;
|
|
24
|
+
readonly reason: string;
|
|
25
|
+
readonly ruleIndex: number | undefined;
|
|
26
|
+
readonly rule: CompiledRuleEntry | undefined;
|
|
27
|
+
readonly defaultAction: RuleAction;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The pure policy outcome for one call, as reported by
|
|
31
|
+
* {@link PermGateRuntime.explainCall}. Same chain as the host-facing decision,
|
|
32
|
+
* minus every side effect and minus the session-state layers.
|
|
33
|
+
*/
|
|
34
|
+
export interface CallExplanation {
|
|
35
|
+
readonly action: RuleAction;
|
|
36
|
+
readonly reason: string;
|
|
37
|
+
readonly source: DecisionSource | 'deny-keyword' | 'cleanup-safe';
|
|
38
|
+
readonly ruleIndex?: number;
|
|
39
|
+
}
|
|
15
40
|
export interface ToolExecutionLike {
|
|
16
41
|
readonly name: string;
|
|
17
42
|
readonly arguments: Record<string, unknown>;
|
|
@@ -153,6 +178,24 @@ export interface PermGateRuntimeOptions extends PermGateConfig {
|
|
|
153
178
|
* Default 30 minutes.
|
|
154
179
|
*/
|
|
155
180
|
readonly networkGrantTtlMs?: number;
|
|
181
|
+
/**
|
|
182
|
+
* Live settings-sourced rules document (the `dsh-perm-gate-rules` namespace),
|
|
183
|
+
* read on every (re)compile. When it carries a real configuration — entries or
|
|
184
|
+
* a non-default `defaultAction` — it takes precedence over `rulesFile` and the
|
|
185
|
+
* rule chain (settings first, file fallback); `undefined` means "namespace
|
|
186
|
+
* still at bare defaults", and the file paths run unchanged.
|
|
187
|
+
*/
|
|
188
|
+
readonly readRulesDocument?: () => RulesConfig | undefined;
|
|
189
|
+
/**
|
|
190
|
+
* Settings-backed allowlist writer. When wired (the settings service is
|
|
191
|
+
* available), {@link approveAllowEverywhere} / {@link setAllowlist} go through
|
|
192
|
+
* it instead of rewriting the rules file; a falsy verdict (no scope, rejected
|
|
193
|
+
* write) falls back to the file path.
|
|
194
|
+
*/
|
|
195
|
+
readonly allowlistWriter?: {
|
|
196
|
+
readonly append: (pattern: string, reason: string) => boolean;
|
|
197
|
+
readonly replace: (patterns: readonly string[], reason: string) => boolean;
|
|
198
|
+
};
|
|
156
199
|
}
|
|
157
200
|
export type CallDecision = 'allow' | 'deny' | 'ask';
|
|
158
201
|
/** The effective Permissive tier state read per tool call. */
|
|
@@ -227,6 +270,12 @@ export declare class PermGateRuntime {
|
|
|
227
270
|
* frozen events, which would otherwise re-fold on every tool call.
|
|
228
271
|
*/
|
|
229
272
|
private presetCache;
|
|
273
|
+
/**
|
|
274
|
+
* Last stand-down announced per session (sessionId → the out-of-scope preset
|
|
275
|
+
* name, `''` when the session records none). Bounded by the session count and
|
|
276
|
+
* only read on the stand-down path — the in-scope path never touches it.
|
|
277
|
+
*/
|
|
278
|
+
private readonly standDownAnnounced;
|
|
230
279
|
private readonly learning;
|
|
231
280
|
private readonly events?;
|
|
232
281
|
/** Learning candidates awaiting human approval + execution (call fingerprint → candidate). */
|
|
@@ -325,8 +374,26 @@ export declare class PermGateRuntime {
|
|
|
325
374
|
* `danger-full-access` means "full access without approval prompts", where a
|
|
326
375
|
* forwarded ask can only fail (the approval seam rejects before any answerer
|
|
327
376
|
* runs) and a hard-deny would silently overrule the tier the user chose.
|
|
377
|
+
*
|
|
378
|
+
* P0 is therefore monotonic only WITHIN the gate's scope, not across every
|
|
379
|
+
* preset. {@link announceStandDown} makes that visible instead of silent.
|
|
328
380
|
*/
|
|
329
381
|
private gateActive;
|
|
382
|
+
/**
|
|
383
|
+
* Record one `stand-down` event the first time a session is observed with a
|
|
384
|
+
* preset outside {@link gatePresets}, and again whenever that preset changes.
|
|
385
|
+
*
|
|
386
|
+
* Per-call silence is what makes a stand-down dangerous: the tool call looks
|
|
387
|
+
* exactly like a call the gate inspected and allowed. One event per
|
|
388
|
+
* transition is enough to say otherwise, and keeps the feed (and the
|
|
389
|
+
* `events.jsonl` append) proportional to preset changes rather than to call
|
|
390
|
+
* volume.
|
|
391
|
+
*
|
|
392
|
+
* The event is a NOTICE, not a decision: it carries no allow/deny meaning,
|
|
393
|
+
* and the call it was observed on is still settled entirely by the selected
|
|
394
|
+
* tier. `verdict: 'stand-down'` labels it for the history view.
|
|
395
|
+
*/
|
|
396
|
+
private announceStandDown;
|
|
330
397
|
/**
|
|
331
398
|
* Whether an `ask` produced while the gate is active can actually reach a
|
|
332
399
|
* human. Under `approval: never` the DSH approval seam returns `rejected`
|
|
@@ -405,6 +472,45 @@ export declare class PermGateRuntime {
|
|
|
405
472
|
* the human seam.
|
|
406
473
|
*/
|
|
407
474
|
decideExecution(exec: ToolExecutionLike): PreToolDecisionLike | undefined;
|
|
475
|
+
/**
|
|
476
|
+
* The **pure policy** outcome for one call: the same chain
|
|
477
|
+
* {@link decideExecution} runs, with every side effect and every session-state
|
|
478
|
+
* layer removed.
|
|
479
|
+
*
|
|
480
|
+
* Side effects removed — no audit entry, no event, no ask tracking, no call
|
|
481
|
+
* clearance, no learning. A rule-test panel must be able to run on every
|
|
482
|
+
* keystroke without writing to the live decision feed.
|
|
483
|
+
*
|
|
484
|
+
* Session-state layers removed — the preset stand-down and the
|
|
485
|
+
* `approval: never` ask degradation both describe a session a session-less
|
|
486
|
+
* caller does not have. Including them is not harmless: the degradation turns
|
|
487
|
+
* every "the rules would ask a human" into a reported **allow**, which is the
|
|
488
|
+
* opposite of the truth for exactly the rules someone opens the panel to
|
|
489
|
+
* review (measured on the live host: `shell ls -la` reported `allow` while the
|
|
490
|
+
* rule layer said `ask`).
|
|
491
|
+
*
|
|
492
|
+
* `reason` is never collapsed: "allow" has causes worth naming (a rule, a
|
|
493
|
+
* grant, cleanup-safety, the permissive default).
|
|
494
|
+
*/
|
|
495
|
+
explainCall(exec: ToolExecutionLike): CallExplanation;
|
|
496
|
+
/**
|
|
497
|
+
* P0 hard-deny → P1 session grant → P2 rules → P4 ask, as a value. Shared by
|
|
498
|
+
* the host-facing decision and {@link explainCall} so the two cannot drift.
|
|
499
|
+
*/
|
|
500
|
+
private rawPolicy;
|
|
501
|
+
/**
|
|
502
|
+
* Rule-layer explanation for one would-be call: what the `permissions` chain
|
|
503
|
+
* decides on its own, before P0 hard-deny, P1 session grants, the P3
|
|
504
|
+
* classifier and the P4 ask ever get a say.
|
|
505
|
+
*
|
|
506
|
+
* This is the read-only half of a dry-run. It appends nothing to the audit
|
|
507
|
+
* mirror, records no event, mints no grant and touches no learning store, so a
|
|
508
|
+
* "rule test" panel may call it on every keystroke without leaving a trace in
|
|
509
|
+
* the decision feed. It answers a narrower question than
|
|
510
|
+
* {@link decideExecution} and says so: `ruleIndex` identifies a rule in the
|
|
511
|
+
* chain, which only this layer can attribute.
|
|
512
|
+
*/
|
|
513
|
+
explainRules(exec: ToolExecutionLike): RuleExplanation;
|
|
408
514
|
/**
|
|
409
515
|
* Apply the Permissive tier to a raw decision. Hard-deny and minted-grant
|
|
410
516
|
* outcomes are never touched (fail-closed). `alwaysConfirm` escalates a
|
|
@@ -484,15 +590,23 @@ export declare class PermGateRuntime {
|
|
|
484
590
|
approveRepeat(exec: ToolExecutionLike, maxUses?: number, ttlMs?: number): void;
|
|
485
591
|
/**
|
|
486
592
|
* Grant "allow every occurrence of this command" (the always-confirm panel's
|
|
487
|
-
* second extended allow button): persist the command into the rules
|
|
488
|
-
*
|
|
593
|
+
* second extended allow button): persist the command into the rules' `allow`
|
|
594
|
+
* whitelist and reload. Prefers the settings-backed writer (the namespace's
|
|
595
|
+
* watch triggers the reload once the write lands); falls back to the rules
|
|
596
|
+
* file when no settings scope is wired. Returns the write verdict.
|
|
489
597
|
*/
|
|
490
598
|
approveAllowEverywhere(commandWord: string, reason?: string): boolean;
|
|
491
|
-
/**
|
|
599
|
+
/**
|
|
600
|
+
* Read-only view of the current allow-list command patterns (whitelist).
|
|
601
|
+
* Reads the settings document when the namespace is configured, else the
|
|
602
|
+
* rules file.
|
|
603
|
+
*/
|
|
492
604
|
allowlist(): readonly string[];
|
|
493
605
|
/**
|
|
494
606
|
* Replace the whitelist with exactly the given command patterns and reload.
|
|
495
|
-
*
|
|
607
|
+
* Prefers the settings-backed writer; falls back to the rules file when no
|
|
608
|
+
* settings scope is wired. Returns the write verdict (false when neither
|
|
609
|
+
* sink is available or the write failed).
|
|
496
610
|
*/
|
|
497
611
|
setAllowlist(patterns: readonly string[]): boolean;
|
|
498
612
|
}
|