Conversation
There was a problem hiding this comment.
Pull request overview
Release PR for Anchor v4.1.4 that updates the governance catalog and scanning/reporting flow to (1) consolidate multiple compliance IDs into a single reported violation, and (2) improve Windows terminal compatibility by removing some Unicode CLI glyphs.
Changes:
- Bumped version to 4.1.4 across package metadata and CLI.
- Updated governance mapping: mitigation patterns now target canonical domain IDs and frameworks/regulators map via
maps_to. - Refactored alias handling and updated engine reporting to aggregate mapped IDs into a single violation entry.
Reviewed changes
Copilot reviewed 19 out of 19 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| setup.py | Bumps package version to 4.1.4. |
| anchor/init.py | Updates library __version__ to 4.1.4. |
| anchor/cli.py | Updates init/sync output formatting, injects maps_to into exported rule dicts, changes rule loading/retention, and removes virtual-alias registration. |
| anchor/core/constitution.py | Updates MITIGATION_SHA256 for integrity verification. |
| anchor/core/loader.py | Refactors alias-chain construction and adds maps_to relations into alias chain. |
| anchor/core/engine.py | Implements Multi-ID aggregation for regex and AST rule matches and updates suppression ignore IDs for subprocess calls. |
| anchor/governance/mitigation.anchor | Remaps mitigation rule IDs to canonical domains and adjusts/extends patterns. |
| .anchor/constitution.anchor | Updates manifest version and legacy alias mapping format. |
| .anchor/mitigation.anchor | Mirrors mitigation catalog changes into the committed .anchor/ copy. |
| .anchor/reports/governance_audit.md | Updates committed audit report to reflect new Multi-ID reporting output. |
| .anchor/frameworks/FINOS_Framework.anchor | Adds FINOS framework mapping rules via maps_to. |
| .anchor/frameworks/OWASP_LLM.anchor | Adds OWASP LLM Top 10 mapping rules via maps_to. |
| .anchor/frameworks/NIST_AI_RMF.anchor | Adds NIST AI RMF mapping rules via maps_to. |
| .anchor/government/SEC_Regulations.anchor | Adds SEC regulator mapping rules via maps_to. |
| .anchor/government/SEBI_Regulations.anchor | Adds SEBI regulator mapping rules via maps_to. |
| .anchor/government/FCA_Regulations.anchor | Adds FCA regulator mapping rules via maps_to. |
| .anchor/government/EU_AI_Act.anchor | Adds EU AI Act mapping rules via maps_to. |
| .anchor/government/CFPB_Regulations.anchor | Adds CFPB mapping rules via maps_to. |
| .anchor/.anchor.sig | Removes legacy local signature file. |
Comments suppressed due to low confidence (2)
anchor/core/engine.py:396
- In the AST-based path, the inline suppression check still only looks for
# anchor: ignore {rule['id']}(canonical ID). After introducing Multi-ID aggregation (framework/regulator IDs viamaps_to), suppressing via a mapped ID (e.g.,# anchor: ignore FINOS-014) will work for regex-based rules but not for AST-based rules. Make the AST suppression check consider the aggregated ID set consistently.
# Aggregate IDs (Canonical + Frameworks)
matching_ids = [rule['id']]
if hasattr(self, 'rules'):
for other in self.rules:
if other.get('maps_to') == rule['id']: matching_ids.append(other['id'])
v_id = ", ".join(sorted(list(set(matching_ids))))
violations.append({
"id": v_id,
"name": rule.get("name", "Unnamed Rule"),
"description": rule.get("description", "No description provided."),
"message": rule.get("message", "Policy Violation"),
"mitigation": rule.get("mitigation", "No mitigation provided."),
"file": file_path,
"line": line_num,
"severity": rule.get("severity", "error")
})
anchor/cli.py:320
- The
policy_templatewritten byanchor initno longer contains the YAML structure that thecheckcommand’sPolicyLoaderexpects (it readsconfig.get('rules', [])). With the current template, users won’t be able to define rule overrides because the file usescustom_rules:(and has an orphan-indented example block) instead of a top-levelrules:list. Update the template to emit valid YAML with arules:list (and, if needed, separateexclude:etc.) that matches whatPolicyLoadermerges.
policy_template = f'''# =============================================================================
# {policy_name.replace('.anchor', '').upper()} — Project Policy
# =============================================================================
# This file is for YOUR project-specific rules.
# Automatically ignored by git to protect company policies.
#
# RULES:
# 1. Can only RAISE severity (ERROR -> BLOCKER is allowed)
# 2. Cannot LOWER severity — the floor is absolute
# 3. Cannot suppress constitutional rules
# Example: raise SEC-006 from error to blocker
# - id: SEC-006
# severity: blocker
# reason: >
# Our PCI-DSS scope requires blocking all direct LLM API calls.
custom_rules:
# Example: add a company-specific rule
# - id: INTERNAL-001
# name: Internal vault access pattern
# severity: blocker
# detection:
# method: regex
# pattern: 'vault\\.read\\((?!approved_keys)'
# description: >
# Vault read operations must only access approved_keys namespace.
'''
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # Only fire on bulk access or sensitive key names | ||
| pattern: >- | ||
| ^(?:[^"\'#]|(["\'])(?:(?!\1).|\\\1)*\1)*\bos\.environ\b(?!\s*\.get|["']) | ||
| ^(?:[^"\'#]|(["\'])(?:(?!\1).|\\\1)*\1)*\bos\.(environ\.(copy|items)\(\)|\benviron\b\s*\[.*(?i)(TOKEN|KEY|SECRET|PASSWORD|CREDENTIAL|API).*\]|\{\*\*os\.environ) |
There was a problem hiding this comment.
This regex uses an inline (?i) flag in the middle of the pattern. In Python re, global inline flags must appear at the start of the expression (or use a scoped group like (?i:...)), otherwise re.error is raised and the rule will silently stop detecting anything. Rewrite the pattern to use a valid case-insensitive construct (e.g., move (?i) to the beginning or change to (?i:...)).
| ^(?:[^"\'#]|(["\'])(?:(?!\1).|\\\1)*\1)*\bos\.(environ\.(copy|items)\(\)|\benviron\b\s*\[.*(?i)(TOKEN|KEY|SECRET|PASSWORD|CREDENTIAL|API).*\]|\{\*\*os\.environ) | |
| ^(?:[^"\'#]|(["\'])(?:(?!\1).|\\\1)*\1)*\bos\.(environ\.(copy|items)\(\)|\benviron\b\s*\[.*(?i:TOKEN|KEY|SECRET|PASSWORD|CREDENTIAL|API).*\]|\{\*\*os\.environ) |
| # Only fire on bulk access or sensitive key names | ||
| pattern: >- | ||
| ^(?:[^"\'#]|(["\'])(?:(?!\1).|\\\1)*\1)*\bos\.environ\b(?!\s*\.get|["']) | ||
| ^(?:[^"\'#]|(["\'])(?:(?!\1).|\\\1)*\1)*\bos\.(environ\.(copy|items)\(\)|\benviron\b\s*\[.*(?i)(TOKEN|KEY|SECRET|PASSWORD|CREDENTIAL|API).*\]|\{\*\*os\.environ) |
There was a problem hiding this comment.
This regex uses an inline (?i) flag in the middle of the pattern. In Python re, global inline flags must appear at the start of the expression (or use a scoped group like (?i:...)), otherwise re.error is raised and the rule will silently stop detecting anything. Rewrite the pattern to use a valid case-insensitive construct (e.g., move (?i) to the beginning or change to (?i:...)).
| ^(?:[^"\'#]|(["\'])(?:(?!\1).|\\\1)*\1)*\bos\.(environ\.(copy|items)\(\)|\benviron\b\s*\[.*(?i)(TOKEN|KEY|SECRET|PASSWORD|CREDENTIAL|API).*\]|\{\*\*os\.environ) | |
| ^(?:[^"\'#]|(["\'])(?:(?!\1).|\\\1)*\1)*\bos\.(environ\.(copy|items)\(\)|\benviron\b\s*\[.*(?i:(TOKEN|KEY|SECRET|PASSWORD|CREDENTIAL|API)).*\]|\{\*\*os\.environ) |
|
|
||
|
|
||
| from anchor import __version__ | ||
| __version__ = "4.1.4" |
There was a problem hiding this comment.
__version__ is imported from anchor and then immediately overwritten with a hard-coded string. This can drift from anchor/__init__.py and setup.py and makes the source of truth unclear. Prefer relying on the package anchor.__version__ (or a single version constant) and remove the reassignment here.
| __version__ = "4.1.4" |
| # Aggregate IDs (Canonical + active Frameworks/Regulators) | ||
| matching_ids = [rule['id']] | ||
| if hasattr(self, 'rules'): | ||
| for other in self.rules: | ||
| if other.get('maps_to') == rule['id']: matching_ids.append(other['id']) | ||
| v_id = ", ".join(sorted(list(set(matching_ids)))) |
There was a problem hiding this comment.
The Multi-ID aggregation loops over self.rules for every match to find maps_to aliases. With larger rule sets this becomes O(matches × rules) and can significantly slow scans. Consider precomputing a reverse index once (e.g., maps_to -> [ids]) during engine initialization and using it here.
| @@ -428,45 +424,21 @@ def resolve_path(rel_path: str) -> Path: | |||
| visited.add(next_id) | |||
|
|
|||
| if next_id in constitution.rules: | |||
| rule = constitution.rules[next_id] | |||
| # Update max severity | |||
| if severity_gte(rule.severity, max_severity): | |||
| max_severity = rule.severity | |||
|
|
|||
| # Treat this rule as our canonical alias target | |||
| canonical_id = next_id | |||
| break # End of chain | |||
| # Target rule found! | |||
| constitution.alias_chain[alias_id] = next_id | |||
| break | |||
| elif next_id in manifest.legacy_aliases: | |||
| next_id = manifest.legacy_aliases[next_id] | |||
| else: | |||
| # Target not found in rules or aliases | |||
| break | |||
|
|
|||
| if canonical_id and canonical_id in constitution.rules: | |||
| constitution.alias_chain[alias_id] = canonical_id | |||
|
|
|||
| # Create virtual rule | |||
| target = constitution.rules[canonical_id] | |||
| alias_rule = Rule( | |||
| id=alias_id, | |||
| name=target.name, | |||
| namespace=target.namespace, | |||
| severity=max_severity, # Use inherited max severity | |||
| min_severity=target.min_severity, | |||
| description=target.description, | |||
| category=target.category, | |||
| maps_to=canonical_id, | |||
| obligation_type=target.obligation_type, | |||
| anchor_mechanism=target.anchor_mechanism, | |||
| source_file=target.source_file, | |||
| original_id=target.original_id, | |||
| v3_id=target.v3_id, | |||
| ) | |||
| constitution.rules[alias_id] = alias_rule | |||
| else: | |||
| constitution.errors.append( | |||
| f"Could not resolve alias chain: {alias_id} → {target_id}" | |||
| ) | |||
|
|
|||
| # Also include maps_to relations from frameworks/regulators in the alias chain | |||
| for rid, rule in constitution.rules.items(): | |||
| if rule.maps_to and rule.maps_to in constitution.rules: | |||
| # If a rule maps to another (e.g. FINOS-014 -> SEC-007) | |||
| # we treat it as an alias for reporting purposes | |||
| if rid not in constitution.alias_chain: | |||
| constitution.alias_chain[rid] = rule.maps_to | |||
There was a problem hiding this comment.
alias_chain entries for legacy IDs are now only set to the first rule found in the chain (e.g., ANC-* -> FINOS-*), even though FINOS rules themselves can maps_to a canonical domain rule. Because get_rule() only resolves one hop via alias_chain, looking up ANC-* will return the framework rule instead of the canonical domain rule described in the docstring. Consider resolving/storing the final canonical target (following maps_to/aliases transitively), or update get_rule() to follow alias_chain until it reaches a stable canonical ID.
| **Timestamp:** 2026-03-22 19:59:07 | ||
| **Source:** `D:\Anchor\anchor\__init__.py` |
There was a problem hiding this comment.
This committed audit report contains environment-specific, absolute Windows paths (e.g., D:\Anchor\...) and a run timestamp, which will cause noisy diffs and can leak local filesystem layout. Consider generating reports at runtime only (and adding them to .gitignore), or sanitize to relative paths and avoid embedding volatile timestamps in tracked files.
| **Timestamp:** 2026-03-22 19:59:07 | |
| **Source:** `D:\Anchor\anchor\__init__.py` | |
| **Timestamp:** (generated at runtime; omitted from committed report) | |
| **Source:** `anchor/__init__.py` |
|
|
||
| click.echo("") | ||
| click.secho("⚓ Anchor V4 — init", fg="cyan", bold=True) | ||
| click.secho("Anchor V4 - init", fg="cyan", bold=True) |
There was a problem hiding this comment.
This PR replaces some Unicode status symbols with ASCII, but cli.py still emits Unicode (e.g., ✗ in the unknown domain/framework/regulator error messages). On Windows terminals with a non-UTF8 code page this can still trigger UnicodeEncodeError. Consider converting the remaining symbols to ASCII as well (or enforcing UTF-8 output explicitly).
| # Aggregate IDs (Canonical + active Frameworks/Regulators) | ||
| matching_ids = [rule['id']] | ||
| if hasattr(self, 'rules'): | ||
| for other in self.rules: | ||
| if other.get('maps_to') == rule['id']: matching_ids.append(other['id']) | ||
| v_id = ", ".join(sorted(list(set(matching_ids)))) | ||
|
|
||
| if self.allow_suppressions: | ||
| if f"# anchor: ignore {rule.get('id')}" in match_text or "# anchor: ignore-all" in match_text: | ||
| if any(f"# anchor: ignore {rid}" in match_text for rid in matching_ids) or "# anchor: ignore-all" in match_text: | ||
| author = self._get_suppression_author(file_path, line_num) | ||
| suppressed.append({ | ||
| "id": rule["id"], "name": rule.get("name"), "file": file_path, "line": line_num, "author": author, "severity": rule.get("severity", "error") | ||
| "id": v_id, "name": rule.get("name"), "file": file_path, "line": line_num, "author": author, "severity": rule.get("severity", "error") | ||
| }) | ||
| is_suppressed = True | ||
| if not is_suppressed: | ||
| violations.append({ | ||
| "id": rule["id"], "name": rule.get("name"), "description": rule.get("description"), "message": rule.get("message"), "mitigation": rule.get("mitigation"), "file": file_path, "line": line_num, "severity": rule.get("severity", "error") | ||
| "id": v_id, "name": rule.get("name"), "description": rule.get("description"), "message": rule.get("message"), "mitigation": rule.get("mitigation"), "file": file_path, "line": line_num, "severity": rule.get("severity", "error") |
There was a problem hiding this comment.
Multi-ID aggregation is currently stored by overwriting the violation id field with a comma-separated string. Downstream code (e.g., anchor.core.healer.suggest_fix() which does rule_id.startswith(prefix)) assumes id is a single rule identifier, so suggestions/auto-fixes and any ID-based lookups will stop working. Keep id as the canonical rule ID and add a separate field (e.g., ids: [...] or related_ids: [...]) for the aggregated set.
PR Release: v4.1.4 — Multi-ID Reporting & Windows Compatibility
This release focuses on refining Anchor's reporting architecture to support multi-compliance ID aggregation and ensuring a seamless experience for Windows users.
Changes
Multi-ID Compliance Reporting
[FINOS-014, SEC-007]) instead of creating redundant entries.loader.pyto eliminate "virtual rule" duplication, ensuring each pattern is scanned exactly once.Canonical Rule Mapping
mitigation.anchorto Canonical Domain IDs (SEC, ALN, etc.).maps_torelation for reporting.Windows Compatibility Fixes
cli.py.UnicodeEncodeErrorthat preventedanchor initandanchor syncfrom completing on Windows systems.Versioning & Integrity
mitigation.anchorSHA-256 integrity hash.Verification
anchor check test_vuln.py --all.anchor init --allon Windows environment.