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,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
+ }
@@ -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 file's `allow` list and reloads, so a human-approved command is
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 { PermGateRuntime } from './runtime.js';
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 = new PermGateRuntime({
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
- const decision = runtime.decideExecution({
57
- name: tool,
58
- arguments: args,
59
- cwd: values.cwd ? resolve(values.cwd) : process.cwd(),
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: decision === undefined ? 'allow' : decision.kind,
63
- reason: decision?.reason ?? '(default/passthrough)',
64
- audited: runtime.auditEntries.length,
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();