dsh-permission-rules 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.
- package/CHANGELOG.md +50 -0
- package/LICENSE +201 -0
- package/README.es.md +182 -0
- package/README.hi.md +182 -0
- package/README.md +182 -0
- package/README.pt.md +182 -0
- package/README.zh.md +182 -0
- package/cordis.patch.yml +16 -0
- package/docs/rules-format.en.md +105 -0
- package/docs/rules-format.md +103 -0
- package/docs/rules-format.schema.json +117 -0
- package/lib/index.js +1606 -0
- package/lib/types/config.d.ts +83 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/config.js +72 -0
- package/lib/types/config.js.map +1 -0
- package/lib/types/events.d.ts +68 -0
- package/lib/types/events.d.ts.map +1 -0
- package/lib/types/events.js +21 -0
- package/lib/types/events.js.map +1 -0
- package/lib/types/glob.d.ts +55 -0
- package/lib/types/glob.d.ts.map +1 -0
- package/lib/types/glob.js +307 -0
- package/lib/types/glob.js.map +1 -0
- package/lib/types/index.d.ts +25 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index.js +25 -0
- package/lib/types/index.js.map +1 -0
- package/lib/types/prose.d.ts +66 -0
- package/lib/types/prose.d.ts.map +1 -0
- package/lib/types/prose.js +143 -0
- package/lib/types/prose.js.map +1 -0
- package/lib/types/rules.d.ts +226 -0
- package/lib/types/rules.d.ts.map +1 -0
- package/lib/types/rules.js +581 -0
- package/lib/types/rules.js.map +1 -0
- package/lib/types/runtime.d.ts +175 -0
- package/lib/types/runtime.d.ts.map +1 -0
- package/lib/types/runtime.js +497 -0
- package/lib/types/runtime.js.map +1 -0
- package/package.json +103 -0
- package/scripts/repair-session-logs.mjs +327 -0
- package/src/config.ts +141 -0
- package/src/events.ts +75 -0
- package/src/glob.ts +303 -0
- package/src/index.ts +27 -0
- package/src/prose.ts +206 -0
- package/src/rules.ts +695 -0
- package/src/runtime.ts +504 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime of `dsh-permission-rules`: per-workspace rule loading (project
|
|
3
|
+
* file chain by session cwd → fallback path → empty set), the
|
|
4
|
+
* `tools/pre-execute` listener that turns a first-match hit into a
|
|
5
|
+
* deny/ask decision (and NEVER short-circuits on allow or passthrough),
|
|
6
|
+
* the `permissionRules/decision` audit event, the `/rules` session command
|
|
7
|
+
* (`list | reload | decisions [n] | test <tool> <json>`), and
|
|
8
|
+
* Chokidar-driven reloads. Every registration is an effect.
|
|
9
|
+
* @module dsh-permission-rules/runtime
|
|
10
|
+
*/
|
|
11
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
12
|
+
import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands';
|
|
13
|
+
import type { PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools';
|
|
14
|
+
import type { Config, ResolvedConfig } from './config.ts';
|
|
15
|
+
import type { CompiledRuleset, RuleHit } from './rules.ts';
|
|
16
|
+
import type { DecisionOutcome } from './events.ts';
|
|
17
|
+
export declare const name = "permission-rules";
|
|
18
|
+
/** Services required before the plugin mounts. */
|
|
19
|
+
export declare const inject: string[];
|
|
20
|
+
/** The rule state bound to one workspace cwd. */
|
|
21
|
+
interface LoadedRules {
|
|
22
|
+
/** The workspace root these rules were resolved for. */
|
|
23
|
+
readonly cwd: string;
|
|
24
|
+
/** Absolute paths of the rule files in effect, nearest first; `[]` = empty rule set. */
|
|
25
|
+
readonly sources: readonly string[];
|
|
26
|
+
readonly compiled: CompiledRuleset;
|
|
27
|
+
/** Last load error, when one is being reported (see {@link PermissionRulesRuntime.rulesFor}). */
|
|
28
|
+
readonly lastError?: string;
|
|
29
|
+
/** `true` when the last initial load threw under `badFilePolicy: 'fail'`. */
|
|
30
|
+
readonly failed?: true;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* State and behavior. One instance per plugin mount; disposals are owned by
|
|
34
|
+
* the watcher/timer effects registered in {@link apply}.
|
|
35
|
+
*/
|
|
36
|
+
export declare class PermissionRulesRuntime {
|
|
37
|
+
private readonly ctx;
|
|
38
|
+
readonly config: ResolvedConfig;
|
|
39
|
+
/** Loaded (or failed) rules per workspace cwd, least-recently-used first for eviction. */
|
|
40
|
+
private readonly byCwd;
|
|
41
|
+
/** Live watchers per rule-file path, with the cwds each serves. */
|
|
42
|
+
private readonly watchers;
|
|
43
|
+
/** Debounce timers per rule-file path. */
|
|
44
|
+
private readonly timers;
|
|
45
|
+
constructor(ctx: Context, config: ResolvedConfig);
|
|
46
|
+
/** The shared compile options derived from config. */
|
|
47
|
+
private compileOptions;
|
|
48
|
+
/**
|
|
49
|
+
* Resolve which files serve a workspace, nearest first: the project file
|
|
50
|
+
* under the session cwd (or an absolute `rulesFile`), with `searchUp`
|
|
51
|
+
* also merging every parent directory's file on the way to the root,
|
|
52
|
+
* else the configured fallback, else `[]` (empty rule set). Nearer files
|
|
53
|
+
* evaluate first, so a child can override a parent rule.
|
|
54
|
+
* @param cwd - the session's absolute workspace root.
|
|
55
|
+
* @returns the absolute rule-file paths in effect, nearest first.
|
|
56
|
+
*/
|
|
57
|
+
resolveSources(cwd: string): string[];
|
|
58
|
+
/** The configured fallback file when it exists, else `[]`. */
|
|
59
|
+
private resolveFallback;
|
|
60
|
+
/**
|
|
61
|
+
* Read, parse, and compile the rule-file chain serving `cwd`. Under
|
|
62
|
+
* `badFilePolicy: 'ignore-with-warning'` a bad file degrades to an empty
|
|
63
|
+
* rule set with a warning and keeps its source paths (so a later fix is
|
|
64
|
+
* watched and adopted); under `'fail'` it throws.
|
|
65
|
+
* @param cwd - the workspace root.
|
|
66
|
+
* @returns the loaded state.
|
|
67
|
+
*/
|
|
68
|
+
load(cwd: string): LoadedRules;
|
|
69
|
+
/**
|
|
70
|
+
* The rules in effect for one cwd, loading on first use. Under
|
|
71
|
+
* `badFilePolicy: 'fail'` a bad initial load throws on EVERY use (the
|
|
72
|
+
* pending tool call errors loudly) while the watcher keeps observing the
|
|
73
|
+
* files so a fix reloads into active rules. Cache hits refresh the
|
|
74
|
+
* least-recently-used position, so eviction drops the workspace that
|
|
75
|
+
* went longest without a decision.
|
|
76
|
+
* @param cwd - the workspace root.
|
|
77
|
+
* @returns the loaded rules.
|
|
78
|
+
*/
|
|
79
|
+
rulesFor(cwd: string): LoadedRules;
|
|
80
|
+
/**
|
|
81
|
+
* Re-read the rule-file chain for one cwd (watch-driven or
|
|
82
|
+
* `/rules reload`). A bad file NEVER crashes the process: the previous
|
|
83
|
+
* rules stay active, the error is logged and reported on the next
|
|
84
|
+
* `/rules` output.
|
|
85
|
+
* @param cwd - the workspace root.
|
|
86
|
+
*/
|
|
87
|
+
reload(cwd: string): void;
|
|
88
|
+
/** The match context `when` conditions evaluate against. */
|
|
89
|
+
private matchContext;
|
|
90
|
+
/**
|
|
91
|
+
* The `tools/pre-execute` listener. A deny/ask hit returns the decision
|
|
92
|
+
* (first match wins, short-circuiting downstream listeners); an allow hit
|
|
93
|
+
* and a passthrough MUST delegate via `next()` so later listeners keep
|
|
94
|
+
* their say. Audit is appended once the final outcome is known, so the
|
|
95
|
+
* recorded `outcome` matches what the waterfall settled on.
|
|
96
|
+
* @param exec - the pending call (name, parsed arguments, caller agent).
|
|
97
|
+
* @param next - the downstream chain.
|
|
98
|
+
* @returns the pre-execute decision.
|
|
99
|
+
*/
|
|
100
|
+
preExecute(exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>;
|
|
101
|
+
/**
|
|
102
|
+
* Append the log-only `permissionRules/decision` audit event for every
|
|
103
|
+
* decision (passthrough included unless `audit: 'hits'`), requesting the
|
|
104
|
+
* envelope's `ignorable: true` marker so any harness build can load the
|
|
105
|
+
* log (readers that do not know the type skip the audit record instead of
|
|
106
|
+
* refusing the session). `source` names the matched rule's file, or the
|
|
107
|
+
* nearest effective file on a passthrough. Agentless calls have no
|
|
108
|
+
* session to audit; append failures are contained so an audit hiccup can
|
|
109
|
+
* never change a permission decision.
|
|
110
|
+
* @param exec - the pending call.
|
|
111
|
+
* @param loaded - the rules in effect.
|
|
112
|
+
* @param hit - the first matching rule, or undefined for passthrough.
|
|
113
|
+
* @param outcome - the final pre-execute decision.
|
|
114
|
+
*/
|
|
115
|
+
audit(exec: ToolExecution, loaded: LoadedRules, hit: RuleHit | undefined, outcome: DecisionOutcome): void;
|
|
116
|
+
/**
|
|
117
|
+
* Execute the `/rules` command: bare `/rules` lists the active rules and
|
|
118
|
+
* their sources; `/rules reload` re-reads the chain; `/rules decisions
|
|
119
|
+
* [n]` shows the session's audit trail; `/rules test <tool> <json>`
|
|
120
|
+
* dry-evaluates the rules against a hypothetical call. Command output
|
|
121
|
+
* stays in the UI — nothing here is injected into the model context.
|
|
122
|
+
* @param invocation - the received command invocation.
|
|
123
|
+
* @returns the command result shown to the user.
|
|
124
|
+
*/
|
|
125
|
+
command(invocation: CommandInvocation): CommandResult;
|
|
126
|
+
/** Render the session's `permissionRules/decision` audit trail, newest last. */
|
|
127
|
+
private decisionsCommand;
|
|
128
|
+
/**
|
|
129
|
+
* Validate deployment-level file references at mount — the earliest
|
|
130
|
+
* resolvable point. An absolute `rulesFile` or a configured
|
|
131
|
+
* `fallbackPath` must exist and parse; a missing referent fails the mount
|
|
132
|
+
* loudly instead of silently degrading later.
|
|
133
|
+
* @throws when a mandated file is missing or invalid.
|
|
134
|
+
*/
|
|
135
|
+
validateDeploymentFiles(): void;
|
|
136
|
+
/** Parse and compile one mandated file, rethrowing as a mount failure. */
|
|
137
|
+
private loadForValidation;
|
|
138
|
+
/** Attach (once per file path) the Chokidar watcher feeding {@link reload}. */
|
|
139
|
+
private attachWatch;
|
|
140
|
+
/**
|
|
141
|
+
* Rebind `cwd` to its current rule-source chain. When a concrete source
|
|
142
|
+
* left the chain (deleted file, fallback switch), detach the workspace
|
|
143
|
+
* from watchers serving other sources and close the ones left empty, so
|
|
144
|
+
* long-running hosts cannot accumulate stale watchers. An empty chain (no
|
|
145
|
+
* rule file in effect) keeps the previous watchers alive: they own the
|
|
146
|
+
* "file recreated → adopted" signal, and the workspace-cache bound keeps
|
|
147
|
+
* the set of such watchers finite.
|
|
148
|
+
*/
|
|
149
|
+
private reconcileWatch;
|
|
150
|
+
/** Close and drop watchers whose workspace sets are empty, clearing their debounce timers. */
|
|
151
|
+
private pruneWatchers;
|
|
152
|
+
/**
|
|
153
|
+
* Bound the per-workspace cache: when a NEW workspace would exceed
|
|
154
|
+
* `maxCachedWorkspaces`, evict the least-recently-used entry (the Map
|
|
155
|
+
* head, refreshed by every {@link rulesFor} hit) and release its watcher
|
|
156
|
+
* slots. Long-lived hosts that visit many workspaces therefore keep a
|
|
157
|
+
* bounded memory footprint.
|
|
158
|
+
*/
|
|
159
|
+
private evictIfFull;
|
|
160
|
+
/** Number of live watchers, for observability and tests. */
|
|
161
|
+
activeWatcherCount(): number;
|
|
162
|
+
/** Number of pending debounce timers, for observability and tests. */
|
|
163
|
+
pendingReloadCount(): number;
|
|
164
|
+
/** Debounce watch events into one reload per stability window. */
|
|
165
|
+
private scheduleReload;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Mount the plugin: resolve config, validate deployment-level rule files,
|
|
169
|
+
* register the pre-execute listener and the `/rules` command.
|
|
170
|
+
* @param ctx - the host context.
|
|
171
|
+
* @param config - raw plugin config.
|
|
172
|
+
*/
|
|
173
|
+
export declare function apply(ctx: Context, config: Config): void;
|
|
174
|
+
export {};
|
|
175
|
+
//# sourceMappingURL=runtime.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../../src/runtime.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime of `dsh-permission-rules`: per-workspace rule loading (project
|
|
3
|
+
* file chain by session cwd → fallback path → empty set), the
|
|
4
|
+
* `tools/pre-execute` listener that turns a first-match hit into a
|
|
5
|
+
* deny/ask decision (and NEVER short-circuits on allow or passthrough),
|
|
6
|
+
* the `permissionRules/decision` audit event, the `/rules` session command
|
|
7
|
+
* (`list | reload | decisions [n] | test <tool> <json>`), and
|
|
8
|
+
* Chokidar-driven reloads. Every registration is an effect.
|
|
9
|
+
* @module dsh-permission-rules/runtime
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
12
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
13
|
+
import chokidar from 'chokidar';
|
|
14
|
+
import { resolveConfig } from "./config.js";
|
|
15
|
+
import { compileRules, compileRulesChain, describeRule, findUnreachableRules, matchRules, parseRulesDocument, RuleError } from "./rules.js";
|
|
16
|
+
import { DESCRIBE_TOKENS, UI_PROSE } from "./prose.js";
|
|
17
|
+
export const name = 'permission-rules';
|
|
18
|
+
/** Services required before the plugin mounts. */
|
|
19
|
+
export const inject = ['commands', 'tools'];
|
|
20
|
+
/**
|
|
21
|
+
* State and behavior. One instance per plugin mount; disposals are owned by
|
|
22
|
+
* the watcher/timer effects registered in {@link apply}.
|
|
23
|
+
*/
|
|
24
|
+
export class PermissionRulesRuntime {
|
|
25
|
+
ctx;
|
|
26
|
+
config;
|
|
27
|
+
/** Loaded (or failed) rules per workspace cwd, least-recently-used first for eviction. */
|
|
28
|
+
byCwd = new Map();
|
|
29
|
+
/** Live watchers per rule-file path, with the cwds each serves. */
|
|
30
|
+
watchers = new Map();
|
|
31
|
+
/** Debounce timers per rule-file path. */
|
|
32
|
+
timers = new Map();
|
|
33
|
+
constructor(ctx, config) {
|
|
34
|
+
this.ctx = ctx;
|
|
35
|
+
this.config = config;
|
|
36
|
+
}
|
|
37
|
+
/** The shared compile options derived from config. */
|
|
38
|
+
compileOptions() {
|
|
39
|
+
return {
|
|
40
|
+
patternMode: this.config.patternMode,
|
|
41
|
+
maxRules: this.config.maxRules,
|
|
42
|
+
maxGlobStars: this.config.maxGlobStars,
|
|
43
|
+
caseInsensitivePaths: this.config.caseInsensitivePaths,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Resolve which files serve a workspace, nearest first: the project file
|
|
48
|
+
* under the session cwd (or an absolute `rulesFile`), with `searchUp`
|
|
49
|
+
* also merging every parent directory's file on the way to the root,
|
|
50
|
+
* else the configured fallback, else `[]` (empty rule set). Nearer files
|
|
51
|
+
* evaluate first, so a child can override a parent rule.
|
|
52
|
+
* @param cwd - the session's absolute workspace root.
|
|
53
|
+
* @returns the absolute rule-file paths in effect, nearest first.
|
|
54
|
+
*/
|
|
55
|
+
resolveSources(cwd) {
|
|
56
|
+
if (this.config.searchUp) {
|
|
57
|
+
const sources = [];
|
|
58
|
+
let dir = cwd;
|
|
59
|
+
for (;;) {
|
|
60
|
+
const candidate = join(dir, this.config.rulesFile);
|
|
61
|
+
if (existsSync(candidate))
|
|
62
|
+
sources.push(candidate);
|
|
63
|
+
const parent = dirname(dir);
|
|
64
|
+
if (parent === dir)
|
|
65
|
+
break;
|
|
66
|
+
dir = parent;
|
|
67
|
+
}
|
|
68
|
+
return sources.length === 0 ? this.resolveFallback() : sources;
|
|
69
|
+
}
|
|
70
|
+
const projectPath = isAbsolute(this.config.rulesFile) ? this.config.rulesFile : join(cwd, this.config.rulesFile);
|
|
71
|
+
if (existsSync(projectPath))
|
|
72
|
+
return [projectPath];
|
|
73
|
+
return this.resolveFallback();
|
|
74
|
+
}
|
|
75
|
+
/** The configured fallback file when it exists, else `[]`. */
|
|
76
|
+
resolveFallback() {
|
|
77
|
+
const fallback = this.config.fallbackPath;
|
|
78
|
+
if (fallback !== undefined) {
|
|
79
|
+
const fallbackPath = isAbsolute(fallback) ? fallback : resolve(fallback);
|
|
80
|
+
if (existsSync(fallbackPath))
|
|
81
|
+
return [fallbackPath];
|
|
82
|
+
}
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Read, parse, and compile the rule-file chain serving `cwd`. Under
|
|
87
|
+
* `badFilePolicy: 'ignore-with-warning'` a bad file degrades to an empty
|
|
88
|
+
* rule set with a warning and keeps its source paths (so a later fix is
|
|
89
|
+
* watched and adopted); under `'fail'` it throws.
|
|
90
|
+
* @param cwd - the workspace root.
|
|
91
|
+
* @returns the loaded state.
|
|
92
|
+
*/
|
|
93
|
+
load(cwd) {
|
|
94
|
+
const sources = this.resolveSources(cwd);
|
|
95
|
+
if (sources.length === 0)
|
|
96
|
+
return { cwd, sources: [], compiled: { rules: [], caseInsensitivePaths: this.config.caseInsensitivePaths } };
|
|
97
|
+
try {
|
|
98
|
+
const entries = sources.map(path => ({ path, text: readFileSync(path, 'utf8') }));
|
|
99
|
+
const { ruleset } = compileRulesChain(entries, this.compileOptions());
|
|
100
|
+
return { cwd, sources, compiled: ruleset };
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
104
|
+
if (this.config.badFilePolicy === 'ignore-with-warning') {
|
|
105
|
+
this.ctx.logger.warn(`permission-rules: ignoring ${sources.join(', ')}: ${message} (empty rule set)`);
|
|
106
|
+
return { cwd, sources, compiled: { rules: [], caseInsensitivePaths: this.config.caseInsensitivePaths }, lastError: message };
|
|
107
|
+
}
|
|
108
|
+
throw error instanceof RuleError ? error : new RuleError(`cannot load ${sources.join(', ')}: ${message}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The rules in effect for one cwd, loading on first use. Under
|
|
113
|
+
* `badFilePolicy: 'fail'` a bad initial load throws on EVERY use (the
|
|
114
|
+
* pending tool call errors loudly) while the watcher keeps observing the
|
|
115
|
+
* files so a fix reloads into active rules. Cache hits refresh the
|
|
116
|
+
* least-recently-used position, so eviction drops the workspace that
|
|
117
|
+
* went longest without a decision.
|
|
118
|
+
* @param cwd - the workspace root.
|
|
119
|
+
* @returns the loaded rules.
|
|
120
|
+
*/
|
|
121
|
+
rulesFor(cwd) {
|
|
122
|
+
const existing = this.byCwd.get(cwd);
|
|
123
|
+
if (existing !== undefined) {
|
|
124
|
+
if (existing.failed === true) {
|
|
125
|
+
throw new RuleError(existing.lastError ?? `rule load failed for ${cwd}`);
|
|
126
|
+
}
|
|
127
|
+
this.byCwd.delete(cwd);
|
|
128
|
+
this.byCwd.set(cwd, existing);
|
|
129
|
+
return existing;
|
|
130
|
+
}
|
|
131
|
+
this.evictIfFull(cwd);
|
|
132
|
+
try {
|
|
133
|
+
const loaded = this.load(cwd);
|
|
134
|
+
this.byCwd.set(cwd, loaded);
|
|
135
|
+
this.reconcileWatch(cwd, loaded.sources);
|
|
136
|
+
return loaded;
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
140
|
+
const sources = this.resolveSources(cwd);
|
|
141
|
+
this.byCwd.set(cwd, { cwd, sources, compiled: { rules: [], caseInsensitivePaths: this.config.caseInsensitivePaths }, lastError: message, failed: true });
|
|
142
|
+
this.reconcileWatch(cwd, sources);
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Re-read the rule-file chain for one cwd (watch-driven or
|
|
148
|
+
* `/rules reload`). A bad file NEVER crashes the process: the previous
|
|
149
|
+
* rules stay active, the error is logged and reported on the next
|
|
150
|
+
* `/rules` output.
|
|
151
|
+
* @param cwd - the workspace root.
|
|
152
|
+
*/
|
|
153
|
+
reload(cwd) {
|
|
154
|
+
const previous = this.byCwd.get(cwd);
|
|
155
|
+
try {
|
|
156
|
+
const loaded = this.load(cwd);
|
|
157
|
+
this.byCwd.set(cwd, loaded);
|
|
158
|
+
this.reconcileWatch(cwd, loaded.sources);
|
|
159
|
+
this.ctx.logger.info(`permission-rules: reloaded ${loaded.compiled.rules.length} rule(s) from ${loaded.sources.join(', ') || '(empty rule set)'} for ${cwd}`);
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
163
|
+
this.ctx.logger.warn(`permission-rules: reload failed for ${cwd}: ${message} (keeping previous rules)`);
|
|
164
|
+
if (previous !== undefined)
|
|
165
|
+
this.byCwd.set(cwd, { ...previous, lastError: message });
|
|
166
|
+
else
|
|
167
|
+
this.byCwd.set(cwd, { cwd, sources: this.resolveSources(cwd), compiled: { rules: [], caseInsensitivePaths: this.config.caseInsensitivePaths }, lastError: message, failed: true });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/** The match context `when` conditions evaluate against. */
|
|
171
|
+
matchContext() {
|
|
172
|
+
return { platform: process.platform, env: process.env };
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* The `tools/pre-execute` listener. A deny/ask hit returns the decision
|
|
176
|
+
* (first match wins, short-circuiting downstream listeners); an allow hit
|
|
177
|
+
* and a passthrough MUST delegate via `next()` so later listeners keep
|
|
178
|
+
* their say. Audit is appended once the final outcome is known, so the
|
|
179
|
+
* recorded `outcome` matches what the waterfall settled on.
|
|
180
|
+
* @param exec - the pending call (name, parsed arguments, caller agent).
|
|
181
|
+
* @param next - the downstream chain.
|
|
182
|
+
* @returns the pre-execute decision.
|
|
183
|
+
*/
|
|
184
|
+
async preExecute(exec, next) {
|
|
185
|
+
const cwd = exec.agent?.session.header.cwd ?? process.cwd();
|
|
186
|
+
const loaded = this.rulesFor(cwd);
|
|
187
|
+
const hit = matchRules(loaded.compiled, exec.name, exec.arguments, cwd, this.matchContext());
|
|
188
|
+
if (hit === undefined || hit.rule.action === 'allow') {
|
|
189
|
+
const decision = await next();
|
|
190
|
+
this.audit(exec, loaded, hit, decision.kind);
|
|
191
|
+
return decision;
|
|
192
|
+
}
|
|
193
|
+
const outcome = hit.rule.action;
|
|
194
|
+
this.audit(exec, loaded, hit, outcome);
|
|
195
|
+
if (outcome === 'deny')
|
|
196
|
+
return { kind: 'deny', reason: hit.rule.reason };
|
|
197
|
+
return { kind: 'ask', reason: hit.rule.reason };
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Append the log-only `permissionRules/decision` audit event for every
|
|
201
|
+
* decision (passthrough included unless `audit: 'hits'`), requesting the
|
|
202
|
+
* envelope's `ignorable: true` marker so any harness build can load the
|
|
203
|
+
* log (readers that do not know the type skip the audit record instead of
|
|
204
|
+
* refusing the session). `source` names the matched rule's file, or the
|
|
205
|
+
* nearest effective file on a passthrough. Agentless calls have no
|
|
206
|
+
* session to audit; append failures are contained so an audit hiccup can
|
|
207
|
+
* never change a permission decision.
|
|
208
|
+
* @param exec - the pending call.
|
|
209
|
+
* @param loaded - the rules in effect.
|
|
210
|
+
* @param hit - the first matching rule, or undefined for passthrough.
|
|
211
|
+
* @param outcome - the final pre-execute decision.
|
|
212
|
+
*/
|
|
213
|
+
audit(exec, loaded, hit, outcome) {
|
|
214
|
+
if (this.config.audit === 'hits' && hit === undefined)
|
|
215
|
+
return;
|
|
216
|
+
const agent = exec.agent;
|
|
217
|
+
if (agent === undefined)
|
|
218
|
+
return;
|
|
219
|
+
try {
|
|
220
|
+
;
|
|
221
|
+
agent.session.append('permissionRules/decision', {
|
|
222
|
+
toolName: exec.name,
|
|
223
|
+
callId: exec.callId,
|
|
224
|
+
source: hit === undefined ? (loaded.sources[0] ?? '') : (loaded.sources[hit.rule.sourceIndex] ?? ''),
|
|
225
|
+
action: hit === undefined ? 'passthrough' : hit.rule.action,
|
|
226
|
+
outcome,
|
|
227
|
+
...hit !== undefined ? { ruleIndex: hit.ruleIndex, reason: hit.rule.reason } : {},
|
|
228
|
+
}, { ignorable: true });
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
this.ctx.logger.warn(`permission-rules: audit append failed: ${String(error)}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Execute the `/rules` command: bare `/rules` lists the active rules and
|
|
236
|
+
* their sources; `/rules reload` re-reads the chain; `/rules decisions
|
|
237
|
+
* [n]` shows the session's audit trail; `/rules test <tool> <json>`
|
|
238
|
+
* dry-evaluates the rules against a hypothetical call. Command output
|
|
239
|
+
* stays in the UI — nothing here is injected into the model context.
|
|
240
|
+
* @param invocation - the received command invocation.
|
|
241
|
+
* @returns the command result shown to the user.
|
|
242
|
+
*/
|
|
243
|
+
command(invocation) {
|
|
244
|
+
const prose = UI_PROSE[this.config.language];
|
|
245
|
+
const raw = invocation.rawInput.trim();
|
|
246
|
+
const [verbRaw, ...rest] = raw.split(/\s+/);
|
|
247
|
+
const verb = (verbRaw ?? '').toLowerCase();
|
|
248
|
+
const cwd = invocation.agent.session.header.cwd ?? process.cwd();
|
|
249
|
+
if (verb === 'reload') {
|
|
250
|
+
if (rest.length > 0)
|
|
251
|
+
return { kind: 'error', text: prose.unknownArg(invocation.rawInput.trim()) };
|
|
252
|
+
this.reload(cwd);
|
|
253
|
+
const reloaded = this.byCwd.get(cwd);
|
|
254
|
+
if (reloaded?.lastError !== undefined)
|
|
255
|
+
return { kind: 'error', text: prose.reloadFailed(reloaded.lastError) };
|
|
256
|
+
const rules = reloaded?.compiled.rules ?? [];
|
|
257
|
+
const source = reloaded === undefined || reloaded.sources.length === 0 ? prose.emptySource : reloaded.sources.join(', ');
|
|
258
|
+
return { kind: 'success', text: prose.reloaded(rules.length, source) };
|
|
259
|
+
}
|
|
260
|
+
if (verb === 'decisions') {
|
|
261
|
+
if (rest.length > 1)
|
|
262
|
+
return { kind: 'error', text: prose.unknownArg(invocation.rawInput.trim()) };
|
|
263
|
+
let count = 10;
|
|
264
|
+
if (rest.length === 1) {
|
|
265
|
+
const parsed = Number(rest[0]);
|
|
266
|
+
if (!Number.isSafeInteger(parsed) || parsed <= 0)
|
|
267
|
+
return { kind: 'error', text: prose.invalidDecisionsCount(rest[0]) };
|
|
268
|
+
count = parsed;
|
|
269
|
+
}
|
|
270
|
+
return this.decisionsCommand(invocation, count, prose);
|
|
271
|
+
}
|
|
272
|
+
if (verb === 'test') {
|
|
273
|
+
const verbText = verbRaw ?? '';
|
|
274
|
+
const restLine = raw.length > verbText.length ? raw.slice(verbText.length).trim() : '';
|
|
275
|
+
const match = /^(\S+)\s*(.*)$/s.exec(restLine);
|
|
276
|
+
const tool = match?.[1];
|
|
277
|
+
const jsonText = match?.[2] ?? '';
|
|
278
|
+
if (tool === undefined || tool.length === 0)
|
|
279
|
+
return { kind: 'error', text: prose.testUsage };
|
|
280
|
+
let args;
|
|
281
|
+
try {
|
|
282
|
+
args = jsonText.length > 0 ? JSON.parse(jsonText) : {};
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
return { kind: 'error', text: prose.testBadJson(jsonText) };
|
|
286
|
+
}
|
|
287
|
+
let loaded;
|
|
288
|
+
try {
|
|
289
|
+
loaded = this.rulesFor(cwd);
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
return { kind: 'error', text: error instanceof Error ? error.message : String(error) };
|
|
293
|
+
}
|
|
294
|
+
const hit = matchRules(loaded.compiled, tool, args, cwd, this.matchContext());
|
|
295
|
+
return {
|
|
296
|
+
kind: 'success',
|
|
297
|
+
text: hit === undefined ? prose.testNoMatch(tool) : prose.testHit(tool, hit.ruleIndex, hit.rule.action, hit.rule.reason),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
if (verb !== '')
|
|
301
|
+
return { kind: 'error', text: prose.unknownArg(invocation.rawInput.trim()) };
|
|
302
|
+
let loaded;
|
|
303
|
+
try {
|
|
304
|
+
loaded = this.rulesFor(cwd);
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
return { kind: 'error', text: error instanceof Error ? error.message : String(error) };
|
|
308
|
+
}
|
|
309
|
+
const lines = [];
|
|
310
|
+
if (loaded.sources.length === 0) {
|
|
311
|
+
lines.push(prose.noRules(cwd, this.config.fallbackPath !== undefined ? prose.fallbackMissing : ''));
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
lines.push(prose.rulesHeader(loaded.compiled.rules.length, loaded.sources, cwd));
|
|
315
|
+
lines.push(...loaded.compiled.rules.map(rule => describeRule(rule, DESCRIBE_TOKENS[this.config.language])));
|
|
316
|
+
const unreachable = findUnreachableRules(loaded.compiled);
|
|
317
|
+
if (unreachable.length > 0)
|
|
318
|
+
lines.push(prose.unreachableWarning(unreachable.map(index => index + 1)));
|
|
319
|
+
}
|
|
320
|
+
if (loaded.lastError !== undefined) {
|
|
321
|
+
lines.push(prose.lastReloadWarning(loaded.lastError));
|
|
322
|
+
}
|
|
323
|
+
lines.push(prose.usage);
|
|
324
|
+
return { kind: 'success', text: lines.join('\n') };
|
|
325
|
+
}
|
|
326
|
+
/** Render the session's `permissionRules/decision` audit trail, newest last. */
|
|
327
|
+
decisionsCommand(invocation, count, prose) {
|
|
328
|
+
const decisions = invocation.agent.session.events.filter(event => event.type === 'permissionRules/decision');
|
|
329
|
+
if (decisions.length === 0)
|
|
330
|
+
return { kind: 'success', text: prose.noDecisions };
|
|
331
|
+
const shown = decisions.slice(-count);
|
|
332
|
+
const lines = [prose.decisionsHeader(shown.length, decisions.length)];
|
|
333
|
+
for (const event of shown) {
|
|
334
|
+
const data = event.data;
|
|
335
|
+
lines.push(prose.decisionLine(event.seq, data.action, data.toolName, data.ruleIndex, data.reason));
|
|
336
|
+
}
|
|
337
|
+
return { kind: 'success', text: lines.join('\n') };
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Validate deployment-level file references at mount — the earliest
|
|
341
|
+
* resolvable point. An absolute `rulesFile` or a configured
|
|
342
|
+
* `fallbackPath` must exist and parse; a missing referent fails the mount
|
|
343
|
+
* loudly instead of silently degrading later.
|
|
344
|
+
* @throws when a mandated file is missing or invalid.
|
|
345
|
+
*/
|
|
346
|
+
validateDeploymentFiles() {
|
|
347
|
+
if (isAbsolute(this.config.rulesFile)) {
|
|
348
|
+
this.loadForValidation(this.config.rulesFile);
|
|
349
|
+
}
|
|
350
|
+
if (this.config.fallbackPath !== undefined) {
|
|
351
|
+
const fallbackPath = isAbsolute(this.config.fallbackPath) ? this.config.fallbackPath : resolve(this.config.fallbackPath);
|
|
352
|
+
if (!existsSync(fallbackPath)) {
|
|
353
|
+
throw new RuleError(`permission-rules: fallbackPath ${JSON.stringify(this.config.fallbackPath)} does not exist`);
|
|
354
|
+
}
|
|
355
|
+
this.loadForValidation(fallbackPath);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
/** Parse and compile one mandated file, rethrowing as a mount failure. */
|
|
359
|
+
loadForValidation(filePath) {
|
|
360
|
+
try {
|
|
361
|
+
const doc = parseRulesDocument(readFileSync(filePath, 'utf8'));
|
|
362
|
+
compileRules(doc, this.compileOptions());
|
|
363
|
+
}
|
|
364
|
+
catch (error) {
|
|
365
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
366
|
+
throw error instanceof RuleError ? error : new RuleError(`permission-rules: cannot load ${filePath}: ${message}`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
/** Attach (once per file path) the Chokidar watcher feeding {@link reload}. */
|
|
370
|
+
attachWatch(cwd, source) {
|
|
371
|
+
if (!this.config.watch || source === '')
|
|
372
|
+
return;
|
|
373
|
+
const existing = this.watchers.get(source);
|
|
374
|
+
if (existing !== undefined) {
|
|
375
|
+
existing.cwds.add(cwd);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
const cwds = new Set([cwd]);
|
|
379
|
+
const watcher = chokidar.watch(source, { persistent: true, ignoreInitial: true });
|
|
380
|
+
const onEvent = () => this.scheduleReload(source);
|
|
381
|
+
watcher.on('add', onEvent);
|
|
382
|
+
watcher.on('change', onEvent);
|
|
383
|
+
watcher.on('unlink', onEvent);
|
|
384
|
+
watcher.on('error', (error) => {
|
|
385
|
+
this.ctx.logger.warn(`permission-rules: watcher error on ${source}: ${String(error)}`);
|
|
386
|
+
});
|
|
387
|
+
this.watchers.set(source, {
|
|
388
|
+
cwds,
|
|
389
|
+
close: () => {
|
|
390
|
+
void watcher.close().catch((error) => {
|
|
391
|
+
this.ctx.logger.warn(`permission-rules: failed to close watcher on ${source}: ${String(error)}`);
|
|
392
|
+
});
|
|
393
|
+
},
|
|
394
|
+
});
|
|
395
|
+
this.ctx.effect(() => () => {
|
|
396
|
+
this.watchers.get(source)?.close();
|
|
397
|
+
this.watchers.delete(source);
|
|
398
|
+
const timer = this.timers.get(source);
|
|
399
|
+
if (timer !== undefined) {
|
|
400
|
+
clearTimeout(timer);
|
|
401
|
+
this.timers.delete(source);
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Rebind `cwd` to its current rule-source chain. When a concrete source
|
|
407
|
+
* left the chain (deleted file, fallback switch), detach the workspace
|
|
408
|
+
* from watchers serving other sources and close the ones left empty, so
|
|
409
|
+
* long-running hosts cannot accumulate stale watchers. An empty chain (no
|
|
410
|
+
* rule file in effect) keeps the previous watchers alive: they own the
|
|
411
|
+
* "file recreated → adopted" signal, and the workspace-cache bound keeps
|
|
412
|
+
* the set of such watchers finite.
|
|
413
|
+
*/
|
|
414
|
+
reconcileWatch(cwd, sources) {
|
|
415
|
+
if (sources.length === 0)
|
|
416
|
+
return;
|
|
417
|
+
for (const [watchedSource, entry] of this.watchers) {
|
|
418
|
+
if (!sources.includes(watchedSource))
|
|
419
|
+
entry.cwds.delete(cwd);
|
|
420
|
+
}
|
|
421
|
+
this.pruneWatchers();
|
|
422
|
+
for (const source of sources)
|
|
423
|
+
this.attachWatch(cwd, source);
|
|
424
|
+
}
|
|
425
|
+
/** Close and drop watchers whose workspace sets are empty, clearing their debounce timers. */
|
|
426
|
+
pruneWatchers() {
|
|
427
|
+
for (const [source, entry] of this.watchers) {
|
|
428
|
+
if (entry.cwds.size > 0)
|
|
429
|
+
continue;
|
|
430
|
+
entry.close();
|
|
431
|
+
this.watchers.delete(source);
|
|
432
|
+
const timer = this.timers.get(source);
|
|
433
|
+
if (timer !== undefined) {
|
|
434
|
+
clearTimeout(timer);
|
|
435
|
+
this.timers.delete(source);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Bound the per-workspace cache: when a NEW workspace would exceed
|
|
441
|
+
* `maxCachedWorkspaces`, evict the least-recently-used entry (the Map
|
|
442
|
+
* head, refreshed by every {@link rulesFor} hit) and release its watcher
|
|
443
|
+
* slots. Long-lived hosts that visit many workspaces therefore keep a
|
|
444
|
+
* bounded memory footprint.
|
|
445
|
+
*/
|
|
446
|
+
evictIfFull(cwd) {
|
|
447
|
+
if (this.byCwd.size < this.config.maxCachedWorkspaces || this.byCwd.has(cwd))
|
|
448
|
+
return;
|
|
449
|
+
const oldest = this.byCwd.keys().next().value;
|
|
450
|
+
if (oldest === undefined)
|
|
451
|
+
return;
|
|
452
|
+
this.byCwd.delete(oldest);
|
|
453
|
+
for (const entry of this.watchers.values())
|
|
454
|
+
entry.cwds.delete(oldest);
|
|
455
|
+
this.pruneWatchers();
|
|
456
|
+
}
|
|
457
|
+
/** Number of live watchers, for observability and tests. */
|
|
458
|
+
activeWatcherCount() {
|
|
459
|
+
return this.watchers.size;
|
|
460
|
+
}
|
|
461
|
+
/** Number of pending debounce timers, for observability and tests. */
|
|
462
|
+
pendingReloadCount() {
|
|
463
|
+
return this.timers.size;
|
|
464
|
+
}
|
|
465
|
+
/** Debounce watch events into one reload per stability window. */
|
|
466
|
+
scheduleReload(source) {
|
|
467
|
+
const existing = this.timers.get(source);
|
|
468
|
+
if (existing !== undefined)
|
|
469
|
+
clearTimeout(existing);
|
|
470
|
+
const timer = setTimeout(() => {
|
|
471
|
+
this.timers.delete(source);
|
|
472
|
+
for (const cwd of this.watchers.get(source)?.cwds ?? [])
|
|
473
|
+
this.reload(cwd);
|
|
474
|
+
}, this.config.watchStabilityThresholdMs);
|
|
475
|
+
this.timers.set(source, timer);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Mount the plugin: resolve config, validate deployment-level rule files,
|
|
480
|
+
* register the pre-execute listener and the `/rules` command.
|
|
481
|
+
* @param ctx - the host context.
|
|
482
|
+
* @param config - raw plugin config.
|
|
483
|
+
*/
|
|
484
|
+
export function apply(ctx, config) {
|
|
485
|
+
const resolved = resolveConfig(config);
|
|
486
|
+
const runtime = new PermissionRulesRuntime(ctx, resolved);
|
|
487
|
+
runtime.validateDeploymentFiles();
|
|
488
|
+
ctx.provide('permissionRulesRuntime', runtime);
|
|
489
|
+
ctx.on('tools/pre-execute', (exec, next) => runtime.preExecute(exec, next));
|
|
490
|
+
ctx.commands.register({
|
|
491
|
+
name: 'rules',
|
|
492
|
+
description: 'list, reload, audit, or dry-test the active permission rules for this workspace',
|
|
493
|
+
input: { hint: '[reload | decisions [n] | test <tool> <json-args>]' },
|
|
494
|
+
handler: invocation => runtime.command(invocation),
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
//# sourceMappingURL=runtime.js.map
|