dsh-perm-gate 0.2.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 (53) hide show
  1. package/CHANGELOG.ja.md +62 -0
  2. package/CHANGELOG.ko.md +60 -0
  3. package/CHANGELOG.md +62 -0
  4. package/INSTALL.ja.md +152 -0
  5. package/INSTALL.ko.md +151 -0
  6. package/INSTALL.md +154 -0
  7. package/INSTALL.zh.md +147 -0
  8. package/LICENSE +21 -0
  9. package/README.ja.md +197 -0
  10. package/README.ko.md +195 -0
  11. package/README.md +209 -0
  12. package/README.zh.md +144 -0
  13. package/assets/.gitkeep +0 -0
  14. package/assets/README.md +5 -0
  15. package/cordis.patch.yml +35 -0
  16. package/examples/permissions.example.yaml +20 -0
  17. package/lib/allowlist.d.ts +19 -0
  18. package/lib/allowlist.js +97 -0
  19. package/lib/audit.d.ts +45 -0
  20. package/lib/audit.js +34 -0
  21. package/lib/classifier.d.ts +74 -0
  22. package/lib/classifier.js +129 -0
  23. package/lib/cli.d.ts +2 -0
  24. package/lib/cli.js +67 -0
  25. package/lib/client.js +859 -0
  26. package/lib/client.js.map +1 -0
  27. package/lib/compiler.d.ts +30 -0
  28. package/lib/compiler.js +126 -0
  29. package/lib/config.d.ts +69 -0
  30. package/lib/config.js +64 -0
  31. package/lib/engine.d.ts +17 -0
  32. package/lib/engine.js +85 -0
  33. package/lib/evaluate.d.ts +20 -0
  34. package/lib/evaluate.js +94 -0
  35. package/lib/events.d.ts +56 -0
  36. package/lib/events.js +129 -0
  37. package/lib/grant.d.ts +60 -0
  38. package/lib/grant.js +121 -0
  39. package/lib/index.d.ts +17 -0
  40. package/lib/index.js +140 -0
  41. package/lib/learning.d.ts +58 -0
  42. package/lib/learning.js +181 -0
  43. package/lib/path.d.ts +47 -0
  44. package/lib/path.js +94 -0
  45. package/lib/risk.d.ts +58 -0
  46. package/lib/risk.js +100 -0
  47. package/lib/rule.d.ts +64 -0
  48. package/lib/rule.js +220 -0
  49. package/lib/runtime.d.ts +146 -0
  50. package/lib/runtime.js +350 -0
  51. package/lib/shell.d.ts +38 -0
  52. package/lib/shell.js +176 -0
  53. package/package.json +120 -0
