agent-rules-kit 0.2.1__py3-none-any.whl

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.
@@ -0,0 +1,608 @@
1
+ """Instruction governance diagnostics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Callable, Sequence
7
+ from pathlib import Path
8
+ from re import Pattern
9
+
10
+ from agent_rules_kit.discovery import InstructionFile
11
+ from agent_rules_kit.findings import Finding, Severity
12
+
13
+ REVIEW_CI_BYPASS_RULE_ID = "AIRK-GOV003"
14
+ REVIEW_CI_BYPASS_MESSAGE = (
15
+ "Instruction file appears to encourage bypassing review, CI, or safe integration boundaries."
16
+ )
17
+
18
+ COMMAND_CONFIRMATION_RULE_ID = "AIRK-GOV004"
19
+ COMMAND_CONFIRMATION_MESSAGE = (
20
+ "Instruction file appears to encourage unsafe command execution without an explicit "
21
+ "confirmation boundary."
22
+ )
23
+
24
+ RUNTIME_NETWORK_LLM_RULE_ID = "AIRK-GOV005"
25
+ RUNTIME_NETWORK_LLM_MESSAGE = (
26
+ "Instruction file appears to encourage runtime network, LLM, or external API use that "
27
+ "conflicts with local-first boundaries."
28
+ )
29
+
30
+ UNREADABLE_INSTRUCTION_FILE_RULE_ID = "AIRK-SYS001"
31
+ UNREADABLE_INSTRUCTION_FILE_MESSAGE = (
32
+ "Instruction file could not be analyzed because it is not valid UTF-8."
33
+ )
34
+
35
+ SYMLINKED_INSTRUCTION_FILE_RULE_ID = "AIRK-SYS002"
36
+ SYMLINKED_INSTRUCTION_FILE_MESSAGE = (
37
+ "Instruction file path is a symlink and was not analyzed."
38
+ )
39
+
40
+ AUTHORITY_SCOPE_RULE_ID = "AIRK-GOV001"
41
+ AUTHORITY_SCOPE_MESSAGE = "Instruction file may lack clear scope or authority."
42
+
43
+ SECRET_BOUNDARY_RULE_ID = "AIRK-GOV002"
44
+ SECRET_BOUNDARY_MESSAGE = "Instruction file may lack an explicit secret-handling boundary."
45
+
46
+ UNSUPPORTED_CLAIM_RULE_ID = "AIRK-GOV006"
47
+ UNSUPPORTED_CLAIM_MESSAGE = (
48
+ "Instruction file may contain an unsupported security or maturity claim."
49
+ )
50
+
51
+ REVIEW_CI_BYPASS_PATTERNS: tuple[Pattern[str], ...] = (
52
+ re.compile(r"\b(ignore|skip)\s+(failing\s+)?(checks|tests|ci)\b", re.IGNORECASE),
53
+ re.compile(r"\bskip\s+(code\s+)?review\b", re.IGNORECASE),
54
+ re.compile(r"\b(commit|push)\s+directly\s+to\s+main\b", re.IGNORECASE),
55
+ re.compile(r"\bdirect\s+push(?:es)?\s+to\s+main\b", re.IGNORECASE),
56
+ re.compile(r"\bmerge\s+without\s+(review|approval)\b", re.IGNORECASE),
57
+ re.compile(
58
+ r"\bbypass(?:ing)?\s+("
59
+ r"branch protection|review|reviews|pending review gates|ci|checks|safe integration"
60
+ r")\b",
61
+ re.IGNORECASE,
62
+ ),
63
+ re.compile(
64
+ r"\bforce[- ]push\b.{0,80}\b(normal|default|routine|workflow)\b",
65
+ re.IGNORECASE,
66
+ ),
67
+ )
68
+
69
+ NEGATED_REVIEW_CI_BYPASS_CONTEXT_PATTERNS: tuple[Pattern[str], ...] = (
70
+ re.compile(
71
+ r"\b(do not|don't|must not|should not|never|avoid|forbid|forbidden|no)\b"
72
+ r".{0,120}\b("
73
+ r"bypass(?:ing)?|skip(?:ping)?|ignore|commit(?:ting)?|push(?:ing|es)?|"
74
+ r"direct push(?:es)?|force[- ]push|merge"
75
+ r")\b",
76
+ re.IGNORECASE,
77
+ ),
78
+ re.compile(
79
+ r"\bnot\s+("
80
+ r"bypass(?:ing)?|skip(?:ping)?|ignore|commit(?:ting)?|push(?:ing)?|"
81
+ r"merge|force[- ]push"
82
+ r")\b",
83
+ re.IGNORECASE,
84
+ ),
85
+ re.compile(
86
+ r"\b(break[- ]glass|emergency)\b.{0,120}\b(explicit|human|maintainer)\s+approval\b",
87
+ re.IGNORECASE,
88
+ ),
89
+ )
90
+
91
+ COMMAND_CONFIRMATION_PATTERNS: tuple[Pattern[str], ...] = (
92
+ re.compile(r"\brm\s+-[A-Za-z]*r[A-Za-z]*f\b|\brm\s+-[A-Za-z]*f[A-Za-z]*r\b", re.IGNORECASE),
93
+ re.compile(r"\b(use|using|run|execute|invoke|always)\b.{0,80}\bsudo\b", re.IGNORECASE),
94
+ re.compile(r"\bsudo\b.{0,80}\b(default|normal|routine|workflow|always)\b", re.IGNORECASE),
95
+ re.compile(r"\bchmod\s+-R\s+(777|[0-7]{3,4})\b", re.IGNORECASE),
96
+ re.compile(r"\bchown\s+-R\b", re.IGNORECASE),
97
+ re.compile(r"\b(curl|wget)\b.{0,120}\|\s*(sh|bash)\b", re.IGNORECASE),
98
+ re.compile(
99
+ r"\b(install|uninstall)\b.{0,100}\b("
100
+ r"without asking|without confirmation|automatically|always"
101
+ r")\b",
102
+ re.IGNORECASE,
103
+ ),
104
+ re.compile(
105
+ r"\b(run|execute)\b.{0,80}\b(repository|repo)\s+scripts?\b"
106
+ r".{0,80}\b(automatically|without asking|as trusted instructions)\b",
107
+ re.IGNORECASE,
108
+ ),
109
+ )
110
+
111
+ NEGATED_COMMAND_CONFIRMATION_CONTEXT_PATTERNS: tuple[Pattern[str], ...] = (
112
+ re.compile(
113
+ r"\b(do not|don't|must not|should not|never|avoid|forbid|forbidden|no)\b"
114
+ r".{0,140}\b("
115
+ r"rm\s+-[A-Za-z]*r[A-Za-z]*f|sudo|chmod\s+-R|chown\s+-R|"
116
+ r"curl\b.{0,80}\|\s*(?:sh|bash)|wget\b.{0,80}\|\s*(?:sh|bash)|"
117
+ r"install|uninstall|run|execute"
118
+ r")\b",
119
+ re.IGNORECASE,
120
+ ),
121
+ re.compile(
122
+ r"\bask\b.{0,80}\bbefore\b.{0,140}\b("
123
+ r"rm\s+-[A-Za-z]*r[A-Za-z]*f|sudo|chmod\s+-R|chown\s+-R|"
124
+ r"downloaded scripts?|curl|wget|install|uninstall|run|execute"
125
+ r")\b",
126
+ re.IGNORECASE,
127
+ ),
128
+ re.compile(
129
+ r"\b(ask|confirm|require|requires|required|request)\b"
130
+ r".{0,120}\b(human|maintainer|operator|user|explicit)\b"
131
+ r".{0,80}\b(approval|confirmation|permission)\b",
132
+ re.IGNORECASE,
133
+ ),
134
+ re.compile(
135
+ r"\b(emergency|break[- ]glass|destructive|privileged)\b"
136
+ r".{0,120}\b(explicit|human|maintainer|operator|user)\b"
137
+ r".{0,80}\b(approval|confirmation|permission)\b",
138
+ re.IGNORECASE,
139
+ ),
140
+ )
141
+
142
+ RUNTIME_NETWORK_LLM_PATTERNS: tuple[Pattern[str], ...] = (
143
+ re.compile(
144
+ r"\b(send|upload|post|transmit|share)\b"
145
+ r".{0,100}\b(repository|repo|source code|codebase|workspace|context|files?)\b"
146
+ r".{0,140}\b("
147
+ r"OpenAI|Anthropic|Claude|Gemini|ChatGPT|LLM|external API|"
148
+ r"external service|remote service"
149
+ r")\b",
150
+ re.IGNORECASE,
151
+ ),
152
+ re.compile(
153
+ r"\b(check|runtime|scan|scanning|audit|analyze|analysis|validation|validate)\b"
154
+ r".{0,140}\b(must|should|required|requires?|needs?|depends on|call|query|use)\b"
155
+ r".{0,100}\b(LLM API|LLM|OpenAI|Anthropic|Claude|Gemini|ChatGPT|external API|remote API)\b",
156
+ re.IGNORECASE,
157
+ ),
158
+ re.compile(
159
+ r"\b(call|query|use)\b"
160
+ r".{0,100}\b(remote\s+)?("
161
+ r"LLM|OpenAI|Anthropic|Claude|Gemini|ChatGPT|external API|remote API"
162
+ r")\b"
163
+ r".{0,100}\b(check|runtime|scan|scanning|audit|analyze|analysis|validation|validate)\b",
164
+ re.IGNORECASE,
165
+ ),
166
+ re.compile(
167
+ r"\b(use|call|invoke|query)\b"
168
+ r".{0,80}\b("
169
+ r"Claude API|Anthropic API|OpenAI API|Gemini API|ChatGPT API|LLM API|"
170
+ r"remote LLM|external LLM"
171
+ r")\b",
172
+ re.IGNORECASE,
173
+ ),
174
+ re.compile(
175
+ r"\b(validator|linter|tool|CLI|command|check|runtime|execution)\b"
176
+ r".{0,120}\b(depends on|requires?|needs?|uses?|using|must use|must call)\b"
177
+ r".{0,120}\b("
178
+ r"OpenAI API|Anthropic API|Claude API|Gemini API|ChatGPT API|LLM API|"
179
+ r"external API|remote API"
180
+ r")\b",
181
+ re.IGNORECASE,
182
+ ),
183
+ re.compile(
184
+ r"\b("
185
+ r"OpenAI API|Anthropic API|Claude API|Gemini API|ChatGPT API|LLM API|"
186
+ r"external API|remote API"
187
+ r")\b"
188
+ r".{0,120}\b("
189
+ r"during execution|at runtime|runtime|for validation|to validate|for analysis|"
190
+ r"to analyze"
191
+ r")\b",
192
+ re.IGNORECASE,
193
+ ),
194
+ re.compile(
195
+ r"\b("
196
+ r"validat(?:e|es|ing|ion)|verif(?:y|ies|ying|ication)|check(?:s|ing)?|"
197
+ r"analyz(?:e|es|ing|sis)"
198
+ r")\b"
199
+ r".{0,80}\b(via|using|through|with|by calling|by querying)\b"
200
+ r".{0,80}\b("
201
+ r"Claude(?:\s+API)?|Anthropic(?:\s+API)?|OpenAI(?:\s+API)?|"
202
+ r"Gemini(?:\s+API)?|ChatGPT|LLM API|remote API|external API"
203
+ r")\b",
204
+ re.IGNORECASE,
205
+ ),
206
+ re.compile(
207
+ r"\b(runtime|check|scan|scanning|audit|analyze|analysis|validation|validate)\b"
208
+ r".{0,120}\b(requires?|needs?|must have|depends on)\b"
209
+ r".{0,100}\b(internet|network|online access|network access)\b",
210
+ re.IGNORECASE,
211
+ ),
212
+ )
213
+
214
+ NEGATED_RUNTIME_NETWORK_LLM_CONTEXT_PATTERNS: tuple[Pattern[str], ...] = (
215
+ re.compile(
216
+ r"\b(do not|don't|must not|should not|never|avoid|avoids|forbid|forbidden|no|without)\b"
217
+ r".{0,180}\b("
218
+ r"network|internet|online|LLMs?|OpenAI|Anthropic|Claude|Gemini|ChatGPT|"
219
+ r"external APIs?|remote services?|API calls?"
220
+ r")\b",
221
+ re.IGNORECASE,
222
+ ),
223
+ re.compile(
224
+ r"\b(does not|do not|don't|must not|should not|never|avoid|avoids|no)\b"
225
+ r".{0,140}\b("
226
+ r"call|use|depend|requires?|needs?|rely|relies|send|upload|post|"
227
+ r"transmit|share"
228
+ r")\b"
229
+ r".{0,140}\b("
230
+ r"network|LLMs?|OpenAI|Anthropic|Claude|Gemini|ChatGPT|external APIs?|"
231
+ r"remote services?"
232
+ r")\b",
233
+ re.IGNORECASE,
234
+ ),
235
+ re.compile(
236
+ r"\b(human|maintainer|operator|user)\b"
237
+ r".{0,100}\b(may|can)\b"
238
+ r".{0,100}\b(use|consult)\b"
239
+ r".{0,100}\b(ChatGPT|Claude|Gemini|OpenAI|Anthropic|LLM)\b"
240
+ r".{0,140}\b(planning|review|research|design)\b",
241
+ re.IGNORECASE,
242
+ ),
243
+ )
244
+
245
+ SECRET_BOUNDARY_PATTERNS: tuple[Pattern[str], ...] = (
246
+ re.compile(r"\bsecret(?:s)?\b", re.IGNORECASE),
247
+ re.compile(r"\btoken(?:s)?\b", re.IGNORECASE),
248
+ re.compile(r"\bcredential(?:s)?\b", re.IGNORECASE),
249
+ re.compile(r"\bapi[-_ ]?key(?:s)?\b", re.IGNORECASE),
250
+ re.compile(r"\bprivate\s+(data|url(?:s)?|key(?:s)?)\b", re.IGNORECASE),
251
+ re.compile(r"\bcustomer\s+data\b", re.IGNORECASE),
252
+ re.compile(r"\bsensitive\s+(value(?:s)?|information|data)\b", re.IGNORECASE),
253
+ )
254
+
255
+ AUTHORITY_SCOPE_PATTERNS: tuple[Pattern[str], ...] = (
256
+ re.compile(r"\bscope\b", re.IGNORECASE),
257
+ re.compile(r"\bauthority\b", re.IGNORECASE),
258
+ re.compile(r"\bprecedence\b", re.IGNORECASE),
259
+ re.compile(r"\bhierarchy\b", re.IGNORECASE),
260
+ re.compile(r"\b(override|overrides|overrode|overridden|overriding)\b", re.IGNORECASE),
261
+ re.compile(r"\bappl(?:y|ies)\s+to\b", re.IGNORECASE),
262
+ re.compile(r"\b(repository|repo)[- ]wide\b", re.IGNORECASE),
263
+ re.compile(r"\bpath[- ]specific\b", re.IGNORECASE),
264
+ re.compile(r"\bnearest\s+AGENTS\.md\b", re.IGNORECASE),
265
+ re.compile(r"\binstruction\s+(chain|order|source|sources)\b", re.IGNORECASE),
266
+ )
267
+
268
+ UNSUPPORTED_CLAIM_PATTERNS: tuple[Pattern[str], ...] = (
269
+ re.compile(r"\bguarantee[sd]?\s+(security|safety)\b", re.IGNORECASE),
270
+ re.compile(r"\bguaranteed\s+(secure|safe|security|safety)\b", re.IGNORECASE),
271
+ re.compile(
272
+ r"\bmake[s]?\s+(the\s+)?(repository|repo|project|tool)\s+(secure|safe)\b",
273
+ re.IGNORECASE,
274
+ ),
275
+ re.compile(r"\bcomplete\s+secret\s+scann(?:er|ing)\b", re.IGNORECASE),
276
+ re.compile(r"\bproduction[- ]ready\b", re.IGNORECASE),
277
+ re.compile(r"\benterprise[- ]grade\b", re.IGNORECASE),
278
+ )
279
+
280
+ NEGATED_UNSUPPORTED_CLAIM_CONTEXT_PATTERNS: tuple[Pattern[str], ...] = (
281
+ re.compile(
282
+ r"\b(do not|don't|must not|should not|never|avoid|forbid|forbidden|no)\b"
283
+ r".{0,120}\b("
284
+ r"claim[s]?|guarantee[sd]?|security|safety|secure|safe|"
285
+ r"production[- ]ready|enterprise[- ]grade|complete secret scann(?:er|ing)"
286
+ r")\b",
287
+ re.IGNORECASE,
288
+ ),
289
+ re.compile(
290
+ r"\bnot\s+(a\s+)?("
291
+ r"security scanner|secret scanner|production[- ]ready|enterprise[- ]grade|"
292
+ r"secure|safe"
293
+ r")\b",
294
+ re.IGNORECASE,
295
+ ),
296
+ )
297
+
298
+
299
+ ContextPredicate = Callable[[Sequence[str], int], bool]
300
+
301
+
302
+ def make_context_aware_predicate(
303
+ trigger_patterns: tuple[Pattern[str], ...],
304
+ negation_patterns: tuple[Pattern[str], ...],
305
+ *,
306
+ context_window: int = 0,
307
+ ) -> ContextPredicate:
308
+ """Return a predicate that evaluates triggers with same-line negation scope."""
309
+
310
+ def predicate(lines: Sequence[str], index: int) -> bool:
311
+ if index < 0 or index >= len(lines):
312
+ return False
313
+
314
+ if not any(pattern.search(lines[index]) is not None for pattern in trigger_patterns):
315
+ return False
316
+
317
+ start = max(0, index - context_window)
318
+ end = min(len(lines), index + context_window + 1)
319
+
320
+ return not any(
321
+ pattern.search(lines[context_index]) is not None
322
+ for context_index in range(start, end)
323
+ for pattern in negation_patterns
324
+ )
325
+
326
+ return predicate
327
+
328
+
329
+ def find_governance_findings(
330
+ repository_root: Path,
331
+ instruction_files: tuple[InstructionFile, ...],
332
+ ) -> tuple[Finding, ...]:
333
+ """Return all governance findings in stable rule order."""
334
+ return _deduplicate_findings(
335
+ (
336
+ *find_unsupported_claim_findings(repository_root, instruction_files),
337
+ *find_review_ci_bypass_findings(repository_root, instruction_files),
338
+ *find_unsafe_command_execution_findings(repository_root, instruction_files),
339
+ *find_runtime_network_llm_dependency_findings(repository_root, instruction_files),
340
+ *find_missing_secret_boundary_findings(repository_root, instruction_files),
341
+ *find_missing_authority_scope_findings(repository_root, instruction_files),
342
+ )
343
+ )
344
+
345
+
346
+ def _unreadable_instruction_file_finding(path: str) -> Finding:
347
+ return Finding(
348
+ rule_id=UNREADABLE_INSTRUCTION_FILE_RULE_ID,
349
+ severity=Severity.WARNING,
350
+ message=UNREADABLE_INSTRUCTION_FILE_MESSAGE,
351
+ path=path,
352
+ )
353
+
354
+
355
+ def _symlinked_instruction_file_finding(path: str) -> Finding:
356
+ return Finding(
357
+ rule_id=SYMLINKED_INSTRUCTION_FILE_RULE_ID,
358
+ severity=Severity.WARNING,
359
+ message=SYMLINKED_INSTRUCTION_FILE_MESSAGE,
360
+ path=path,
361
+ )
362
+
363
+
364
+ def _has_symlink_component(repository_root: Path, relative_path: str) -> bool:
365
+ current = repository_root
366
+
367
+ for part in Path(relative_path).parts:
368
+ current = current / part
369
+ if current.is_symlink():
370
+ return True
371
+
372
+ return False
373
+
374
+
375
+ def _deduplicate_findings(findings: tuple[Finding, ...]) -> tuple[Finding, ...]:
376
+ unique: list[Finding] = []
377
+ seen: set[Finding] = set()
378
+
379
+ for finding in findings:
380
+ if finding in seen:
381
+ continue
382
+ seen.add(finding)
383
+ unique.append(finding)
384
+
385
+ return tuple(unique)
386
+
387
+
388
+ def find_unsafe_command_execution_findings(
389
+ repository_root: Path,
390
+ instruction_files: tuple[InstructionFile, ...],
391
+ ) -> tuple[Finding, ...]:
392
+ """Return unsafe command execution guidance findings."""
393
+ return _find_line_findings(
394
+ repository_root,
395
+ instruction_files,
396
+ rule_id=COMMAND_CONFIRMATION_RULE_ID,
397
+ severity=Severity.WARNING,
398
+ message=COMMAND_CONFIRMATION_MESSAGE,
399
+ predicate=make_context_aware_predicate(
400
+ COMMAND_CONFIRMATION_PATTERNS,
401
+ NEGATED_COMMAND_CONFIRMATION_CONTEXT_PATTERNS,
402
+ ),
403
+ )
404
+
405
+
406
+ def find_runtime_network_llm_dependency_findings(
407
+ repository_root: Path,
408
+ instruction_files: tuple[InstructionFile, ...],
409
+ ) -> tuple[Finding, ...]:
410
+ """Return runtime network, LLM, or external API dependency findings."""
411
+ return _find_line_findings(
412
+ repository_root,
413
+ instruction_files,
414
+ rule_id=RUNTIME_NETWORK_LLM_RULE_ID,
415
+ severity=Severity.WARNING,
416
+ message=RUNTIME_NETWORK_LLM_MESSAGE,
417
+ predicate=make_context_aware_predicate(
418
+ RUNTIME_NETWORK_LLM_PATTERNS,
419
+ NEGATED_RUNTIME_NETWORK_LLM_CONTEXT_PATTERNS,
420
+ ),
421
+ )
422
+
423
+
424
+ def find_missing_authority_scope_findings(
425
+ repository_root: Path,
426
+ instruction_files: tuple[InstructionFile, ...],
427
+ ) -> tuple[Finding, ...]:
428
+ """Return findings for files without visible scope or authority guidance."""
429
+ findings: list[Finding] = []
430
+
431
+ for instruction_file in instruction_files:
432
+ candidate = repository_root / instruction_file.path
433
+
434
+ if _has_symlink_component(repository_root, instruction_file.path):
435
+ findings.append(_symlinked_instruction_file_finding(instruction_file.path))
436
+ continue
437
+
438
+ try:
439
+ text = candidate.read_text(encoding="utf-8")
440
+ except UnicodeDecodeError:
441
+ findings.append(_unreadable_instruction_file_finding(instruction_file.path))
442
+ continue
443
+
444
+ if not _contains_authority_scope_boundary(text):
445
+ findings.append(
446
+ Finding(
447
+ rule_id=AUTHORITY_SCOPE_RULE_ID,
448
+ severity=Severity.WARNING,
449
+ message=AUTHORITY_SCOPE_MESSAGE,
450
+ path=instruction_file.path,
451
+ )
452
+ )
453
+
454
+ return tuple(findings)
455
+
456
+
457
+ def find_missing_secret_boundary_findings(
458
+ repository_root: Path,
459
+ instruction_files: tuple[InstructionFile, ...],
460
+ ) -> tuple[Finding, ...]:
461
+ """Return findings for files without visible secret-handling guidance."""
462
+ findings: list[Finding] = []
463
+
464
+ for instruction_file in instruction_files:
465
+ candidate = repository_root / instruction_file.path
466
+
467
+ if _has_symlink_component(repository_root, instruction_file.path):
468
+ findings.append(_symlinked_instruction_file_finding(instruction_file.path))
469
+ continue
470
+
471
+ try:
472
+ text = candidate.read_text(encoding="utf-8")
473
+ except UnicodeDecodeError:
474
+ findings.append(_unreadable_instruction_file_finding(instruction_file.path))
475
+ continue
476
+
477
+ if not _contains_secret_boundary(text):
478
+ findings.append(
479
+ Finding(
480
+ rule_id=SECRET_BOUNDARY_RULE_ID,
481
+ severity=Severity.WARNING,
482
+ message=SECRET_BOUNDARY_MESSAGE,
483
+ path=instruction_file.path,
484
+ )
485
+ )
486
+
487
+ return tuple(findings)
488
+
489
+
490
+ def find_review_ci_bypass_findings(
491
+ repository_root: Path,
492
+ instruction_files: tuple[InstructionFile, ...],
493
+ ) -> tuple[Finding, ...]:
494
+ """Return review, CI, or safe integration bypass findings."""
495
+ return _find_line_findings(
496
+ repository_root,
497
+ instruction_files,
498
+ rule_id=REVIEW_CI_BYPASS_RULE_ID,
499
+ severity=Severity.WARNING,
500
+ message=REVIEW_CI_BYPASS_MESSAGE,
501
+ predicate=make_context_aware_predicate(
502
+ REVIEW_CI_BYPASS_PATTERNS,
503
+ NEGATED_REVIEW_CI_BYPASS_CONTEXT_PATTERNS,
504
+ ),
505
+ )
506
+
507
+
508
+ def find_unsupported_claim_findings(
509
+ repository_root: Path,
510
+ instruction_files: tuple[InstructionFile, ...],
511
+ ) -> tuple[Finding, ...]:
512
+ """Return unsupported security or maturity claim findings."""
513
+ return _find_line_findings(
514
+ repository_root,
515
+ instruction_files,
516
+ rule_id=UNSUPPORTED_CLAIM_RULE_ID,
517
+ severity=Severity.WARNING,
518
+ message=UNSUPPORTED_CLAIM_MESSAGE,
519
+ predicate=make_context_aware_predicate(
520
+ UNSUPPORTED_CLAIM_PATTERNS,
521
+ NEGATED_UNSUPPORTED_CLAIM_CONTEXT_PATTERNS,
522
+ ),
523
+ )
524
+
525
+
526
+ def _find_line_findings(
527
+ repository_root: Path,
528
+ instruction_files: tuple[InstructionFile, ...],
529
+ *,
530
+ rule_id: str,
531
+ severity: Severity,
532
+ message: str,
533
+ predicate: ContextPredicate,
534
+ ) -> tuple[Finding, ...]:
535
+ findings: list[Finding] = []
536
+
537
+ for instruction_file in instruction_files:
538
+ candidate = repository_root / instruction_file.path
539
+
540
+ if _has_symlink_component(repository_root, instruction_file.path):
541
+ findings.append(_symlinked_instruction_file_finding(instruction_file.path))
542
+ continue
543
+
544
+ try:
545
+ text = candidate.read_text(encoding="utf-8")
546
+ except UnicodeDecodeError:
547
+ findings.append(_unreadable_instruction_file_finding(instruction_file.path))
548
+ continue
549
+
550
+ lines = text.splitlines()
551
+
552
+ for index, line in enumerate(lines):
553
+ if predicate(lines, index):
554
+ findings.append(
555
+ Finding(
556
+ rule_id=rule_id,
557
+ severity=severity,
558
+ message=message,
559
+ path=instruction_file.path,
560
+ line=index + 1,
561
+ evidence=line,
562
+ )
563
+ )
564
+
565
+ return tuple(findings)
566
+
567
+
568
+ def _contains_secret_boundary(text: str) -> bool:
569
+ return any(pattern.search(text) is not None for pattern in SECRET_BOUNDARY_PATTERNS)
570
+
571
+
572
+ def _contains_authority_scope_boundary(text: str) -> bool:
573
+ return any(pattern.search(text) is not None for pattern in AUTHORITY_SCOPE_PATTERNS)
574
+
575
+
576
+ __all__ = [
577
+ "ContextPredicate",
578
+ "make_context_aware_predicate",
579
+ "AUTHORITY_SCOPE_MESSAGE",
580
+ "AUTHORITY_SCOPE_PATTERNS",
581
+ "AUTHORITY_SCOPE_RULE_ID",
582
+ "COMMAND_CONFIRMATION_MESSAGE",
583
+ "COMMAND_CONFIRMATION_PATTERNS",
584
+ "COMMAND_CONFIRMATION_RULE_ID",
585
+ "NEGATED_COMMAND_CONFIRMATION_CONTEXT_PATTERNS",
586
+ "NEGATED_RUNTIME_NETWORK_LLM_CONTEXT_PATTERNS",
587
+ "NEGATED_REVIEW_CI_BYPASS_CONTEXT_PATTERNS",
588
+ "NEGATED_UNSUPPORTED_CLAIM_CONTEXT_PATTERNS",
589
+ "REVIEW_CI_BYPASS_MESSAGE",
590
+ "REVIEW_CI_BYPASS_PATTERNS",
591
+ "REVIEW_CI_BYPASS_RULE_ID",
592
+ "RUNTIME_NETWORK_LLM_MESSAGE",
593
+ "RUNTIME_NETWORK_LLM_PATTERNS",
594
+ "RUNTIME_NETWORK_LLM_RULE_ID",
595
+ "SECRET_BOUNDARY_MESSAGE",
596
+ "SECRET_BOUNDARY_PATTERNS",
597
+ "SECRET_BOUNDARY_RULE_ID",
598
+ "UNSUPPORTED_CLAIM_MESSAGE",
599
+ "UNSUPPORTED_CLAIM_PATTERNS",
600
+ "UNSUPPORTED_CLAIM_RULE_ID",
601
+ "find_governance_findings",
602
+ "find_unsafe_command_execution_findings",
603
+ "find_runtime_network_llm_dependency_findings",
604
+ "find_missing_authority_scope_findings",
605
+ "find_missing_secret_boundary_findings",
606
+ "find_review_ci_bypass_findings",
607
+ "find_unsupported_claim_findings",
608
+ ]
@@ -0,0 +1,73 @@
1
+ """Read-only init planning for agent instruction files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import StrEnum
7
+ from pathlib import Path
8
+
9
+
10
+ class InitPlanAction(StrEnum):
11
+ """Supported init actions."""
12
+
13
+ CREATE = "create"
14
+ BACKUP_AND_REPLACE = "backup-and-replace"
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class PlannedInitFile:
19
+ """A file action planned by init."""
20
+
21
+ path: str
22
+ action: InitPlanAction
23
+ reason: str
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class InitPlan:
28
+ """Read-only init plan for a repository."""
29
+
30
+ repository: str
31
+ files: tuple[PlannedInitFile, ...]
32
+
33
+
34
+ def build_init_plan(root: Path | str) -> InitPlan:
35
+ """Build a read-only init plan without modifying files."""
36
+ root_path = Path(root)
37
+
38
+ if not root_path.exists():
39
+ raise ValueError(f"repository root does not exist: {root_path}")
40
+ if not root_path.is_dir():
41
+ raise ValueError(f"repository root is not a directory: {root_path}")
42
+
43
+ target_path = "AGENTS.md"
44
+ candidate = root_path / target_path
45
+
46
+ if candidate.is_symlink():
47
+ raise ValueError("refusing to plan init for symlinked path: AGENTS.md")
48
+
49
+ if candidate.exists():
50
+ action = InitPlanAction.BACKUP_AND_REPLACE
51
+ reason = "existing file would be backed up before replacement"
52
+ else:
53
+ action = InitPlanAction.CREATE
54
+ reason = "baseline agent instruction file would be created"
55
+
56
+ return InitPlan(
57
+ repository=str(root_path),
58
+ files=(
59
+ PlannedInitFile(
60
+ path=target_path,
61
+ action=action,
62
+ reason=reason,
63
+ ),
64
+ ),
65
+ )
66
+
67
+
68
+ __all__ = [
69
+ "InitPlan",
70
+ "InitPlanAction",
71
+ "PlannedInitFile",
72
+ "build_init_plan",
73
+ ]