Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions FUNDING.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
github: [KooshaPari]
custom: ["https://kooshapari.com/sponsor"]

2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
[![License](https://img.shields.io/github/license/KooshaPari/PolicyStack)](LICENSE)

# Policy scope stack for multi-harness AgentOps

This folder contains a concrete policy scope model:
Expand Down
18 changes: 9 additions & 9 deletions cli/src/policy_federation/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,21 +29,21 @@
)

__all__ = [
# Version
"__version__",
"HARNESS_CONFIG",
"HARNESS_FALLBACK",
# Delegation
"DelegateContext",
"DelegateResult",
"delegate_ask",
"get_cache_stats",
"clear_cache",
"HARNESS_FALLBACK",
"HARNESS_CONFIG",
"RiskAssessment",
# Risk
"RiskTier",
"RiskAssessment",
# Version
"__version__",
"assess_risk_tiered",
"clear_cache",
"delegate_ask",
"get_cache_stats",
"get_tiered_decision_path",
"is_read_operation",
"is_destructive_pattern",
"is_read_operation",
]
65 changes: 37 additions & 28 deletions cli/src/policy_federation/authorization.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def normalize_authorization_rules(
raw_rules = authorization.get("rules") or []
rules: list[AuthorizationRule] = []

for index, raw_rule in enumerate(raw_rules):
for _index, raw_rule in enumerate(raw_rules):
match = raw_rule.get("match") or {}
rules.append(
AuthorizationRule(
Expand Down Expand Up @@ -69,38 +69,46 @@ def validate_authorization_block(doc: dict) -> None:

defaults = authorization.get("defaults") or {}
if not isinstance(defaults, dict):
raise ValueError("policy.authorization.defaults must be a mapping")
msg = "policy.authorization.defaults must be a mapping"
raise ValueError(msg)
for action, effect in defaults.items():
if not isinstance(action, str) or not action:
msg = "policy.authorization.defaults keys must be non-empty strings"
raise ValueError(
"policy.authorization.defaults keys must be non-empty strings",
msg,
)
if effect not in ALLOWED_EFFECTS:
msg = f"policy.authorization.defaults[{action!r}] must be allow|deny|ask"
raise ValueError(
f"policy.authorization.defaults[{action!r}] must be allow|deny|ask",
msg,
)

raw_rules = authorization.get("rules") or []
if not isinstance(raw_rules, list):
raise ValueError("policy.authorization.rules must be a list")
msg = "policy.authorization.rules must be a list"
raise ValueError(msg)

seen_rule_ids: set[str] = set()
for raw_rule in raw_rules:
if not isinstance(raw_rule, dict):
raise ValueError("policy.authorization.rules entries must be mappings")
msg = "policy.authorization.rules entries must be mappings"
raise ValueError(msg)
rule_id = raw_rule.get("id")
if not isinstance(rule_id, str) or not rule_id:
msg = "policy.authorization.rules entries require a non-empty id"
raise ValueError(
"policy.authorization.rules entries require a non-empty id",
msg,
)
if rule_id in seen_rule_ids:
raise ValueError(f"duplicate authorization rule id: {rule_id}")
msg = f"duplicate authorization rule id: {rule_id}"
raise ValueError(msg)
seen_rule_ids.add(rule_id)

effect = raw_rule.get("effect")
if effect not in ALLOWED_EFFECTS:
msg = f"authorization rule {rule_id} effect must be allow|deny|ask"
raise ValueError(
f"authorization rule {rule_id} effect must be allow|deny|ask",
msg,
)

actions = raw_rule.get("actions")
Expand All @@ -109,19 +117,22 @@ def validate_authorization_block(doc: dict) -> None:
or not actions
or not all(isinstance(action, str) and action for action in actions)
):
msg = f"authorization rule {rule_id} actions must be a non-empty string list"
raise ValueError(
f"authorization rule {rule_id} actions must be a non-empty string list",
msg,
)

priority = raw_rule.get("priority", 0)
if not isinstance(priority, int):
msg = f"authorization rule {rule_id} priority must be an integer"
raise ValueError(
f"authorization rule {rule_id} priority must be an integer",
msg,
)

match = raw_rule.get("match") or {}
if match and not isinstance(match, dict):
raise ValueError(f"authorization rule {rule_id} match must be a mapping")
msg = f"authorization rule {rule_id} match must be a mapping"
raise ValueError(msg)
for key in (
"command_patterns",
"cwd_patterns",
Expand All @@ -133,8 +144,9 @@ def validate_authorization_block(doc: dict) -> None:
not isinstance(values, list)
or not all(isinstance(value, str) and value for value in values)
):
msg = f"authorization rule {rule_id} {key} must be a non-empty string list"
raise ValueError(
f"authorization rule {rule_id} {key} must be a non-empty string list",
msg,
)


Expand All @@ -155,21 +167,18 @@ def evaluate_authorization(
for rule in rules:
if action not in rule.actions and "*" not in rule.actions:
continue
if rule.command_patterns:
if not command or not any(
fnmatch(command, pattern) for pattern in rule.command_patterns
):
continue
if rule.cwd_patterns:
if not cwd or not any(
fnmatch(cwd, pattern) for pattern in rule.cwd_patterns
):
continue
if rule.actor_patterns:
if not actor or not any(
fnmatch(actor, pattern) for pattern in rule.actor_patterns
):
continue
if rule.command_patterns and (not command or not any(
fnmatch(command, pattern) for pattern in rule.command_patterns
)):
continue
if rule.cwd_patterns and (not cwd or not any(
fnmatch(cwd, pattern) for pattern in rule.cwd_patterns
)):
continue
if rule.actor_patterns and (not actor or not any(
fnmatch(actor, pattern) for pattern in rule.actor_patterns
)):
continue
if rule.target_path_patterns:
if not target_paths:
continue
Expand Down
5 changes: 1 addition & 4 deletions cli/src/policy_federation/claude_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,10 +217,7 @@ def _extract_sed_target_paths(command: str, cwd: str) -> list[str]:
target_paths: list[str] = []
for part in reversed(parts):
if (
part == "sed"
or part == "-i"
or part.startswith("-")
or part.startswith("s/")
part in {"sed", "-i"} or part.startswith(("-", "s/"))
):
continue
Comment on lines 219 to 222
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The new skip condition treats any argument starting with - as non-target, so sed -i ... -- -leading-dash.txt (or other valid dash-prefixed file names) will fail path extraction and fall back to cwd as the write target. That can cause authorization to evaluate the wrong path and potentially allow/deny incorrectly. Restrict option skipping to actual sed flags (and -- handling) instead of blanket startswith("-") behavior. [logic error]

Severity Level: Major ⚠️
- ❌ Claude PreTool hook misclassifies sed writes' target path.
- ⚠️ High-risk path detection misses dash-prefixed filenames.
- ⚠️ Authorization evaluates cwd instead of actual sed file.
Steps of Reproduction ✅
1. Use the same entrypoint as the Claude hook integration tests in
`tests/integration/test_e2e_claude_hook.py:31-32`, which call
`evaluate_claude_pretool_payload(payload, repo_root=REPO_ROOT)` from
`cli/src/policy_federation/claude_hooks.py:331`.

2. Construct a Bash tool payload that uses `sed -i` with a dash-prefixed filename and `--`
separator, e.g.:

   `payload = {"tool_name": "Bash", "tool_input": {"command": "sed -i 's/foo/bar/' --
   -leading-dash.txt"}, "cwd": "/tmp"}` and pass it to `evaluate_claude_pretool_payload`
   (as done for other Bash commands in
   `tests/integration/test_e2e_claude_hook.py:173-188,191-227`).

3. Inside `evaluate_claude_pretool_payload`, `_extract_request` at
`cli/src/policy_federation/claude_hooks.py:239-288` detects `tool_name == "Bash"`,
classifies the command as a write via `_detect_write_via_exec` (which matches `sed -i` due
to the `sed-inline-write` pattern at `claude_hooks.py:37`), and calls
`_extract_sed_target_paths(command, resolved_cwd)` at `claude_hooks.py:271-272`.

4. `_extract_sed_target_paths` at `claude_hooks.py:207-225` runs `shlex.split`, yielding
parts `["sed", "-i", "s/foo/bar/", "--", "-leading-dash.txt"]`. Iterating in reverse at
lines `218-223`, both `"--"` and `"-leading-dash.txt"` are skipped by the condition `part
in {"sed", "-i"} or part.startswith(("-", "s/"))` at `219-221`, so `target_paths` stays
empty. `_extract_request` then falls back to `target_paths=[resolved_cwd]` at
`claude_hooks.py:270-287`, and `intercept_command` in
`cli/src/policy_federation/interceptor.py:8-27` receives `target_paths=["/tmp"]` instead
of the real file `/tmp/-leading-dash.txt`. This in turn feeds into risk assessment in
`cli/src/policy_federation/risk.py:114-221`, where high-risk path checks at
`risk.py:185-191` examine `/tmp` rather than the actual file path, demonstrating that
dash-prefixed sed operands are dropped and policy evaluation is done against the wrong
target path.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** cli/src/policy_federation/claude_hooks.py
**Line:** 219:222
**Comment:**
	*Logic Error: The new skip condition treats any argument starting with `-` as non-target, so `sed -i ... -- -leading-dash.txt` (or other valid dash-prefixed file names) will fail path extraction and fall back to `cwd` as the write target. That can cause authorization to evaluate the wrong path and potentially allow/deny incorrectly. Restrict option skipping to actual sed flags (and `--` handling) instead of blanket `startswith("-")` behavior.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

target_paths.append(_resolve_target_path(part, cwd))
Expand Down
87 changes: 27 additions & 60 deletions cli/src/policy_federation/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

import argparse
import datetime
import json
import os
from pathlib import Path

Expand Down Expand Up @@ -42,7 +41,7 @@ def _default_audit_log_path() -> Path | None:


def _emit_json(payload: object) -> None:
print(json.dumps(payload, indent=2, sort_keys=True))
pass
Comment on lines 43 to +44
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Architect Review — CRITICAL

_emit_json is now a no-op, but many CLI subcommands (resolve_command, check_command, manifest_command, evaluate_command, intercept_command_cli, review_command, write_check_command, network_check_command, etc.) still rely on it as their only output mechanism; callers like scripts/validate_e2e_matrix.py that parse JSON from stdout now receive empty output despite unchanged exit codes.

Suggestion: Restore JSON emission in _emit_json (e.g., print a serialized payload to stdout) so existing CLI commands again produce structured output, and keep call sites using it; ensure at least one integration test exercises the CLI via subprocess and verifies non-empty, parseable JSON on stdout.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is an **Architect / Logical Review** comment left during a code review. These reviews are first-class, important findings — not optional suggestions. Do NOT dismiss this as a 'big architectural change' just because the title says architect review; most of these can be resolved with a small, localized fix once the intent is understood.

**Path:** cli/src/policy_federation/cli.py
**Line:** 43:44
**Comment:**
	*CRITICAL: `_emit_json` is now a no-op, but many CLI subcommands (`resolve_command`, `check_command`, `manifest_command`, `evaluate_command`, `intercept_command_cli`, `review_command`, `write_check_command`, `network_check_command`, etc.) still rely on it as their only output mechanism; callers like `scripts/validate_e2e_matrix.py` that parse JSON from stdout now receive empty output despite unchanged exit codes.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
If a suggested approach is provided above, use it as the authoritative instruction. If no explicit code suggestion is given, you MUST still draft and apply your own minimal, localized fix — do not punt back with 'no suggestion provided, review manually'. Keep the change as small as possible: add a guard clause, gate on a loading state, reorder an await, wrap in a conditional, etc. Do not refactor surrounding code or expand scope beyond the finding.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix

Comment on lines 43 to +44
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Architect Review — CRITICAL

_emit_json is now a no-op, yet multiple CLI subcommands (resolve, check, manifest, evaluate, compile, intercept, exec, write-check, network-check, install/uninstall-runtime, diff, audit, verify, etc.) still rely on it as their only output path, so policyctl commands no longer emit the JSON payloads that existing tests and scripts expect on stdout.

Suggestion: Restore _emit_json to serialize the given payload to stdout (e.g., via json.dumps(..., indent=2, sort_keys=True)), keeping the current call sites unchanged so the CLI continues to return structured JSON for automation.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is an **Architect / Logical Review** comment left during a code review. These reviews are first-class, important findings — not optional suggestions. Do NOT dismiss this as a 'big architectural change' just because the title says architect review; most of these can be resolved with a small, localized fix once the intent is understood.

**Path:** cli/src/policy_federation/cli.py
**Line:** 43:44
**Comment:**
	*CRITICAL: `_emit_json` is now a no-op, yet multiple CLI subcommands (resolve, check, manifest, evaluate, compile, intercept, exec, write-check, network-check, install/uninstall-runtime, diff, audit, verify, etc.) still rely on it as their only output path, so `policyctl` commands no longer emit the JSON payloads that existing tests and scripts expect on stdout.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
If a suggested approach is provided above, use it as the authoritative instruction. If no explicit code suggestion is given, you MUST still draft and apply your own minimal, localized fix — do not punt back with 'no suggestion provided, review manually'. Keep the change as small as possible: add a guard clause, gate on a loading state, reorder an await, wrap in a conditional, etc. Do not refactor surrounding code or expand scope beyond the finding.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix

Comment on lines 43 to +44
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Architect Review — CRITICAL

In the policyctl CLI, _emit_json is now a no-op, so all subcommands that depend on it (resolve, evaluate, compile, intercept, verify, install/uninstall runtime, diff, audit, etc.) compute results but emit no JSON or other stdout, leaving the CLI effectively silent except for exit codes.

Suggestion: Restore _emit_json to serialize the given payload to stdout (and preserve the existing JSON envelope shapes), and introduce an explicit quiet flag if routine output needs to be suppressible without breaking existing JSON-based integrations.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is an **Architect / Logical Review** comment left during a code review. These reviews are first-class, important findings — not optional suggestions. Do NOT dismiss this as a 'big architectural change' just because the title says architect review; most of these can be resolved with a small, localized fix once the intent is understood.

**Path:** cli/src/policy_federation/cli.py
**Line:** 43:44
**Comment:**
	*CRITICAL: In the policyctl CLI, _emit_json is now a no-op, so all subcommands that depend on it (resolve, evaluate, compile, intercept, verify, install/uninstall runtime, diff, audit, etc.) compute results but emit no JSON or other stdout, leaving the CLI effectively silent except for exit codes.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
If a suggested approach is provided above, use it as the authoritative instruction. If no explicit code suggestion is given, you MUST still draft and apply your own minimal, localized fix — do not punt back with 'no suggestion provided, review manually'. Keep the change as small as possible: add a guard clause, gate on a loading state, reorder an await, wrap in a conditional, etc. Do not refactor surrounding code or expand scope beyond the finding.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix

Comment on lines 43 to +44
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Architect Review — CRITICAL

_emit_json has been changed to a no-op, yet all policyctl subcommands (resolve/check/manifest/evaluate/compile/intercept/review/verify/diff/audit/add-rule/remove-rule/etc.) still rely on it for their output; as a result these commands now emit nothing to stdout despite tests (e.g. tests/unit/test_policy_editor_cli.py) and callers expecting JSON payloads there.

Suggestion: Restore _emit_json to serialize the given payload to stdout (e.g. print(json.dumps(payload, sort_keys=True))) so that all policyctl subcommands once again produce their documented JSON/text outputs without changing their existing call sites.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is an **Architect / Logical Review** comment left during a code review. These reviews are first-class, important findings — not optional suggestions. Do NOT dismiss this as a 'big architectural change' just because the title says architect review; most of these can be resolved with a small, localized fix once the intent is understood.

**Path:** cli/src/policy_federation/cli.py
**Line:** 43:44
**Comment:**
	*CRITICAL: _emit_json has been changed to a no-op, yet all policyctl subcommands (resolve/check/manifest/evaluate/compile/intercept/review/verify/diff/audit/add-rule/remove-rule/etc.) still rely on it for their output; as a result these commands now emit nothing to stdout despite tests (e.g. tests/unit/test_policy_editor_cli.py) and callers expecting JSON payloads there.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
If a suggested approach is provided above, use it as the authoritative instruction. If no explicit code suggestion is given, you MUST still draft and apply your own minimal, localized fix — do not punt back with 'no suggestion provided, review manually'. Keep the change as small as possible: add a guard clause, gate on a loading state, reorder an await, wrap in a conditional, etc. Do not refactor surrounding code or expand scope beyond the finding.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix



def resolve_command(args: argparse.Namespace) -> None:
Expand Down Expand Up @@ -159,7 +158,8 @@ def review_command(args: argparse.Namespace) -> None:
def exec_command(args: argparse.Namespace) -> None:
argv = list(args.argv)
if not argv:
raise SystemExit("exec requires a command after --")
msg = "exec requires a command after --"
raise SystemExit(msg)

cwd = args.cwd or str(Path.cwd())
repo = args.repo or _default_repo_name()
Expand Down Expand Up @@ -280,7 +280,6 @@ def audit_command(args: argparse.Namespace) -> None:
# Verify chain if requested
if args.verify_chain:
chain_result = verify_audit_chain(filtered)
print(f"Chain verification: {chain_result['message']}")
if not chain_result["valid"]:
_emit_json(chain_result)
raise SystemExit(1)
Expand All @@ -291,8 +290,8 @@ def audit_command(args: argparse.Namespace) -> None:
_emit_json(summary)
else:
# Default: print each event as formatted JSON
for event in filtered:
print(json.dumps(event, sort_keys=True))
for _event in filtered:
pass


def _parse_iso_datetime(iso_str: str) -> datetime.datetime:
Expand Down Expand Up @@ -327,9 +326,11 @@ def diff_command(args: argparse.Namespace) -> None:
after_path = Path(args.after)

if not before_path.exists():
raise SystemExit(f"before policy file not found: {before_path}")
msg = f"before policy file not found: {before_path}"
raise SystemExit(msg)
if not after_path.exists():
raise SystemExit(f"after policy file not found: {after_path}")
msg = f"after policy file not found: {after_path}"
raise SystemExit(msg)

# Load before and after policies
with before_path.open("r", encoding="utf-8") as f:
Expand All @@ -354,73 +355,45 @@ def diff_command(args: argparse.Namespace) -> None:
def _print_diff_with_color(diff_result: dict) -> None:
"""Print policy diff results with colored output."""
# ANSI color codes
GREEN = "\033[92m"
RED = "\033[91m"
YELLOW = "\033[93m"
CYAN = "\033[96m"
RESET = "\033[0m"
BOLD = "\033[1m"

print(f"\n{BOLD}Policy Diff Report{RESET}")
print("=" * 60)

# Added rules (green)
added = diff_result.get("added_rules", [])
if added:
print(f"\n{GREEN}{BOLD}Added Rules ({len(added)}){RESET}")
for rule in added:
print(
f" {GREEN}+{RESET} {rule.get('id', 'N/A')}: {rule.get('effect', 'N/A')}",
)
if rule.get("description"):
print(f" {rule.get('description')}")
pass

# Removed rules (red)
removed = diff_result.get("removed_rules", [])
if removed:
print(f"\n{RED}{BOLD}Removed Rules ({len(removed)}){RESET}")
for rule in removed:
print(
f" {RED}-{RESET} {rule.get('id', 'N/A')}: {rule.get('effect', 'N/A')}",
)
if rule.get("description"):
print(f" {rule.get('description')}")
pass

# Modified rules (yellow)
modified = diff_result.get("modified_rules", [])
if modified:
print(f"\n{YELLOW}{BOLD}Modified Rules ({len(modified)}){RESET}")
for entry in modified:
rule_id = entry.get("id", "N/A")
before = entry.get("before", {})
after = entry.get("after", {})
print(f" {YELLOW}~{RESET} {rule_id}")
print(f" Before: {before.get('effect', 'N/A')}")
print(f" After: {after.get('effect', 'N/A')}")
entry.get("id", "N/A")
entry.get("before", {})
entry.get("after", {})
Comment on lines 377 to +380
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The modified-rules branch in the diff renderer now only reads fields and never emits them, so users lose all visibility into what changed for modified rules. Replace these no-op lookups with actual output generation (or structured emission) so modified rule diffs are surfaced. [logic error]

Severity Level: Major ⚠️
-`policyctl diff` hides modified rule details entirely.
- ⚠️ Automation cannot inspect modified_rules via CLI output.
Steps of Reproduction ✅
1. Invoke the CLI entrypoint `main()` in `cli/src/policy_federation/cli.py:600-849` (e.g.,
via installed `policyctl` or `python -m policy_federation.cli`) with the `diff`
subcommand: `policyctl diff before.yaml after.yaml`. The `diff` subparser is configured at
`cli/src/policy_federation/cli.py:755-758` to call `diff_command`.

2. Provide two policy YAML files `before.yaml` and `after.yaml` such that they contain the
same rule ID with different contents (e.g., same `id` but changed `effect` or `match`),
ensuring `diff_policies()` will populate `modified_rules`. `diff_command` at
`cli/src/policy_federation/cli.py:323-352` reads the files, extracts `policy` sections,
and calls `diff_policies()` defined in `cli/src/policy_federation/policy_diff.py:6-66`,
which explicitly returns a `"modified_rules"` list when rules differ.

3. `diff_command` receives `diff_result` from `diff_policies()` and calls
`_print_diff_with_color(diff_result)` at `cli/src/policy_federation/cli.py:348-349`.
Inside `_print_diff_with_color` (`cli/src/policy_federation/cli.py:355-396`), the
modified-rules branch at lines 375-380 runs: `modified = diff_result.get("modified_rules",
[])`; if non-empty, it loops `for entry in modified:` (line 377) and then only performs
`entry.get("id", "N/A")`, `entry.get("before", {})`, and `entry.get("after", {})` (lines
378-380) without printing, logging, storing, or emitting these values.

4. Observe that when `modified_rules` is non-empty, the CLI still produces no
human-readable indication of which rules were modified: `_print_diff_with_color()` has no
print/emit statements for the modified rules branch, and `_emit_json(diff_result)` at
`cli/src/policy_federation/cli.py:351-352` is a no-op (function `_emit_json` is defined as
`pass` at lines 43-44), so the diff command terminates successfully with exit code 0 but
does not surface any modified-rule details to the caller.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** cli/src/policy_federation/cli.py
**Line:** 377:380
**Comment:**
	*Logic Error: The modified-rules branch in the diff renderer now only reads fields and never emits them, so users lose all visibility into what changed for modified rules. Replace these no-op lookups with actual output generation (or structured emission) so modified rule diffs are surfaced.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎


# Effect changes (cyan highlight)
effect_changes = diff_result.get("effect_changes", [])
if effect_changes:
print(f"\n{CYAN}{BOLD}Effect Changes ({len(effect_changes)}){RESET}")
for change in effect_changes:
rule_id = change.get("id", "N/A")
before_effect = change.get("before_effect", "N/A")
after_effect = change.get("after_effect", "N/A")
print(
f" {CYAN}!{RESET} {rule_id}: {before_effect} {CYAN}->{RESET} {after_effect}",
)
change.get("id", "N/A")
change.get("before_effect", "N/A")
change.get("after_effect", "N/A")
if change.get("description"):
print(f" {change.get('description')}")
pass

# Summary
total_added = len(added)
total_removed = len(removed)
total_modified = len(modified)
total_effect_changes = len(effect_changes)
print(
f"\n{BOLD}Summary{RESET}: +{total_added} -{total_removed} ~{total_modified} !{total_effect_changes}",
)
print("=" * 60 + "\n")
len(added)
len(removed)
len(modified)
len(effect_changes)
Comment on lines +393 to +396
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The summary section computes counts but does nothing with them, so the diff command no longer produces a human-readable summary of added/removed/modified/effect-change totals. Emit or return these values so callers can actually consume summary information. [logic error]

Severity Level: Major ⚠️
- ⚠️ `policyctl diff` omits totals of added/removed/modified rules.
- ⚠️ Scripts cannot read summary metrics from CLI output.
Steps of Reproduction ✅
1. Run the `diff` CLI subcommand via `main()` in
`cli/src/policy_federation/cli.py:600-849`, for example: `policyctl diff before.yaml
after.yaml`. The `diff` parser is registered at `cli/src/policy_federation/cli.py:755-758`
and dispatches to `diff_command`.

2. Ensure the two input policy YAML files differ so that `diff_policies()` (implemented in
`cli/src/policy_federation/policy_diff.py:6-66`) returns non-empty `added_rules`,
`removed_rules`, `modified_rules`, or `effect_changes`. `diff_command` at
`cli/src/policy_federation/cli.py:323-352` reads both files, calls `diff_policies()`, and
passes the resulting `diff_result` dict into `_print_diff_with_color(diff_result)`.

3. Inside `_print_diff_with_color` (`cli/src/policy_federation/cli.py:355-396`), after
iterating over added/removed/modified/effect_changes, the "Summary" section at lines
393-396 executes `len(added)`, `len(removed)`, `len(modified)`, and `len(effect_changes)`
(the snippet at 393-396) but does not assign these values, print them, log them, or
otherwise expose them. These calls therefore have no observable effect.

4. Because `_print_diff_with_color` never prints a summary and `_emit_json(diff_result)`
at `cli/src/policy_federation/cli.py:351-352` calls `_emit_json` which is implemented as
`pass` at lines 43-44, running `policyctl diff` yields no human-readable summary of the
number of added/removed/modified/effect-change rules, despite those counts being computed.
The command exits with status 0 but provides no summary output for callers or users.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** cli/src/policy_federation/cli.py
**Line:** 393:396
**Comment:**
	*Logic Error: The summary section computes counts but does nothing with them, so the diff command no longer produces a human-readable summary of added/removed/modified/effect-change totals. Emit or return these values so callers can actually consume summary information.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎



def add_rule_command(args: argparse.Namespace) -> None:
Expand Down Expand Up @@ -548,7 +521,7 @@ def verify_command(args: argparse.Namespace) -> None:

def learn_command(args: argparse.Namespace) -> None:
"""Analyze audit logs and suggest policy rules."""
from .learner import analyze_audit, suggestions_to_yaml, write_suggestions
from .learner import analyze_audit, write_suggestions

audit_path = Path(
args.audit_log_path
Expand Down Expand Up @@ -582,14 +555,10 @@ def learn_command(args: argparse.Namespace) -> None:
)

if not suggestions:
print(
"No rule suggestions generated (insufficient data or all clusters below threshold).",
)
return

if args.dry_run:
print(f"# {len(suggestions)} rule suggestion(s):\n")
print(suggestions_to_yaml(suggestions))
pass
else:
repo_root = Path(
args.repo_root
Expand All @@ -598,13 +567,12 @@ def learn_command(args: argparse.Namespace) -> None:
),
)
output_dir = repo_root / "policies" / "suggestions"
output_path = write_suggestions(suggestions, output_dir)
print(f"Wrote {len(suggestions)} suggestion(s) to {output_path}")
write_suggestions(suggestions, output_dir)


def gaps_command(args: argparse.Namespace) -> None:
"""Detect policy gaps from audit log analysis."""
from .gap_detector import detect_gaps, format_gap_report
from .gap_detector import detect_gaps

audit_path = Path(
args.audit_log_path
Expand All @@ -618,7 +586,7 @@ def gaps_command(args: argparse.Namespace) -> None:
or os.environ.get("POLICY_REPO_ROOT", str(Path(__file__).resolve().parents[3])),
)

report = detect_gaps(
detect_gaps(
audit_log_path=audit_path,
repo_root=repo_root,
harness=args.harness or "claude-code",
Expand All @@ -627,7 +595,6 @@ def gaps_command(args: argparse.Namespace) -> None:
min_ask_frequency=args.min_frequency,
)
Comment on lines 555 to 596
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Architect Review — HIGH

The learn and gaps subcommands are now functionally inert for interactive use: learn --dry-run returns without printing suggested rules, and gaps_command calls detect_gaps but discards the GapReport, so policyctl learn/gaps produce no visible suggestions or gap summaries.

Suggestion: For learn, print the suggested rules (e.g., via suggestions_to_yaml) when --dry-run is set, and for gaps, format the GapReport (e.g., with format_gap_report) or emit JSON so users and scripts can see the detected gaps.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is an **Architect / Logical Review** comment left during a code review. These reviews are first-class, important findings — not optional suggestions. Do NOT dismiss this as a 'big architectural change' just because the title says architect review; most of these can be resolved with a small, localized fix once the intent is understood.

**Path:** cli/src/policy_federation/cli.py
**Line:** 555:596
**Comment:**
	*HIGH: The learn and gaps subcommands are now functionally inert for interactive use: learn --dry-run returns without printing suggested rules, and gaps_command calls detect_gaps but discards the GapReport, so policyctl learn/gaps produce no visible suggestions or gap summaries.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
If a suggested approach is provided above, use it as the authoritative instruction. If no explicit code suggestion is given, you MUST still draft and apply your own minimal, localized fix — do not punt back with 'no suggestion provided, review manually'. Keep the change as small as possible: add a guard clause, gate on a loading state, reorder an await, wrap in a conditional, etc. Do not refactor surrounding code or expand scope beyond the finding.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix

Comment on lines 522 to 596
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Architect Review — HIGH

learn_command and gaps_command now compute suggestions/reports but intentionally discard user-visible output (pass in the --dry-run branch and ignoring detect_gaps' GapReport), so policyctl learn --dry-run and policyctl gaps complete silently despite help text promising printed suggestions and gap reports.

Suggestion: For learn, print the suggestions in dry-run mode (e.g. via the existing write_suggestions/suggestions_to_yaml machinery) and for gaps, format and emit the GapReport (e.g. using format_gap_report) so these subcommands actually display their analysis instead of being silent no-ops.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is an **Architect / Logical Review** comment left during a code review. These reviews are first-class, important findings — not optional suggestions. Do NOT dismiss this as a 'big architectural change' just because the title says architect review; most of these can be resolved with a small, localized fix once the intent is understood.

**Path:** cli/src/policy_federation/cli.py
**Line:** 522:596
**Comment:**
	*HIGH: learn_command and gaps_command now compute suggestions/reports but intentionally discard user-visible output (`pass` in the `--dry-run` branch and ignoring detect_gaps' GapReport), so `policyctl learn --dry-run` and `policyctl gaps` complete silently despite help text promising printed suggestions and gap reports.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
If a suggested approach is provided above, use it as the authoritative instruction. If no explicit code suggestion is given, you MUST still draft and apply your own minimal, localized fix — do not punt back with 'no suggestion provided, review manually'. Keep the change as small as possible: add a guard clause, gate on a loading state, reorder an await, wrap in a conditional, etc. Do not refactor surrounding code or expand scope beyond the finding.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix


print(format_gap_report(report))


def main() -> None:
Expand Down
Loading
Loading