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,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent identity extraction for the `agents` rule dimension.
|
|
3
|
+
*
|
|
4
|
+
* Given a tool execution context, produces zero or more identity candidates
|
|
5
|
+
* that the `agents` dimension can match against. Candidates are produced
|
|
6
|
+
* in priority order: most-specific first.
|
|
7
|
+
*
|
|
8
|
+
* Identity model:
|
|
9
|
+
* - `main` — the primary (top-level) agent session
|
|
10
|
+
* - `subagent` — a delegated child agent (parentAuthorized present)
|
|
11
|
+
* - `preset:<name>` — running under a named permission preset
|
|
12
|
+
*
|
|
13
|
+
* When no identity can be determined, an empty array is returned and the
|
|
14
|
+
* `agents` dimension in rules fail-closes (no match → rule skipped).
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* The subset of ToolExecutionLike we need. Defined as a permissive interface
|
|
18
|
+
* to avoid a circular import with runtime.ts. Uses `unknown` for event types
|
|
19
|
+
* since we only need to read `.type`/`.kind` and `.preset`/`.tier` properties.
|
|
20
|
+
*/
|
|
21
|
+
export interface ExecutionLike {
|
|
22
|
+
readonly parentAuthorized?: boolean;
|
|
23
|
+
readonly agent?: {
|
|
24
|
+
readonly sessionId?: string;
|
|
25
|
+
readonly session?: {
|
|
26
|
+
readonly id?: string;
|
|
27
|
+
readonly events?: readonly unknown[];
|
|
28
|
+
readonly snapshotEvents?: () => readonly unknown[];
|
|
29
|
+
readonly ownEvents?: () => readonly unknown[];
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Extract agent identity candidates from a tool execution context.
|
|
35
|
+
*
|
|
36
|
+
* Returns a deduplicated list of identity strings. The `agents` rule
|
|
37
|
+
* dimension matches if ANY candidate matches ANY entry in the rule's
|
|
38
|
+
* `agents` list.
|
|
39
|
+
*
|
|
40
|
+
* @returns candidates in priority order, e.g. `['preset:permissive', 'subagent']`
|
|
41
|
+
* or `['main']`. Empty array = undecidable → agents rules fail-close.
|
|
42
|
+
*/
|
|
43
|
+
export declare function extractAgentCandidates(exec: ExecutionLike): string[];
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent identity extraction for the `agents` rule dimension.
|
|
3
|
+
*
|
|
4
|
+
* Given a tool execution context, produces zero or more identity candidates
|
|
5
|
+
* that the `agents` dimension can match against. Candidates are produced
|
|
6
|
+
* in priority order: most-specific first.
|
|
7
|
+
*
|
|
8
|
+
* Identity model:
|
|
9
|
+
* - `main` — the primary (top-level) agent session
|
|
10
|
+
* - `subagent` — a delegated child agent (parentAuthorized present)
|
|
11
|
+
* - `preset:<name>` — running under a named permission preset
|
|
12
|
+
*
|
|
13
|
+
* When no identity can be determined, an empty array is returned and the
|
|
14
|
+
* `agents` dimension in rules fail-closes (no match → rule skipped).
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Extract agent identity candidates from a tool execution context.
|
|
18
|
+
*
|
|
19
|
+
* Returns a deduplicated list of identity strings. The `agents` rule
|
|
20
|
+
* dimension matches if ANY candidate matches ANY entry in the rule's
|
|
21
|
+
* `agents` list.
|
|
22
|
+
*
|
|
23
|
+
* @returns candidates in priority order, e.g. `['preset:permissive', 'subagent']`
|
|
24
|
+
* or `['main']`. Empty array = undecidable → agents rules fail-close.
|
|
25
|
+
*/
|
|
26
|
+
export function extractAgentCandidates(exec) {
|
|
27
|
+
const candidates = [];
|
|
28
|
+
// 1. Preset name — most specific identity.
|
|
29
|
+
const preset = presetFromEvents(exec);
|
|
30
|
+
if (preset !== undefined) {
|
|
31
|
+
candidates.push(`preset:${preset}`);
|
|
32
|
+
}
|
|
33
|
+
// 2. Main vs subagent — determined by parentAuthorized presence.
|
|
34
|
+
if (exec.parentAuthorized !== undefined) {
|
|
35
|
+
// parentAuthorized is present → this is a child/subagent.
|
|
36
|
+
candidates.push('subagent');
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
// parentAuthorized absent → assume main agent.
|
|
40
|
+
candidates.push('main');
|
|
41
|
+
}
|
|
42
|
+
return candidates;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Derive the current permission preset name from the session event log.
|
|
46
|
+
*
|
|
47
|
+
* The event log is append-only; the last `preset-switch` or `tier-select`
|
|
48
|
+
* event carries the current preset. Returns undefined when the log is
|
|
49
|
+
* unavailable or no preset event exists.
|
|
50
|
+
*/
|
|
51
|
+
function presetFromEvents(exec) {
|
|
52
|
+
const events = eventsOf(exec);
|
|
53
|
+
if (events === undefined)
|
|
54
|
+
return undefined;
|
|
55
|
+
// Walk backwards to find the most recent preset/tier event.
|
|
56
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
57
|
+
const ev = events[i];
|
|
58
|
+
if (typeof ev !== 'object' || ev === null)
|
|
59
|
+
continue;
|
|
60
|
+
const rec = ev;
|
|
61
|
+
const type = rec.type ?? rec.kind;
|
|
62
|
+
if (typeof type !== 'string')
|
|
63
|
+
continue;
|
|
64
|
+
if (type === 'preset-switch' || type === 'tier-select') {
|
|
65
|
+
const value = rec.preset ?? rec.tier ?? rec.value;
|
|
66
|
+
if (typeof value === 'string' && value !== '')
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Read the session event log from a ToolExecutionLike, handling both
|
|
74
|
+
* DSH 0.1.1 (plain array) and 0.1.2+ (function accessors) shapes.
|
|
75
|
+
*/
|
|
76
|
+
function eventsOf(exec) {
|
|
77
|
+
const session = exec.agent?.session;
|
|
78
|
+
if (session === undefined)
|
|
79
|
+
return undefined;
|
|
80
|
+
if (typeof session.snapshotEvents === 'function') {
|
|
81
|
+
try {
|
|
82
|
+
return session.snapshotEvents();
|
|
83
|
+
}
|
|
84
|
+
catch { /* ignore */ }
|
|
85
|
+
}
|
|
86
|
+
if (typeof session.ownEvents === 'function') {
|
|
87
|
+
try {
|
|
88
|
+
return session.ownEvents();
|
|
89
|
+
}
|
|
90
|
+
catch { /* ignore */ }
|
|
91
|
+
}
|
|
92
|
+
if (Array.isArray(session.events))
|
|
93
|
+
return session.events;
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
package/lib/allowlist.d.ts
CHANGED
|
@@ -1,3 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal face of the DSH settings scope the rules namespace is bound to
|
|
3
|
+
* (`scope.get()` / `scope.update(patch)`; the host merges a patch into the
|
|
4
|
+
* user layer, replacing arrays wholesale).
|
|
5
|
+
*/
|
|
6
|
+
export interface SettingsRulesScope {
|
|
7
|
+
get(): unknown;
|
|
8
|
+
update(patch: object): Promise<unknown> | unknown;
|
|
9
|
+
}
|
|
10
|
+
/** The allow-list command patterns currently in the settings rules document (read-only). */
|
|
11
|
+
export declare function listAllowFromRulesDoc(doc: unknown): string[];
|
|
12
|
+
/**
|
|
13
|
+
* Append one command pattern as a new `allow` entry in the settings rules
|
|
14
|
+
* namespace. When the namespace is still unconfigured (bare defaults) and a
|
|
15
|
+
* file document is supplied, the namespace is seeded from it first — the
|
|
16
|
+
* implicit migration — so the first settings write cannot shadow file-defined
|
|
17
|
+
* deny/ask rules. Returns false when the scope rejects the write.
|
|
18
|
+
*/
|
|
19
|
+
export declare function appendAllowToSettings(scope: SettingsRulesScope, pattern: string, reason?: string, seed?: unknown): Promise<boolean>;
|
|
20
|
+
/**
|
|
21
|
+
* Replace the settings rules namespace's `allow` whitelist with exactly the
|
|
22
|
+
* given command patterns (one allow entry per pattern); empty clears it.
|
|
23
|
+
* Other sections (deny / ask / defaultAction) are preserved — seeded from the
|
|
24
|
+
* file document on the namespace's first write. Returns false when the scope
|
|
25
|
+
* rejects the write.
|
|
26
|
+
*/
|
|
27
|
+
export declare function replaceAllowInSettings(scope: SettingsRulesScope, patterns: readonly string[], reason?: string, seed?: unknown): Promise<boolean>;
|
|
1
28
|
/**
|
|
2
29
|
* Append one command pattern as a new `allow` entry in the rules file.
|
|
3
30
|
* Returns false (leaving the file untouched) on any I/O or shape error.
|
package/lib/allowlist.js
CHANGED
|
@@ -2,11 +2,113 @@
|
|
|
2
2
|
* Durable allowlist (whitelist) writes for dsh-perm-gate.
|
|
3
3
|
*
|
|
4
4
|
* The "allow everywhere" approval action appends a matched command pattern to
|
|
5
|
-
* the rules
|
|
5
|
+
* the rules' `allow` list and reloads, so a human-approved command is
|
|
6
6
|
* persistently whitelisted across sessions — not just granted once.
|
|
7
|
+
*
|
|
8
|
+
* Two storage backs, per the dual-source migration:
|
|
9
|
+
* - **settings** (`appendAllowToSettings` / `replaceAllowInSettings`) — the
|
|
10
|
+
* `dsh-perm-gate-rules` namespace; the primary write path once the settings
|
|
11
|
+
* service is available.
|
|
12
|
+
* - **rules file** (`appendAllowCommand` / `replaceAllowCommands`) — the legacy
|
|
13
|
+
* path, kept as the fallback when no settings scope is wired (and as the
|
|
14
|
+
* migration seed: the first settings write carries the file document along so
|
|
15
|
+
* file-defined deny/ask rules are not shadowed).
|
|
7
16
|
*/
|
|
8
17
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
9
18
|
import { parse, stringify } from 'yaml';
|
|
19
|
+
import { isRulesConfigured } from './config.js';
|
|
20
|
+
function isRecord(v) {
|
|
21
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
22
|
+
}
|
|
23
|
+
/** Accept both the bare rules form and the file's `permissions:`-wrapped form. */
|
|
24
|
+
function unwrapPermissions(seed) {
|
|
25
|
+
if (!isRecord(seed))
|
|
26
|
+
return {};
|
|
27
|
+
return isRecord(seed['permissions']) ? seed['permissions'] : seed;
|
|
28
|
+
}
|
|
29
|
+
/** The file document a first write should migrate into the namespace, or bare defaults. */
|
|
30
|
+
function seedOrDefaults(seed) {
|
|
31
|
+
const root = unwrapPermissions(seed);
|
|
32
|
+
return {
|
|
33
|
+
defaultAction: typeof root['defaultAction'] === 'string' ? root['defaultAction'] : 'ask',
|
|
34
|
+
deny: Array.isArray(root['deny']) ? root['deny'] : [],
|
|
35
|
+
allow: Array.isArray(root['allow']) ? root['allow'] : [],
|
|
36
|
+
ask: Array.isArray(root['ask']) ? root['ask'] : [],
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function allowEntriesOf(section) {
|
|
40
|
+
const allow = section['allow'];
|
|
41
|
+
return Array.isArray(allow) ? allow : [];
|
|
42
|
+
}
|
|
43
|
+
function commandEntries(patterns, reason) {
|
|
44
|
+
return patterns.map((pattern) => ({ command: [pattern], reason }));
|
|
45
|
+
}
|
|
46
|
+
/** The allow-list command patterns currently in the settings rules document (read-only). */
|
|
47
|
+
export function listAllowFromRulesDoc(doc) {
|
|
48
|
+
const section = unwrapPermissions(doc);
|
|
49
|
+
const out = [];
|
|
50
|
+
for (const entry of allowEntriesOf(section)) {
|
|
51
|
+
if (!isRecord(entry))
|
|
52
|
+
continue;
|
|
53
|
+
const command = entry['command'];
|
|
54
|
+
if (typeof command === 'string') {
|
|
55
|
+
out.push(command);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (!Array.isArray(command))
|
|
59
|
+
continue;
|
|
60
|
+
for (const c of command)
|
|
61
|
+
if (typeof c === 'string')
|
|
62
|
+
out.push(c);
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Append one command pattern as a new `allow` entry in the settings rules
|
|
68
|
+
* namespace. When the namespace is still unconfigured (bare defaults) and a
|
|
69
|
+
* file document is supplied, the namespace is seeded from it first — the
|
|
70
|
+
* implicit migration — so the first settings write cannot shadow file-defined
|
|
71
|
+
* deny/ask rules. Returns false when the scope rejects the write.
|
|
72
|
+
*/
|
|
73
|
+
export async function appendAllowToSettings(scope, pattern, reason = 'permissive allow-everywhere', seed) {
|
|
74
|
+
try {
|
|
75
|
+
const current = scope.get();
|
|
76
|
+
const section = isRulesConfigured(current) && isRecord(current)
|
|
77
|
+
? { ...current }
|
|
78
|
+
: seedOrDefaults(seed);
|
|
79
|
+
const allow = allowEntriesOf(section);
|
|
80
|
+
allow.push({ command: [pattern], reason });
|
|
81
|
+
section['allow'] = allow;
|
|
82
|
+
await scope.update(section);
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Replace the settings rules namespace's `allow` whitelist with exactly the
|
|
91
|
+
* given command patterns (one allow entry per pattern); empty clears it.
|
|
92
|
+
* Other sections (deny / ask / defaultAction) are preserved — seeded from the
|
|
93
|
+
* file document on the namespace's first write. Returns false when the scope
|
|
94
|
+
* rejects the write.
|
|
95
|
+
*/
|
|
96
|
+
export async function replaceAllowInSettings(scope, patterns, reason = 'permissive allowlist', seed) {
|
|
97
|
+
try {
|
|
98
|
+
if (patterns === undefined)
|
|
99
|
+
return false;
|
|
100
|
+
const current = scope.get();
|
|
101
|
+
const section = isRulesConfigured(current) && isRecord(current)
|
|
102
|
+
? { ...current }
|
|
103
|
+
: seedOrDefaults(seed);
|
|
104
|
+
section['allow'] = commandEntries(patterns, reason);
|
|
105
|
+
await scope.update(section);
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
10
112
|
/**
|
|
11
113
|
* Append one command pattern as a new `allow` entry in the rules file.
|
|
12
114
|
* Returns false (leaving the file untouched) on any I/O or shape error.
|
package/lib/cli.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { parseArgs } from 'node:util';
|
|
12
12
|
import { resolve } from 'node:path';
|
|
13
|
-
import {
|
|
13
|
+
import { createDryRunRuntime, runDryRun } from './dry-run.js';
|
|
14
14
|
function main() {
|
|
15
15
|
const { values } = parseArgs({
|
|
16
16
|
options: {
|
|
@@ -23,11 +23,7 @@ function main() {
|
|
|
23
23
|
},
|
|
24
24
|
});
|
|
25
25
|
const rulesFile = values.rules ? resolve(values.rules) : undefined;
|
|
26
|
-
const runtime =
|
|
27
|
-
rulesFile,
|
|
28
|
-
caseInsensitivePaths: true,
|
|
29
|
-
permissive: values.permissive || undefined,
|
|
30
|
-
});
|
|
26
|
+
const runtime = createDryRunRuntime({ rulesFile, permissive: values.permissive });
|
|
31
27
|
if (values.list) {
|
|
32
28
|
process.stdout.write(JSON.stringify({
|
|
33
29
|
rulesFile,
|
|
@@ -53,15 +49,16 @@ function main() {
|
|
|
53
49
|
process.exitCode = 2;
|
|
54
50
|
return;
|
|
55
51
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
});
|
|
52
|
+
// `hostView` reproduces this command's historic output exactly: it reports the
|
|
53
|
+
// host-facing decision path (whose `allow` is a bare passthrough with no
|
|
54
|
+
// reason) and its audit count. The panel reports the pure policy verdict
|
|
55
|
+
// instead, because a session-less caller must not inherit the session layers.
|
|
56
|
+
const result = runDryRun({ tool, args, rulesFile, cwd: values.cwd, hostView: true }, runtime);
|
|
57
|
+
const host = result.host;
|
|
61
58
|
process.stdout.write(JSON.stringify({
|
|
62
|
-
decision:
|
|
63
|
-
reason:
|
|
64
|
-
audited:
|
|
59
|
+
decision: host?.verdict ?? result.verdict,
|
|
60
|
+
reason: host?.reason ?? result.reason,
|
|
61
|
+
audited: host?.audited ?? 0,
|
|
65
62
|
}, null, 2) + '\n');
|
|
66
63
|
}
|
|
67
64
|
main();
|