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/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 { rules: [] }
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 { rules: [] }
367
+ if (rawRules === undefined) return []
304
368
  if (!Array.isArray(rawRules)) throw new RuleError('"rules" must be a list')
305
- return { rules: rawRules.map((raw, index) => parseRuleEntry(raw, index)) }
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 become
310
- * RegExps (invalid globs/regexes throw here, at load), and the rule count
311
- * is capped by `maxRules`.
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([compileSourceRules(doc, options)], options)
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. The returned
325
- * rules carry source-local `index` values and `sourceIndex` 0 — the
326
- * cacheable unit a runtime can reuse across chains (keyed by source path +
327
- * content hash). The total-count cap is NOT applied here; it bounds the
328
- * merged chain in {@link mergeCompiledRules}.
329
- * @param doc - the parsed document.
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 of one source.
429
+ * @returns the compiled rules plus the disabled-rule reports.
332
430
  */
333
- export function compileSourceRules(doc: RulesFileDoc, options: CompileOptions): CompiledRule[] {
334
- return doc.rules.map((entry, index) => compileRule(entry, index, 0, options))
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). Rules
360
- * keep per-file order and are evaluated in chain order, so a nearer file
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 => compileSourceRules(parseRulesDocument(entry.text), options))
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, index: number, sourceIndex: number, options: CompileOptions): CompiledRule {
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, compileSourceRules, describeRule, findUnreachableRules, isShellTool, matchRules, mergeCompiledRules, normalizeWorkspacePath, parseRulesDocument, PLATFORMS, RuleError } from './rules.ts'
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
- /** Last load error, when one is being reported (see {@link PermissionRulesRuntime.rulesFor}). */
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`. Under
281
- * `badFilePolicy: 'ignore-with-warning'` a bad file degrades to an empty
282
- * rule set with a warning and keeps its source paths (so a later fix is
283
- * watched and adopted); under `'fail'` it throws.
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 = sources.map(path => this.compileSourceFile(path))
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.rules
344
+ return cached
321
345
  }
322
- const rules = compileSourceRules(parseRulesDocument(text), this.compileOptions())
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
- return rules
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
- return { kind: 'success', text: lines.join('\n') }
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 fails the mount
1343
- * loudly instead of silently degrading later.
1344
- * @throws when a mandated file is missing or invalid.
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
- /** Parse and compile one mandated file, rethrowing as a mount failure. */
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
- const doc = parseRulesDocument(readFileSync(filePath, 'utf8'))
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