@@ -0,0 +1,94 @@
1
+ /**
2
+ * First-match decision for dsh-perm-gate. Given a tool call + its command/path
3
+ * candidate strings, evaluate the deny-first rule chain and return the first
4
+ * matching action (or `defaultAction`). Pure — no I/O.
5
+ */
6
+ import { normalizeWorkspacePath } from './path.js';
7
+ import { extractPathCandidates, extractUrlCandidates, } from './rule.js';
8
+ import { decomposeShellCommand, isForceDeletion, isRecursiveDeletion } from './shell.js';
9
+ const SHELL_TOOLS = new Set(['bash', 'pwsh', 'sh', 'cmd', 'powershell']);
10
+ /** Whether one compiled rule matches the given call context. */
11
+ export function ruleMatches(rule, ctx, commands, pathCands, urlCands) {
12
+ if (!rule.enabled)
13
+ return false;
14
+ // tools dimension: any glob matches.
15
+ if (rule.tools.length > 0 && !rule.tools.some((t) => t.re.test(ctx.tool)))
16
+ return false;
17
+ const ci = ctx.caseInsensitive ?? false;
18
+ // command dimension: at least one decomposed simple command satisfies some command spec.
19
+ if (rule.command.length > 0) {
20
+ if (commands.length === 0)
21
+ return false;
22
+ let ok = false;
23
+ for (const spec of rule.command) {
24
+ for (const cmd of commands) {
25
+ if (!spec.word.re.test(cmd.command))
26
+ continue;
27
+ if (spec.flag === 'recursive' && !isRecursiveDeletion(cmd))
28
+ continue;
29
+ if (spec.flag === 'force' && !isForceDeletion(cmd))
30
+ continue;
31
+ ok = true;
32
+ break;
33
+ }
34
+ if (ok)
35
+ break;
36
+ }
37
+ if (!ok)
38
+ return false;
39
+ }
40
+ // args dimension: any token matches any args glob.
41
+ if (rule.args.length > 0) {
42
+ const tokens = [];
43
+ for (const cmd of commands)
44
+ tokens.push(...cmd.args, ...cmd.redirects);
45
+ tokens.push(...pathCands, ...urlCands);
46
+ if (!tokens.some((tok) => rule.args.some((g) => g.re.test(tok))))
47
+ return false;
48
+ }
49
+ // paths dimension: any workspace-relative path candidate matches any path glob.
50
+ if (rule.paths.length > 0) {
51
+ const rel = pathCands
52
+ .map((p) => normalizeWorkspacePath(ctx.cwd, p, ci))
53
+ .filter((p) => p.length > 0);
54
+ if (rel.length === 0)
55
+ return false;
56
+ if (!rel.some((p) => rule.paths.some((g) => g.re.test(p))))
57
+ return false;
58
+ }
59
+ return true;
60
+ }
61
+ /** Compute the deterministic first-match decision (P2 chain). */
62
+ export function decideRules(ruleset, ctx) {
63
+ const commands = ctx.commandText !== undefined && SHELL_TOOLS.has(ctx.tool)
64
+ ? decomposeSafe(ctx.commandText)
65
+ : [];
66
+ const pathCands = extractPathCandidates(ctx.args);
67
+ const urlCands = extractUrlCandidates(ctx.args);
68
+ const scan = (list, _action) => {
69
+ for (const rule of list) {
70
+ if (ruleMatches(rule, ctx, commands, pathCands, urlCands)) {
71
+ return { action: rule.action, reason: rule.reason, ruleIndex: rule.index };
72
+ }
73
+ }
74
+ return undefined;
75
+ };
76
+ const deny = scan(ruleset.deny, 'deny');
77
+ if (deny !== undefined)
78
+ return deny;
79
+ const allow = scan(ruleset.allow, 'allow');
80
+ if (allow !== undefined)
81
+ return allow;
82
+ const ask = scan(ruleset.ask, 'ask');
83
+ if (ask !== undefined)
84
+ return ask;
85
+ return { action: ruleset.defaultAction, reason: `no rule matched; default action`, ruleIndex: undefined };
86
+ }
87
+ function decomposeSafe(text) {
88
+ try {
89
+ return decomposeShellCommand(text).commands;
90
+ }
91
+ catch {
92
+ return [];
93
+ }
94
+ }
@@ -0,0 +1,56 @@
1
+ /** One gate decision event. `kind` drives the client notice strip styling. */
2
+ export interface GateEvent {
3
+ /** Monotonic id (resumed from the file across restarts). */
4
+ readonly id: number;
5
+ readonly ts: string;
6
+ readonly sessionId: string;
7
+ readonly tool: string;
8
+ /** auto = allowed (rule/grant/llm), ask = routed to the human, deny = vetoed, learned = confirmation settled. */
9
+ readonly kind: 'auto' | 'ask' | 'deny' | 'learned';
10
+ /** Risk category when an LLM verdict contributed to the decision. */
11
+ readonly risk?: string;
12
+ /** Truncated decision reason. */
13
+ readonly reason: string;
14
+ }
15
+ export interface GateEventInput {
16
+ readonly sessionId?: string;
17
+ readonly tool: string;
18
+ readonly kind: GateEvent['kind'];
19
+ readonly risk?: string;
20
+ readonly reason: string;
21
+ }
22
+ export declare class EventLog {
23
+ /** JSONL path; `undefined` disables event recording entirely. */
24
+ private readonly filePath;
25
+ private readonly now;
26
+ private seq;
27
+ constructor(
28
+ /** JSONL path; `undefined` disables event recording entirely. */
29
+ filePath: string | undefined, now?: () => number);
30
+ /** Append one event; returns it, or undefined when recording is disabled/failed. */
31
+ append(input: GateEventInput): GateEvent | undefined;
32
+ /** Events with `id > since`, optionally filtered to one session. */
33
+ query({ sessionId, since }?: {
34
+ sessionId?: string;
35
+ since?: number;
36
+ }): GateEvent[];
37
+ private nextId;
38
+ }
39
+ /**
40
+ * The minimal face of the DSH `webServer` service this plugin uses
41
+ * (typed locally — never value-imported; provided by the dsh runtime).
42
+ */
43
+ export interface WebServerLike {
44
+ register(route: {
45
+ kind: 'exact';
46
+ path: string;
47
+ handler: (req: unknown, res: unknown) => unknown;
48
+ }): () => void;
49
+ }
50
+ export declare const EVENTS_ROUTE = "/api/dsh-perm-gate/events";
51
+ /**
52
+ * Register `GET /api/dsh-perm-gate/events?sessionId=&since=` on the webServer
53
+ * service. Returns whether the route was registered (false when the service is
54
+ * unavailable — the host keeps running, only the HTTP API is missing).
55
+ */
56
+ export declare function registerEventsRoute(server: unknown, log: EventLog): boolean;
package/lib/events.js ADDED
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Gate decision events for dsh-perm-gate: a JSONL audit feed the browser half
3
+ * polls so every automatic allow / ask / deny is visible in the conversation UI.
4
+ *
5
+ * Pure append-only logging: every I/O error is swallowed (events are best-effort
6
+ * and must never influence gating), and the id sequence resumes from the file on
7
+ * first use so restarts never reuse an id the client has already seen.
8
+ */
9
+ import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
10
+ import { dirname } from 'node:path';
11
+ const REASON_MAX = 200;
12
+ export class EventLog {
13
+ filePath;
14
+ now;
15
+ seq = -1; // lazily restored from the file on first append
16
+ constructor(
17
+ /** JSONL path; `undefined` disables event recording entirely. */
18
+ filePath, now = Date.now) {
19
+ this.filePath = filePath;
20
+ this.now = now;
21
+ }
22
+ /** Append one event; returns it, or undefined when recording is disabled/failed. */
23
+ append(input) {
24
+ if (this.filePath === undefined)
25
+ return undefined;
26
+ try {
27
+ const id = this.nextId();
28
+ const ev = {
29
+ id,
30
+ ts: new Date(this.now()).toISOString(),
31
+ sessionId: input.sessionId ?? '',
32
+ tool: input.tool,
33
+ kind: input.kind,
34
+ ...(input.risk === undefined ? {} : { risk: input.risk }),
35
+ reason: String(input.reason).slice(0, REASON_MAX),
36
+ };
37
+ mkdirSync(dirname(this.filePath), { recursive: true });
38
+ appendFileSync(this.filePath, JSON.stringify(ev) + '\n', 'utf8');
39
+ return ev;
40
+ }
41
+ catch {
42
+ return undefined; // best-effort only
43
+ }
44
+ }
45
+ /** Events with `id > since`, optionally filtered to one session. */
46
+ query({ sessionId, since } = {}) {
47
+ if (this.filePath === undefined)
48
+ return [];
49
+ let text;
50
+ try {
51
+ text = readFileSync(this.filePath, 'utf8');
52
+ }
53
+ catch {
54
+ return []; // no events yet
55
+ }
56
+ const out = [];
57
+ for (const line of text.split('\n')) {
58
+ if (line.trim() === '')
59
+ continue;
60
+ try {
61
+ const ev = JSON.parse(line);
62
+ if (typeof ev.id !== 'number')
63
+ continue;
64
+ if (since !== undefined && ev.id <= since)
65
+ continue;
66
+ if (sessionId !== undefined && sessionId !== '' && ev.sessionId !== sessionId)
67
+ continue;
68
+ out.push(ev);
69
+ }
70
+ catch {
71
+ // corrupted line: skip
72
+ }
73
+ }
74
+ return out;
75
+ }
76
+ nextId() {
77
+ if (this.seq < 0) {
78
+ this.seq = 0;
79
+ for (const ev of this.query()) {
80
+ if (ev.id > this.seq)
81
+ this.seq = ev.id;
82
+ }
83
+ }
84
+ this.seq += 1;
85
+ return this.seq;
86
+ }
87
+ }
88
+ export const EVENTS_ROUTE = '/api/dsh-perm-gate/events';
89
+ /**
90
+ * Register `GET /api/dsh-perm-gate/events?sessionId=&since=` on the webServer
91
+ * service. Returns whether the route was registered (false when the service is
92
+ * unavailable — the host keeps running, only the HTTP API is missing).
93
+ */
94
+ export function registerEventsRoute(server, log) {
95
+ if (typeof server !== 'object' || server === null)
96
+ return false;
97
+ const candidate = server;
98
+ if (typeof candidate.register !== 'function')
99
+ return false;
100
+ candidate.register({
101
+ kind: 'exact',
102
+ path: EVENTS_ROUTE,
103
+ handler: (rawReq, rawRes) => {
104
+ const req = rawReq;
105
+ const res = rawRes;
106
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
107
+ res.writeHead(405);
108
+ res.end();
109
+ return;
110
+ }
111
+ let sessionId;
112
+ let since;
113
+ try {
114
+ const url = new URL(req.url ?? '/', 'http://localhost');
115
+ sessionId = url.searchParams.get('sessionId') ?? undefined;
116
+ const raw = url.searchParams.get('since');
117
+ if (raw !== null && raw !== '')
118
+ since = Number.parseInt(raw, 10) || 0;
119
+ }
120
+ catch {
121
+ // unparseable URL: answer with the unfiltered tail
122
+ }
123
+ const events = log.query({ sessionId, since });
124
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-cache' });
125
+ res.end(JSON.stringify({ events }));
126
+ },
127
+ });
128
+ return true;
129
+ }
package/lib/grant.d.ts ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Session-scoped grants for dsh-perm-gate: a bounded, precise grant that lets a
3
+ * specific tool+call through once (or N times) within a TTL. Grants are keyed
4
+ * by a canonical fingerprint of the call so re-running with a DIFFERENT target
5
+ * never reuses authority.
6
+ */
7
+ export type GrantDecision = 'allow' | 'no-match';
8
+ export interface GrantSpec {
9
+ readonly tool: string;
10
+ /** Canonical fingerprint of the call (see {@link canonicalizeCall}). */
11
+ readonly fingerprint: string;
12
+ readonly decidedBy: 'human' | 'approval' | 'grant-chain';
13
+ readonly sessionId: string;
14
+ readonly parentAuthorized: boolean;
15
+ readonly ttlMs: number;
16
+ readonly maxUses: number;
17
+ readonly expiresAt: number;
18
+ }
19
+ export declare class GrantExpiredError extends Error {
20
+ constructor();
21
+ }
22
+ /**
23
+ * Canonicalize a tool call into a stable-but-precise fingerprint.
24
+ * Object keys are sorted and pure-value secrets are redacted by length, while
25
+ * meaningful fields that change authority (host, path, command word) are kept.
26
+ * Two calls that are cosmetic-equivalent (`{a:1,b:2}` vs `{b:2,a:1}`, or
27
+ * whitespace differences in a command) share a fingerprint; a call that targets
28
+ * a different path or host does NOT.
29
+ */
30
+ export declare function canonicalizeCall(tool: string, args: Record<string, unknown>): string;
31
+ /** A live grant with remaining uses; encapsulates expiry consumption. */
32
+ export declare class SessionGrant {
33
+ readonly spec: GrantSpec;
34
+ private readonly now;
35
+ private uses;
36
+ constructor(spec: GrantSpec, now?: () => number);
37
+ get expiresAt(): number;
38
+ get remaining(): number;
39
+ ready(): boolean;
40
+ /** Consume one use if within TTL/maxUses; throws on expiry. otherwise returns false. */
41
+ tryConsume(now?: number): boolean;
42
+ }
43
+ /** Session-scoped grant registry. */
44
+ export declare class GrantRegistry {
45
+ private readonly sessionId;
46
+ private readonly now;
47
+ private readonly grants;
48
+ constructor(sessionId: string, now?: () => number);
49
+ get size(): number;
50
+ /** Mint a precise grant. Self-minting restrictions are enforced by the decision engine, not here. */
51
+ mint(spec: Omit<GrantSpec, 'sessionId' | 'expiresAt'>): SessionGrant;
52
+ /**
53
+ * Resolve an allow for a call. Requires the grant's session to be a descendant
54
+ * of (or equal to) the caller. Returns 'allow' after consuming a use.
55
+ */
56
+ decide(tool: string, args: Record<string, unknown>): GrantDecision;
57
+ private prune;
58
+ }
59
+ /** Constructor helper: grant spec expiry is derived from ttlMs. */
60
+ export declare function withExpiry(spec: Omit<GrantSpec, 'expiresAt'>, now?: () => number): GrantSpec;
package/lib/grant.js ADDED
@@ -0,0 +1,121 @@
1
+ export class GrantExpiredError extends Error {
2
+ constructor() {
3
+ super('grant expired');
4
+ this.name = 'GrantExpiredError';
5
+ }
6
+ }
7
+ /**
8
+ * Canonicalize a tool call into a stable-but-precise fingerprint.
9
+ * Object keys are sorted and pure-value secrets are redacted by length, while
10
+ * meaningful fields that change authority (host, path, command word) are kept.
11
+ * Two calls that are cosmetic-equivalent (`{a:1,b:2}` vs `{b:2,a:1}`, or
12
+ * whitespace differences in a command) share a fingerprint; a call that targets
13
+ * a different path or host does NOT.
14
+ */
15
+ export function canonicalizeCall(tool, args) {
16
+ const sortable = sortKeys(args);
17
+ return `${tool}:${stableStringify(sortable)}`;
18
+ }
19
+ function sortKeys(v) {
20
+ if (Array.isArray(v))
21
+ return v.slice(0, 50).map(sortKeys);
22
+ if (typeof v === 'object' && v !== null) {
23
+ const out = {};
24
+ for (const k of Object.keys(v).sort())
25
+ out[k] = sortKeys(v[k]);
26
+ return out;
27
+ }
28
+ return v;
29
+ }
30
+ function stableStringify(v) {
31
+ if (typeof v === 'string')
32
+ return JSON.stringify(v.replace(/\s+/g, ' '));
33
+ if (typeof v !== 'object' || v === null)
34
+ return JSON.stringify(v);
35
+ if (Array.isArray(v))
36
+ return `[${v.map(stableStringify).join(',')}]`;
37
+ return `{${Object.keys(v).map((k) => JSON.stringify(k) + ':' + stableStringify(v[k])).join(',')}}`;
38
+ }
39
+ /** A live grant with remaining uses; encapsulates expiry consumption. */
40
+ export class SessionGrant {
41
+ spec;
42
+ now;
43
+ uses = 0;
44
+ constructor(spec, now = Date.now) {
45
+ this.spec = spec;
46
+ this.now = now;
47
+ }
48
+ get expiresAt() {
49
+ return this.spec.expiresAt;
50
+ }
51
+ get remaining() {
52
+ return this.spec.maxUses - this.uses;
53
+ }
54
+ ready() {
55
+ return this.remaining > 0;
56
+ }
57
+ /** Consume one use if within TTL/maxUses; throws on expiry. otherwise returns false. */
58
+ tryConsume(now) {
59
+ const at = now ?? this.now();
60
+ if (at >= this.spec.expiresAt)
61
+ throw new GrantExpiredError();
62
+ if (this.uses >= this.spec.maxUses)
63
+ return false;
64
+ this.uses += 1;
65
+ return true;
66
+ }
67
+ }
68
+ /** Session-scoped grant registry. */
69
+ export class GrantRegistry {
70
+ sessionId;
71
+ now;
72
+ grants = [];
73
+ constructor(sessionId, now = Date.now) {
74
+ this.sessionId = sessionId;
75
+ this.now = now;
76
+ }
77
+ get size() {
78
+ return this.grants.length;
79
+ }
80
+ /** Mint a precise grant. Self-minting restrictions are enforced by the decision engine, not here. */
81
+ mint(spec) {
82
+ const now = this.now();
83
+ const full = { ...spec, sessionId: this.sessionId, expiresAt: now + spec.ttlMs };
84
+ const grant = new SessionGrant(full, this.now);
85
+ this.prune();
86
+ this.grants.push(grant);
87
+ return grant;
88
+ }
89
+ /**
90
+ * Resolve an allow for a call. Requires the grant's session to be a descendant
91
+ * of (or equal to) the caller. Returns 'allow' after consuming a use.
92
+ */
93
+ decide(tool, args) {
94
+ const fp = canonicalizeCall(tool, args);
95
+ this.prune();
96
+ for (const g of this.grants) {
97
+ if (g.spec.fingerprint !== fp)
98
+ continue;
99
+ try {
100
+ if (g.tryConsume())
101
+ return 'allow';
102
+ }
103
+ catch {
104
+ return 'no-match';
105
+ }
106
+ }
107
+ return 'no-match';
108
+ }
109
+ prune() {
110
+ const at = this.now();
111
+ for (let i = this.grants.length - 1; i >= 0; i -= 1) {
112
+ const g = this.grants[i];
113
+ if (at >= g.spec.expiresAt || g.remaining <= 0)
114
+ this.grants.splice(i, 1);
115
+ }
116
+ }
117
+ }
118
+ /** Constructor helper: grant spec expiry is derived from ttlMs. */
119
+ export function withExpiry(spec, now = Date.now) {
120
+ return { ...spec, expiresAt: now() + spec.ttlMs };
121
+ }
package/lib/index.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * dsh-perm-gate — DSH cordis function plugin.
3
+ *
4
+ * Function-plugin contract: exports `name` / `inject` / `Config` / `apply`
5
+ * with NO default export (the DSH Loader unwraps `exports.default ?? exports`,
6
+ * and a stray default would discard the metadata).
7
+ */
8
+ import type { Context } from '@deepseek-ai/cordis';
9
+ import { Config } from './config.js';
10
+ import { PermGateRuntime } from './runtime.js';
11
+ export declare const name = "dsh-perm-gate";
12
+ /** The `tools` service drives `tools/pre-execute`/`tools/result`; it is supplied by dsh-tools. */
13
+ export declare const inject: string[];
14
+ export { Config };
15
+ /** Runtime settings namespace: `permissive` + `permissiveStrategies` (editable in the UI). */
16
+ export declare const PERMISSIVE_NAMESPACE = "dsh-perm-gate";
17
+ export declare function apply(ctx: Context, config?: Record<string, unknown>): PermGateRuntime;
package/lib/index.js ADDED
@@ -0,0 +1,140 @@
1
+ import { join } from 'node:path';
2
+ import { Config, resolvePermissiveStrategies } from './config.js';
3
+ import { registerEventsRoute } from './events.js';
4
+ import { PermGateRuntime } from './runtime.js';
5
+ export const name = 'dsh-perm-gate';
6
+ /** The `tools` service drives `tools/pre-execute`/`tools/result`; it is supplied by dsh-tools. */
7
+ export const inject = ['tools'];
8
+ export { Config };
9
+ /** Runtime settings namespace: `permissive` + `permissiveStrategies` (editable in the UI). */
10
+ export const PERMISSIVE_NAMESPACE = 'dsh-perm-gate';
11
+ /**
12
+ * Inline equivalent of the official `installSettingsSection` helper: register
13
+ * the namespace through the `settings` service, layer the composition entry as
14
+ * `base`, and keep the runtime source live so the UI card applies immediately.
15
+ * `onScope` receives the live scope after registration (for seeding/syncing
16
+ * host-owned fields like the allowlist).
17
+ */
18
+ function installSettingsSection(ctx, ns, schema, entry, hooks) {
19
+ ;
20
+ ctx.inject(['settings'], (sctx) => {
21
+ const scope = sctx.settings.register(ns, schema, { base: entry });
22
+ hooks.setSource(() => scope.get());
23
+ hooks.onChange();
24
+ hooks.onScope?.(scope);
25
+ sctx.effect(() => () => {
26
+ hooks.setSource(() => entry);
27
+ hooks.onChange();
28
+ });
29
+ scope.watch(() => hooks.onChange());
30
+ });
31
+ }
32
+ /** Column a settings-scope value into the typed live face. */
33
+ function asSurface(value) {
34
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
35
+ ? value
36
+ : undefined;
37
+ }
38
+ export function apply(ctx, config = {}) {
39
+ // Runtime-adjustable config: the composition entry is the base; the settings
40
+ // namespace layers on top and `current()` always reads the active section.
41
+ let current = () => config;
42
+ // Plugin-owned data files live under $DSH_HOME (node_modules may be read-only);
43
+ // a missing dshHome degrades both stores to in-memory.
44
+ const dataDir = typeof config.dshHome === 'string' && config.dshHome !== '' ? join(config.dshHome, 'perm-gate') : undefined;
45
+ const runtime = new PermGateRuntime({
46
+ ...config,
47
+ // Read the Permissive tier live so the UI card's switches take effect on
48
+ // the next tool call (no reload). Falls back to the composition entry.
49
+ readPermissive: () => {
50
+ const c = current();
51
+ return {
52
+ enabled: c.permissive ?? false,
53
+ strategies: resolvePermissiveStrategies(c.permissiveStrategies),
54
+ };
55
+ },
56
+ // Read the llmAssist receiver (endpoint/model/secret/timeout) live from the
57
+ // same namespace, so editing the settings card applies to the next tool call.
58
+ readClassifyConfig: () => {
59
+ const c = current();
60
+ return {
61
+ endpoint: c.classifierEndpoint,
62
+ model: c.classifierModel,
63
+ apiKey: c.classifierApiKey,
64
+ timeoutMs: c.riskTimeoutMs ?? 20_000,
65
+ };
66
+ },
67
+ // Read the verdict-learning switch and threshold live.
68
+ readRiskLearning: () => {
69
+ const c = current();
70
+ return {
71
+ enabled: c.riskLearning ?? false,
72
+ threshold: c.riskThreshold ?? 3,
73
+ };
74
+ },
75
+ learningFile: typeof config.learningFile === 'string' && config.learningFile !== ''
76
+ ? config.learningFile
77
+ : (dataDir !== undefined ? join(dataDir, 'learning.json') : undefined),
78
+ eventsFile: typeof config.eventsFile === 'string' && config.eventsFile !== ''
79
+ ? config.eventsFile
80
+ : (dataDir !== undefined ? join(dataDir, 'events.jsonl') : undefined),
81
+ });
82
+ installSettingsSection(ctx, PERMISSIVE_NAMESPACE, Config, config, {
83
+ setSource: (source) => {
84
+ current = source;
85
+ },
86
+ onChange: () => { },
87
+ onScope: (scope) => {
88
+ // Seed the editable whitelist from the rules file only when the namespace
89
+ // carries no override yet, so the card shows the current allow patterns.
90
+ const surface = asSurface(scope.get());
91
+ if (surface !== undefined && !Array.isArray(surface.allowlist)) {
92
+ const patterns = runtime.allowlist();
93
+ if (patterns.length > 0)
94
+ void scope.set('allowlist', patterns.slice());
95
+ }
96
+ // Card edits -> namespace -> rulesFile (mirror + reload).
97
+ scope.watch(() => {
98
+ const next = asSurface(scope.get());
99
+ if (next !== undefined && Array.isArray(next.allowlist)) {
100
+ runtime.setAllowlist(next.allowlist.filter((x) => typeof x === 'string'));
101
+ }
102
+ });
103
+ },
104
+ });
105
+ // Event feed HTTP API (best effort): the dsh webServer service exposes the
106
+ // JSONL decision events to the browser half; without it events stay on disk.
107
+ try {
108
+ const webServer = ctx.get('webServer');
109
+ if (webServer !== undefined && runtime.eventLog !== undefined) {
110
+ registerEventsRoute(webServer, runtime.eventLog);
111
+ }
112
+ }
113
+ catch {
114
+ // webServer unavailable: the feed remains disk-only
115
+ }
116
+ const listener = async (exec, next) => {
117
+ const decision = runtime.decideExecution(exec);
118
+ if (decision === undefined)
119
+ return next();
120
+ // llmAssist: refine an `ask` with the risk grader before the human seam.
121
+ // undefined = auto-allowed (risk-safe / learned) → proceed.
122
+ if (decision.kind === 'ask') {
123
+ const refined = await runtime.refineAsk(exec, decision);
124
+ if (refined === undefined)
125
+ return next();
126
+ return refined;
127
+ }
128
+ return decision;
129
+ };
130
+ // `tools/pre-execute` / `tools/result` are dsh-tools events, not part of
131
+ // cordis core's typed `Events`, so they are registered through the string overload.
132
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
133
+ const host = ctx;
134
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
135
+ host.on('tools/pre-execute', listener);
136
+ // Settle verdict-learning candidates: a result for a registered ask means the
137
+ // human approved it and it executed — one confirmation recorded.
138
+ host.on('tools/result', ((exec) => { runtime.settleExecution(exec); }));
139
+ return runtime;
140
+ }
@@ -0,0 +1,58 @@
1
+ /** One confirmed operation sample: fingerprint plus a short human-readable context. */
2
+ export interface LearningSample {
3
+ readonly fp: string;
4
+ readonly ctx: string;
5
+ readonly at: number;
6
+ }
7
+ /** The persisted document shape (version 1). */
8
+ export interface LearningDoc {
9
+ version: 1;
10
+ /** key (`tool|category`) → human confirmation count. */
11
+ confirmed: Record<string, number>;
12
+ /** key → the most recent confirmed samples (bounded). */
13
+ samples: Record<string, LearningSample[]>;
14
+ }
15
+ export interface RiskLearningOptions {
16
+ /** Confirmations required before an exact-sample re-run may auto-allow. A getter reads the live setting. Default 3. */
17
+ readonly threshold?: number | (() => number);
18
+ /** Samples retained per key (oldest evicted). Default 10. */
19
+ readonly maxSamples?: number;
20
+ readonly now?: () => number;
21
+ }
22
+ /**
23
+ * Derive a stable, precise operation fingerprint from structured arguments:
24
+ * shell calls → `word|target-basename`; file-arg tools → the target basename;
25
+ * everything else → the tool name alone. Two calls share a fingerprint only
26
+ * when they perform the same kind of write against the same-named target.
27
+ */
28
+ export declare function operationFingerprint(tool: string, args: Record<string, unknown>, commandText?: string): string;
29
+ /** The learning key for one call: `tool|category` (only neutral learns today). */
30
+ export declare function learnKey(tool: string, category: string): string;
31
+ export declare class RiskLearning {
32
+ /** Persistence path; `undefined` keeps state in memory only. */
33
+ private readonly filePath;
34
+ private readonly options;
35
+ private doc;
36
+ private loaded;
37
+ constructor(
38
+ /** Persistence path; `undefined` keeps state in memory only. */
39
+ filePath: string | undefined, options?: RiskLearningOptions);
40
+ private get threshold();
41
+ private load;
42
+ private persist;
43
+ /** Human confirmations recorded so far for one key. */
44
+ count(key: string): number;
45
+ /** Whether one key already holds a confirmed sample with this fingerprint. */
46
+ hasSample(key: string, fp: string): boolean;
47
+ /**
48
+ * The learning gate: auto-allow only when the key reached the threshold AND
49
+ * this exact fingerprint was among the human-confirmed samples.
50
+ */
51
+ shouldAutoAllow(key: string, fp: string): boolean;
52
+ /** Record one human confirmation: bump the count and store the sample (bounded). */
53
+ confirm(key: string, fp: string, ctx: string): void;
54
+ /** Drop all learning state (counts and samples) and persist the empty doc. */
55
+ reset(): void;
56
+ /** Read-only deep view (for tests and diagnostics). */
57
+ snapshot(): LearningDoc;
58
+ }