dsh-permission-rules 0.7.21 → 0.7.22
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 +7 -0
- package/README.en.md +504 -0
- package/README.es.md +504 -0
- package/README.hi.md +504 -0
- package/README.md +309 -245
- package/README.pt.md +504 -0
- package/docs/assets/dsh-permission-rules-flow-dark.svg +58 -0
- package/docs/assets/dsh-permission-rules-flow-light.svg +58 -0
- package/docs/assets/dsh-permission-rules-flow-v5-dark.svg +103 -0
- package/docs/assets/dsh-permission-rules-flow-v5-light.svg +103 -0
- package/docs/assets/dsh-permission-rules-hero-en-dark.webp +0 -0
- package/docs/assets/dsh-permission-rules-hero-en-light.webp +0 -0
- package/docs/assets/dsh-permission-rules-hero-zh-dark.webp +0 -0
- package/docs/assets/dsh-permission-rules-hero-zh-light.webp +0 -0
- package/docs/rules-format.en.md +5 -4
- package/docs/rules-format.md +5 -4
- package/lib/index.js +230 -40
- package/lib/types/config.d.ts +9 -4
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/prose.d.ts +6 -0
- package/lib/types/prose.d.ts.map +1 -1
- package/lib/types/rules.d.ts +74 -15
- package/lib/types/rules.d.ts.map +1 -1
- package/lib/types/runtime.d.ts +59 -12
- package/lib/types/runtime.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/config.ts +9 -4
- package/src/prose.ts +21 -0
- package/src/rules.ts +131 -24
- package/src/runtime.ts +140 -24
- package/README-es.md +0 -443
- package/README-hi.md +0 -445
- package/README-pt.md +0 -443
- package/README-zh.md +0 -443
package/src/rules.ts
CHANGED
|
@@ -70,10 +70,38 @@ export class RuleError extends Error {
|
|
|
70
70
|
/** A parsed, shape-validated rule document (patterns not yet compiled). */
|
|
71
71
|
export interface RulesFileDoc {
|
|
72
72
|
rules: RuleDocEntry[]
|
|
73
|
+
/**
|
|
74
|
+
* Rules an ISOLATED parse could not validate and therefore skipped. The
|
|
75
|
+
* strict {@link parseRulesDocument} throws on the first one instead and
|
|
76
|
+
* never populates this list.
|
|
77
|
+
*/
|
|
78
|
+
issues?: readonly RuleIssue[]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* One rule the loader disabled instead of failing the whole file: the
|
|
83
|
+
* per-rule blast radius of issue #49. `index` is the rule's 0-based
|
|
84
|
+
* position in the source document's `rules` list, so an error keeps
|
|
85
|
+
* pointing at the entry the author wrote even when other entries were
|
|
86
|
+
* skipped (a bare array position would drift).
|
|
87
|
+
*/
|
|
88
|
+
export interface RuleIssue {
|
|
89
|
+
/** 0-based position in the source document's `rules` list. */
|
|
90
|
+
readonly index: number
|
|
91
|
+
/** The `RuleError`-shaped message naming the rule and the bad field/pattern. */
|
|
92
|
+
readonly message: string
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The isolated per-source compile result: the rules that loaded plus the ones disabled. */
|
|
96
|
+
export interface CompiledSourceRules {
|
|
97
|
+
readonly rules: readonly CompiledRule[]
|
|
98
|
+
readonly issues: readonly RuleIssue[]
|
|
73
99
|
}
|
|
74
100
|
|
|
75
101
|
/** One parsed rule: match dimensions plus the action, reason, and metadata. */
|
|
76
102
|
export interface RuleDocEntry {
|
|
103
|
+
/** 0-based position in the source document's `rules` list. */
|
|
104
|
+
readonly index: number
|
|
77
105
|
match: RuleMatchDoc
|
|
78
106
|
action: RuleAction
|
|
79
107
|
reason: string
|
|
@@ -287,28 +315,71 @@ export interface MatchContext {
|
|
|
287
315
|
* Parse one rules file body. Unknown document/rule/match fields, invalid
|
|
288
316
|
* actions, missing reasons, and wrong-shaped collections all throw
|
|
289
317
|
* {@link RuleError} — a malformed file fails loudly at load, never silently.
|
|
318
|
+
* This is the STRICT shape: editor saves use it so a hand edit is refused
|
|
319
|
+
* with its error instead of being silently dropped. The loader uses
|
|
320
|
+
* {@link parseRulesDocumentIsolated} instead, where one bad rule disables
|
|
321
|
+
* only itself.
|
|
290
322
|
* @param text - the raw YAML text.
|
|
291
323
|
* @returns the validated document.
|
|
292
324
|
*/
|
|
293
325
|
export function parseRulesDocument(text: string): RulesFileDoc {
|
|
326
|
+
return { rules: parseRulesSection(text).map((raw, index) => parseRuleEntry(raw, index)) }
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Parse one rules file body with per-rule isolation (issue #49): a rule
|
|
331
|
+
* whose shape is invalid is skipped and reported in
|
|
332
|
+
* {@link RulesFileDoc.issues}, and every other rule still loads. Document
|
|
333
|
+
* STRUCTURE errors (invalid YAML, an unknown top-level field, a `rules`
|
|
334
|
+
* value that is not a list) are still file-level and throw: they leave no
|
|
335
|
+
* rule list to isolate.
|
|
336
|
+
* @param text - the raw YAML text.
|
|
337
|
+
* @returns the validated document plus the skipped-rule reports.
|
|
338
|
+
*/
|
|
339
|
+
export function parseRulesDocumentIsolated(text: string): RulesFileDoc {
|
|
340
|
+
const rules: RuleDocEntry[] = []
|
|
341
|
+
const issues: RuleIssue[] = []
|
|
342
|
+
parseRulesSection(text).forEach((raw, index) => {
|
|
343
|
+
try {
|
|
344
|
+
rules.push(parseRuleEntry(raw, index))
|
|
345
|
+
} catch (error: unknown) {
|
|
346
|
+
issues.push({ index, message: ruleErrorMessage(error) })
|
|
347
|
+
}
|
|
348
|
+
})
|
|
349
|
+
return issues.length === 0 ? { rules } : { rules, issues }
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Parse the document SHELL: validate the YAML, the top-level mapping, and
|
|
354
|
+
* the `rules` list, returning the raw rule entries. Everything below this
|
|
355
|
+
* level can be isolated per rule.
|
|
356
|
+
*/
|
|
357
|
+
function parseRulesSection(text: string): unknown[] {
|
|
294
358
|
const parsed = parseYaml(normalizeLineEndings(text))
|
|
295
359
|
// An empty (or comment-only) file is a valid empty rule set.
|
|
296
|
-
if (parsed === null || parsed === undefined) return
|
|
360
|
+
if (parsed === null || parsed === undefined) return []
|
|
297
361
|
const root = asRecord(parsed, 'rules document root')
|
|
298
362
|
const unknownRoot = Object.keys(root).filter(key => key !== 'rules')
|
|
299
363
|
if (unknownRoot.length > 0) {
|
|
300
364
|
throw new RuleError(`unknown top-level field${unknownRoot.length > 1 ? 's' : ''} ${unknownRoot.map(k => JSON.stringify(k)).join(', ')} (only "rules" is allowed)`)
|
|
301
365
|
}
|
|
302
366
|
const rawRules = root['rules']
|
|
303
|
-
if (rawRules === undefined) return
|
|
367
|
+
if (rawRules === undefined) return []
|
|
304
368
|
if (!Array.isArray(rawRules)) throw new RuleError('"rules" must be a list')
|
|
305
|
-
return
|
|
369
|
+
return rawRules
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** One error's message, for an isolated-rule report. */
|
|
373
|
+
function ruleErrorMessage(error: unknown): string {
|
|
374
|
+
return error instanceof Error ? error.message : String(error)
|
|
306
375
|
}
|
|
307
376
|
|
|
308
377
|
/**
|
|
309
|
-
* Compile a parsed document for the hot path: pattern strings
|
|
310
|
-
* RegExps (invalid globs/regexes throw here
|
|
311
|
-
*
|
|
378
|
+
* Compile a parsed document for the hot path STRICTLY: pattern strings
|
|
379
|
+
* become RegExps (invalid globs/regexes throw here), and the rule count is
|
|
380
|
+
* capped by `maxRules`. Used by editor saves (and the generated allow-host
|
|
381
|
+
* rule), so a hand edit never has a rule silently dropped. The loader uses
|
|
382
|
+
* {@link compileRulesText} instead.
|
|
312
383
|
* @param doc - the parsed document.
|
|
313
384
|
* @param options - pattern mode, caps, and case handling.
|
|
314
385
|
* @returns the compiled ruleset (every rule attributed to source 0).
|
|
@@ -317,21 +388,55 @@ export function compileRules(doc: RulesFileDoc, options: CompileOptions): Compil
|
|
|
317
388
|
if (doc.rules.length > options.maxRules) {
|
|
318
389
|
throw new RuleError(`rule count ${doc.rules.length} exceeds maxRules ${options.maxRules}`)
|
|
319
390
|
}
|
|
320
|
-
return mergeCompiledRules([
|
|
391
|
+
return mergeCompiledRules([doc.rules.map(entry => compileRule(entry, 0, options))], options)
|
|
321
392
|
}
|
|
322
393
|
|
|
323
394
|
/**
|
|
324
|
-
* Compile one parsed document into per-source compiled rules
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
395
|
+
* Compile one parsed document into per-source compiled rules with
|
|
396
|
+
* per-rule isolation (issue #49): a rule whose patterns cannot compile is
|
|
397
|
+
* disabled and reported in {@link CompiledSourceRules.issues}, while every
|
|
398
|
+
* other rule still loads. The returned rules carry source-local `index`
|
|
399
|
+
* values and `sourceIndex` 0 — the cacheable unit a runtime can reuse
|
|
400
|
+
* across chains (keyed by source path + content hash). The total-count cap
|
|
401
|
+
* is NOT applied here; it bounds the merged chain in
|
|
402
|
+
* {@link mergeCompiledRules}.
|
|
403
|
+
* @param doc - the parsed document (isolated or strict).
|
|
404
|
+
* @param options - compile options.
|
|
405
|
+
* @returns the compiled rules of one source plus the disabled-rule reports.
|
|
406
|
+
*/
|
|
407
|
+
export function compileSourceRules(doc: RulesFileDoc, options: CompileOptions): CompiledSourceRules {
|
|
408
|
+
const rules: CompiledRule[] = []
|
|
409
|
+
const issues: RuleIssue[] = [...(doc.issues ?? [])]
|
|
410
|
+
for (const entry of doc.rules) {
|
|
411
|
+
try {
|
|
412
|
+
rules.push(compileRule(entry, 0, options))
|
|
413
|
+
} catch (error: unknown) {
|
|
414
|
+
issues.push({ index: entry.index, message: ruleErrorMessage(error) })
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
issues.sort((left, right) => left.index - right.index)
|
|
418
|
+
return { rules, issues }
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Parse and compile one rule-file text with per-rule isolation — the
|
|
423
|
+
* loader-facing entry point for issue #49. A bad rule disables only itself
|
|
424
|
+
* (reported with its 0-based document index); the rest of the file stays
|
|
425
|
+
* active. A file-level failure still throws, so `badFilePolicy` keeps
|
|
426
|
+
* choosing how a whole unreadable/unparseable file is handled.
|
|
427
|
+
* @param text - the raw YAML text.
|
|
330
428
|
* @param options - compile options.
|
|
331
|
-
* @returns the compiled rules
|
|
429
|
+
* @returns the compiled rules plus the disabled-rule reports.
|
|
332
430
|
*/
|
|
333
|
-
export function
|
|
334
|
-
|
|
431
|
+
export function compileRulesText(text: string, options: CompileOptions): CompiledSourceRules {
|
|
432
|
+
const doc = parseRulesDocumentIsolated(text)
|
|
433
|
+
// The cap counts RAW entries, so a file full of invalid rules cannot
|
|
434
|
+
// sidestep the DoS bound by having its entries skipped.
|
|
435
|
+
const total = doc.rules.length + (doc.issues?.length ?? 0)
|
|
436
|
+
if (total > options.maxRules) {
|
|
437
|
+
throw new RuleError(`rule count ${total} exceeds maxRules ${options.maxRules}`)
|
|
438
|
+
}
|
|
439
|
+
return compileSourceRules(doc, options)
|
|
335
440
|
}
|
|
336
441
|
|
|
337
442
|
/**
|
|
@@ -356,23 +461,25 @@ export function mergeCompiledRules(sources: readonly CompiledRule[][], options:
|
|
|
356
461
|
}
|
|
357
462
|
|
|
358
463
|
/**
|
|
359
|
-
* Parse and compile an ordered source chain (nearest file first).
|
|
360
|
-
* keep per-file order and are evaluated in chain order, so a nearer
|
|
361
|
-
* can override a farther one on first-match semantics; `sourceIndex`
|
|
464
|
+
* Parse and compile an ordered source chain (nearest file first) STRICTLY.
|
|
465
|
+
* Rules keep per-file order and are evaluated in chain order, so a nearer
|
|
466
|
+
* file can override a farther one on first-match semantics; `sourceIndex`
|
|
362
467
|
* attributes every rule to its file. The TOTAL rule count is capped by
|
|
363
|
-
* `maxRules`.
|
|
468
|
+
* `maxRules`. The plugin's loader does not use this helper — it compiles
|
|
469
|
+
* each file through {@link compileRulesText} so one bad rule cannot take
|
|
470
|
+
* the chain down.
|
|
364
471
|
* @param entries - the rule-file texts, nearest file first.
|
|
365
472
|
* @param options - compile options.
|
|
366
473
|
* @returns the merged ruleset and the source paths it references.
|
|
367
474
|
*/
|
|
368
475
|
export function compileRulesChain(entries: readonly RulesChainEntry[], options: CompileOptions): { ruleset: CompiledRuleset; sources: string[] } {
|
|
369
476
|
const sources = entries.map(entry => entry.path)
|
|
370
|
-
const compiledSources = entries.map(entry =>
|
|
477
|
+
const compiledSources = entries.map(entry => parseRulesDocument(entry.text).rules.map(rule => compileRule(rule, 0, options)))
|
|
371
478
|
return { ruleset: mergeCompiledRules(compiledSources, options), sources }
|
|
372
479
|
}
|
|
373
480
|
|
|
374
481
|
/** Compile one parsed rule into its hot-path form. */
|
|
375
|
-
function compileRule(entry: RuleDocEntry,
|
|
482
|
+
function compileRule(entry: RuleDocEntry, sourceIndex: number, options: CompileOptions): CompiledRule {
|
|
376
483
|
const tools = entry.match.tools.map(pattern => compileToolPattern(pattern, options.maxGlobStars))
|
|
377
484
|
const agents = entry.match.agents.map(pattern => compileToolPattern(pattern, options.maxGlobStars))
|
|
378
485
|
const params = new Map<string, { positive: readonly RegExp[]; negative: readonly RegExp[] }>()
|
|
@@ -386,7 +493,7 @@ function compileRule(entry: RuleDocEntry, index: number, sourceIndex: number, op
|
|
|
386
493
|
}
|
|
387
494
|
const argv = entry.match.argv === undefined ? undefined : compileArgv(entry.match.argv, options)
|
|
388
495
|
return {
|
|
389
|
-
index,
|
|
496
|
+
index: entry.index,
|
|
390
497
|
sourceIndex,
|
|
391
498
|
action: entry.action,
|
|
392
499
|
reason: entry.reason,
|
|
@@ -768,7 +875,7 @@ function parseRuleEntry(raw: unknown, index: number): RuleDocEntry {
|
|
|
768
875
|
throw new RuleError(`${at}: description must be a non-empty string`)
|
|
769
876
|
}
|
|
770
877
|
const match = parseMatch(record['match'], at)
|
|
771
|
-
return { match, action: action as RuleAction, reason, enabled: enabled !== false, ...(description !== undefined ? { description } : {}), tags: parseStringList(record['tags'], `${at}.tags`) }
|
|
878
|
+
return { index, match, action: action as RuleAction, reason, enabled: enabled !== false, ...(description !== undefined ? { description } : {}), tags: parseStringList(record['tags'], `${at}.tags`) }
|
|
772
879
|
}
|
|
773
880
|
|
|
774
881
|
/** Validate the `match` block of one rule. */
|
package/src/runtime.ts
CHANGED
|
@@ -26,8 +26,8 @@ import type { CallId } from './call-id.ts'
|
|
|
26
26
|
import { insertAllowRule, isWritableHost, normalizeAllowHost, resolveAllowHostTarget } from './allow-host.ts'
|
|
27
27
|
import type { AllowHostInsertion } from './allow-host.ts'
|
|
28
28
|
import type { AllowHostRequest, AllowHostResult } from './wire.ts'
|
|
29
|
-
import { compileRules,
|
|
30
|
-
import type { CompileOptions, CompiledRule, CompiledRuleset, MatchContext, NetworkTarget, RuleHit } from './rules.ts'
|
|
29
|
+
import { compileRules, compileRulesText, describeRule, findUnreachableRules, isShellTool, matchRules, mergeCompiledRules, normalizeWorkspacePath, parseRulesDocument, PLATFORMS, RuleError } from './rules.ts'
|
|
30
|
+
import type { CompileOptions, CompiledRule, CompiledRuleset, MatchContext, NetworkTarget, RuleHit, RuleIssue } from './rules.ts'
|
|
31
31
|
import { DESCRIBE_TOKENS, UI_PROSE } from './prose.ts'
|
|
32
32
|
import type { UiProse } from './prose.ts'
|
|
33
33
|
import { blockMessage, decideNetworkTarget, defaultDecision, networkModeForSandbox } from './network.ts'
|
|
@@ -50,6 +50,12 @@ export const name = 'permission-rules'
|
|
|
50
50
|
/** Services required before the plugin mounts. */
|
|
51
51
|
export const inject = ['commands', 'tools']
|
|
52
52
|
|
|
53
|
+
/** One rule disabled at load, attributed to the source file that declared it (issue #49). */
|
|
54
|
+
export interface RuleFileIssue extends RuleIssue {
|
|
55
|
+
/** Absolute path of the rule file whose rule was disabled. */
|
|
56
|
+
readonly source: string
|
|
57
|
+
}
|
|
58
|
+
|
|
53
59
|
/** The rule state bound to one workspace cwd. */
|
|
54
60
|
interface LoadedRules {
|
|
55
61
|
/** The workspace root these rules were resolved for. */
|
|
@@ -57,7 +63,9 @@ interface LoadedRules {
|
|
|
57
63
|
/** Absolute paths of the rule files in effect, nearest first; `[]` = empty rule set. */
|
|
58
64
|
readonly sources: readonly string[]
|
|
59
65
|
readonly compiled: CompiledRuleset
|
|
60
|
-
/**
|
|
66
|
+
/** Rules disabled by per-rule parse/compile errors; the rest of the policy stays active. */
|
|
67
|
+
readonly issues?: readonly RuleFileIssue[]
|
|
68
|
+
/** Last FILE-level load error, when one is being reported (see {@link PermissionRulesRuntime.rulesFor}). */
|
|
61
69
|
readonly lastError?: string
|
|
62
70
|
/** `true` when the last initial load threw under `badFilePolicy: 'fail'`. */
|
|
63
71
|
readonly failed?: true
|
|
@@ -162,7 +170,7 @@ export class PermissionRulesRuntime {
|
|
|
162
170
|
private readonly byCwd = new Map<string, LoadedRules>()
|
|
163
171
|
|
|
164
172
|
/** Compiled per-source rules keyed by absolute path, with the content hash they were compiled from (LRU-refreshed). */
|
|
165
|
-
private readonly compileCache = new Map<string, { hash: string; rules: CompiledRule[] }>()
|
|
173
|
+
private readonly compileCache = new Map<string, { hash: string; rules: readonly CompiledRule[]; issues: readonly RuleIssue[] }>()
|
|
166
174
|
|
|
167
175
|
/** Live watchers per watched path (rule file or candidate ancestor directory), with the cwds each serves. */
|
|
168
176
|
private readonly watchers = new Map<string, WatcherEntry>()
|
|
@@ -277,10 +285,17 @@ export class PermissionRulesRuntime {
|
|
|
277
285
|
}
|
|
278
286
|
|
|
279
287
|
/**
|
|
280
|
-
* Read, parse, and compile the rule-file chain serving `cwd`.
|
|
281
|
-
*
|
|
282
|
-
* rule
|
|
283
|
-
*
|
|
288
|
+
* Read, parse, and compile the rule-file chain serving `cwd`.
|
|
289
|
+
*
|
|
290
|
+
* Per-rule failures (a bad glob/regex, an unknown field or action, a
|
|
291
|
+
* missing reason) no longer fail the whole load: that rule is disabled,
|
|
292
|
+
* reported with its 0-based document index and source file, and every
|
|
293
|
+
* other rule — in that file and in every other file of the chain — stays
|
|
294
|
+
* active (issue #49). FILE-level failures (unreadable file, invalid YAML,
|
|
295
|
+
* unknown top-level field, `rules` not a list, or a total over
|
|
296
|
+
* `maxRules`) still follow `badFilePolicy`: `'ignore-with-warning'`
|
|
297
|
+
* degrades to an empty rule set with a warning and keeps the source paths
|
|
298
|
+
* (so a later fix is watched and adopted); `'fail'` throws.
|
|
284
299
|
* @param cwd - the workspace root.
|
|
285
300
|
* @returns the loaded state.
|
|
286
301
|
*/
|
|
@@ -288,9 +303,15 @@ export class PermissionRulesRuntime {
|
|
|
288
303
|
const sources = this.resolveSources(cwd)
|
|
289
304
|
if (sources.length === 0) return { cwd, sources: [], compiled: { rules: [], caseInsensitivePaths: this.config.caseInsensitivePaths } }
|
|
290
305
|
try {
|
|
291
|
-
const compiledSources =
|
|
306
|
+
const compiledSources: CompiledRule[][] = []
|
|
307
|
+
const issues: RuleFileIssue[] = []
|
|
308
|
+
for (const path of sources) {
|
|
309
|
+
const source = this.compileSourceFile(path)
|
|
310
|
+
compiledSources.push([...source.rules])
|
|
311
|
+
for (const issue of source.issues) issues.push({ source: path, index: issue.index, message: issue.message })
|
|
312
|
+
}
|
|
292
313
|
const ruleset = mergeCompiledRules(compiledSources, this.compileOptions())
|
|
293
|
-
return { cwd, sources, compiled: ruleset }
|
|
314
|
+
return { cwd, sources, compiled: ruleset, ...(issues.length === 0 ? {} : { issues }) }
|
|
294
315
|
} catch (error: unknown) {
|
|
295
316
|
const message = error instanceof Error ? error.message : String(error)
|
|
296
317
|
if (this.config.badFilePolicy === 'ignore-with-warning') {
|
|
@@ -306,23 +327,79 @@ export class PermissionRulesRuntime {
|
|
|
306
327
|
* file's content hash is unchanged. The cache key is the source path plus
|
|
307
328
|
* the content hash, so the same file read by many workspaces (the
|
|
308
329
|
* built-in baseline and any shared fallback/absolute `rulesFile`) is
|
|
309
|
-
* parsed and compiled ONCE, not once per cwd.
|
|
330
|
+
* parsed and compiled ONCE, not once per cwd. Per-rule failures are
|
|
331
|
+
* isolated by {@link compileRulesText}; they are logged here (once per
|
|
332
|
+
* recompiled content, at `error` under `badFilePolicy: 'fail'` and at
|
|
333
|
+
* `warn` under `'ignore-with-warning'`) instead of being thrown.
|
|
310
334
|
* @param path - the absolute source path.
|
|
311
|
-
* @returns that source's compiled rules (source-local index, sourceIndex 0).
|
|
335
|
+
* @returns that source's compiled rules (source-local index, sourceIndex 0) and disabled-rule reports.
|
|
312
336
|
*/
|
|
313
|
-
private compileSourceFile(path: string): CompiledRule[] {
|
|
337
|
+
private compileSourceFile(path: string): { rules: readonly CompiledRule[]; issues: readonly RuleIssue[] } {
|
|
314
338
|
const text = readFileSync(path, 'utf8')
|
|
315
339
|
const hash = createHash('sha256').update(text).digest('hex')
|
|
316
340
|
const cached = this.compileCache.get(path)
|
|
317
341
|
if (cached !== undefined && cached.hash === hash) {
|
|
318
342
|
this.compileCache.delete(path)
|
|
319
343
|
this.compileCache.set(path, cached)
|
|
320
|
-
return cached
|
|
344
|
+
return cached
|
|
321
345
|
}
|
|
322
|
-
const rules =
|
|
323
|
-
this.compileCache.set(path, { hash, rules })
|
|
346
|
+
const { rules, issues } = compileRulesText(text, this.compileOptions())
|
|
347
|
+
this.compileCache.set(path, { hash, rules, issues })
|
|
324
348
|
this.evictCompileCache()
|
|
325
|
-
|
|
349
|
+
this.reportRuleIssues(path, issues)
|
|
350
|
+
return { rules, issues }
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Report the rules an isolated load disabled. The file still loads, so
|
|
355
|
+
* this is not a file-level failure: `badFilePolicy: 'fail'` reports each
|
|
356
|
+
* disabled rule at `error` level and `'ignore-with-warning'` at `warn`,
|
|
357
|
+
* both naming the file and the 1-based rule position the author can find in
|
|
358
|
+
* the document (the `RuleIssue.index` itself is 0-based). The same errors stay queryable through `/rules`.
|
|
359
|
+
* @param path - the rule file the rules came from.
|
|
360
|
+
* @param issues - the disabled-rule reports for that file.
|
|
361
|
+
*/
|
|
362
|
+
private reportRuleIssues(path: string, issues: readonly RuleIssue[]): void {
|
|
363
|
+
if (issues.length === 0) return
|
|
364
|
+
const log = this.config.badFilePolicy === 'fail' ? this.ctx.logger.error.bind(this.ctx.logger) : this.ctx.logger.warn.bind(this.ctx.logger)
|
|
365
|
+
const shown = issues.slice(0, 5)
|
|
366
|
+
for (const issue of shown) {
|
|
367
|
+
log(`permission-rules: ${path}: rule ${issue.index + 1} disabled by a load error: ${issue.message}`)
|
|
368
|
+
}
|
|
369
|
+
if (issues.length > shown.length) {
|
|
370
|
+
log(`permission-rules: ${path}: ${issues.length - shown.length} more rule(s) disabled by load errors (run /rules for the full list)`)
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* ONE process's startup check: load the chain for `cwd` eagerly and, when
|
|
376
|
+
* it is degraded, emit a single prominent warning instead of waiting for
|
|
377
|
+
* the first dangerous tool call. The per-rule errors themselves are logged
|
|
378
|
+
* by {@link compileSourceFile}; this adds the summary that makes the state
|
|
379
|
+
* impossible to miss in the host log, and `/rules` stays the
|
|
380
|
+
* session-visible surface with the detailed list. Never throws: a startup
|
|
381
|
+
* check must not take the plugin down (issue #49).
|
|
382
|
+
* @param cwd - the workspace to check (the host cwd at mount, the session cwd on creation).
|
|
383
|
+
*/
|
|
384
|
+
startupNotice(cwd: string = process.cwd()): void {
|
|
385
|
+
let loaded: LoadedRules
|
|
386
|
+
try {
|
|
387
|
+
// `load`, not `rulesFor`: a startup probe must not claim a cache slot or
|
|
388
|
+
// install watchers for the host's own directory, and the compile cache
|
|
389
|
+
// already makes the later first-use load free.
|
|
390
|
+
loaded = this.load(cwd)
|
|
391
|
+
} catch (error: unknown) {
|
|
392
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
393
|
+
this.ctx.logger.error(`permission-rules: STARTUP: cannot load the rule chain for ${cwd}: ${message} — tool calls in this workspace will fail until the file is fixed (run /rules reload after fixing it)`)
|
|
394
|
+
return
|
|
395
|
+
}
|
|
396
|
+
const issues = loaded.issues ?? []
|
|
397
|
+
if (issues.length > 0) {
|
|
398
|
+
this.ctx.logger.error(`permission-rules: STARTUP: ${issues.length} rule(s) could not be loaded and are disabled; ${loaded.compiled.rules.length} rule(s) from ${loaded.sources.join(', ')} stay active. Run /rules for the per-rule errors, then fix the named entries.`)
|
|
399
|
+
}
|
|
400
|
+
if (loaded.lastError !== undefined) {
|
|
401
|
+
this.ctx.logger.error(`permission-rules: STARTUP: the rule chain for ${cwd} is degraded (${loaded.lastError}); the previous/empty rule set is in effect. Run /rules for details.`)
|
|
402
|
+
}
|
|
326
403
|
}
|
|
327
404
|
|
|
328
405
|
/** Bound the compile cache: drop the least-recently-used source beyond the workspace cap (compile results are cheap to rebuild). */
|
|
@@ -1169,6 +1246,16 @@ export class PermissionRulesRuntime {
|
|
|
1169
1246
|
if (reloaded?.lastError !== undefined) return { kind: 'error', text: prose.reloadFailed(reloaded.lastError) }
|
|
1170
1247
|
const rules = reloaded?.compiled.rules ?? []
|
|
1171
1248
|
const source = reloaded === undefined || reloaded.sources.length === 0 ? prose.emptySource : reloaded.sources.join(', ')
|
|
1249
|
+
const issues = reloaded?.issues ?? []
|
|
1250
|
+
if (issues.length > 0) {
|
|
1251
|
+
return {
|
|
1252
|
+
kind: 'error',
|
|
1253
|
+
text: [
|
|
1254
|
+
prose.reloadDegraded(rules.length, source, issues.length),
|
|
1255
|
+
...issues.map(issue => prose.ruleIssueLine(this.displaySource(issue.source, cwd), issue.index, issue.message)),
|
|
1256
|
+
].join('\n'),
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1172
1259
|
return { kind: 'success', text: prose.reloaded(rules.length, source) }
|
|
1173
1260
|
}
|
|
1174
1261
|
if (verb === 'list' && rest.length > 0) return { kind: 'error', text: prose.unknownArg(invocation.rawInput.trim()) }
|
|
@@ -1197,10 +1284,15 @@ export class PermissionRulesRuntime {
|
|
|
1197
1284
|
return { kind: 'error', text: error instanceof Error ? error.message : String(error) }
|
|
1198
1285
|
}
|
|
1199
1286
|
const lines: string[] = []
|
|
1287
|
+
const issues = loaded.issues ?? []
|
|
1200
1288
|
if (loaded.sources.length === 0) {
|
|
1201
1289
|
lines.push(prose.noRules(cwd, this.config.fallbackPath !== undefined ? prose.fallbackMissing : ''))
|
|
1202
1290
|
} else {
|
|
1203
1291
|
lines.push(prose.rulesHeader(loaded.compiled.rules.length, loaded.sources, cwd))
|
|
1292
|
+
if (issues.length > 0) {
|
|
1293
|
+
lines.push(prose.degradedRules(issues.length, loaded.compiled.rules.length))
|
|
1294
|
+
for (const issue of issues) lines.push(prose.ruleIssueLine(this.displaySource(issue.source, cwd), issue.index, issue.message))
|
|
1295
|
+
}
|
|
1204
1296
|
const multiSource = loaded.sources.length > 1
|
|
1205
1297
|
lines.push(...loaded.compiled.rules.map(rule => describeRule(rule, DESCRIBE_TOKENS[this.config.language], multiSource ? this.displaySource(loaded.sources[rule.sourceIndex] ?? '', cwd) : undefined)))
|
|
1206
1298
|
const unreachable = findUnreachableRules(loaded.compiled)
|
|
@@ -1213,7 +1305,9 @@ export class PermissionRulesRuntime {
|
|
|
1213
1305
|
lines.push(prose.dryRunNotice)
|
|
1214
1306
|
}
|
|
1215
1307
|
lines.push(prose.usage)
|
|
1216
|
-
|
|
1308
|
+
// A degraded policy must be visible where the user looks: the command
|
|
1309
|
+
// result renders as an error (red) instead of a neutral listing.
|
|
1310
|
+
return { kind: issues.length > 0 || loaded.lastError !== undefined ? 'error' : 'success', text: lines.join('\n') }
|
|
1217
1311
|
}
|
|
1218
1312
|
|
|
1219
1313
|
/**
|
|
@@ -1339,9 +1433,13 @@ export class PermissionRulesRuntime {
|
|
|
1339
1433
|
/**
|
|
1340
1434
|
* Validate deployment-level file references at mount — the earliest
|
|
1341
1435
|
* resolvable point. An absolute `rulesFile` or a configured
|
|
1342
|
-
* `fallbackPath` must exist and parse; a missing referent
|
|
1343
|
-
* loudly instead of silently degrading later.
|
|
1344
|
-
*
|
|
1436
|
+
* `fallbackPath` must exist and parse; a missing referent or a FILE-level
|
|
1437
|
+
* failure fails the mount loudly instead of silently degrading later. A
|
|
1438
|
+
* single bad RULE inside such a file no longer fails the mount (issue
|
|
1439
|
+
* #49): it is disabled, reported, and the rest of the file stays active —
|
|
1440
|
+
* otherwise one typo would take the whole plugin (and its protection)
|
|
1441
|
+
* down before any session exists.
|
|
1442
|
+
* @throws when a mandated file is missing or unparseable as a document.
|
|
1345
1443
|
*/
|
|
1346
1444
|
validateDeploymentFiles(): void {
|
|
1347
1445
|
if (isAbsolute(this.config.rulesFile)) {
|
|
@@ -1362,11 +1460,15 @@ export class PermissionRulesRuntime {
|
|
|
1362
1460
|
}
|
|
1363
1461
|
}
|
|
1364
1462
|
|
|
1365
|
-
/**
|
|
1463
|
+
/**
|
|
1464
|
+
* Parse and compile one mandated file with per-rule isolation, rethrowing
|
|
1465
|
+
* only a FILE-level failure as a mount failure. The disabled-rule reports
|
|
1466
|
+
* are logged here (the mount is the first place they can be seen).
|
|
1467
|
+
* @param filePath - the deployment-level rule file to validate.
|
|
1468
|
+
*/
|
|
1366
1469
|
private loadForValidation(filePath: string): void {
|
|
1367
1470
|
try {
|
|
1368
|
-
|
|
1369
|
-
compileRules(doc, this.compileOptions())
|
|
1471
|
+
this.compileSourceFile(filePath)
|
|
1370
1472
|
} catch (error: unknown) {
|
|
1371
1473
|
const message = error instanceof Error ? error.message : String(error)
|
|
1372
1474
|
throw error instanceof RuleError ? error : new RuleError(`permission-rules: cannot load ${filePath}: ${message}`)
|
|
@@ -1668,6 +1770,20 @@ export async function apply(ctx: Context, config: LiveConfig): Promise<void> {
|
|
|
1668
1770
|
input: { hint: '[list | reload | network | decisions [n] | test [--cwd <dir>] [--env K=V] [--agent <sel>] [--platform <name>] <tool> <json-args>]' },
|
|
1669
1771
|
handler: invocation => runtime.command(invocation),
|
|
1670
1772
|
})
|
|
1773
|
+
// Issue #49: a broken rule used to be visible only as one line in the host
|
|
1774
|
+
// log (or not at all when it aborted activation). Check the configured
|
|
1775
|
+
// chain NOW, and again for every session's workspace as it is created, so
|
|
1776
|
+
// the state is announced at startup instead of on the first dangerous call.
|
|
1777
|
+
runtime.startupNotice()
|
|
1778
|
+
ctx.inject(['sessions'], (scope) => {
|
|
1779
|
+
scope.on('session/created', (session) => {
|
|
1780
|
+
try {
|
|
1781
|
+
runtime.startupNotice(session.header.cwd ?? process.cwd())
|
|
1782
|
+
} catch {
|
|
1783
|
+
// A startup notice must never veto session creation.
|
|
1784
|
+
}
|
|
1785
|
+
})
|
|
1786
|
+
})
|
|
1671
1787
|
if (resolved.network.enabled) await runtime.attachNetworkProxy()
|
|
1672
1788
|
ctx.inject(['systemPrompt'], (scope) => {
|
|
1673
1789
|
const systemPrompt = scope.get('systemPrompt') as { context?: (entry: { name: string; order?: number; text: string }) => void } | undefined
|