Skip to content

Commit e32c365

Browse files
author
bgagent
committed
feat(cedar-hitl): three-outcome PolicyEngine core
Chunk 2 of the Cedar HITL gates PR. Rewrites agent/src/policy.py into the three-outcome engine specified in docs/design/CEDAR_HITL_GATES.md section 6. The REQUIRE_APPROVAL outcome is the human-in-the-loop surface the next chunks (PreToolUse hook extension, REST API, CLI) plug into. This chunk ships the engine and its load-time validation; no hook or wire-format changes yet. Engine: - Outcome enum (ALLOW, DENY, REQUIRE_APPROVAL) + extended PolicyDecision with .allowed backward-compat shim for Phase 1a/1b/2 callers. Custom __init__ accepts both outcome= and legacy allowed= kwargs so existing tests keep working verbatim. - Three-outcome pipeline per section 6.2: hard-deny eval (absolute) -> allowlist fast-path (tool_type/tool_group/bash_pattern/write_path/ all_session) -> recent-decision cache (60s TTL on DENIED/TIMED_OUT) -> soft-deny eval (with post-eval rule-scope allowlist check and blueprint_disable filtering) -> default ALLOW. - ApprovalAllowlist (section 6.4): parses and matches every scope type. Strips whitespace and rejects empty-after-strip values so "tool_type: Read " normalizes instead of silently mismatching (review finding 6). - RecentDecisionCache (section 12.9): 50-entry LRU, INDEPENDENT of approvalGateCap. Populated only on DENIED/TIMED_OUT. Session-scoped (documented section 12.8 caveat). - Annotation handling (sections 5.2 + 6.3): parses @rule_id, @tier, @approval_timeout_s, @Severity, @category via cedarpy.policies_to_json_str(); merges on multi-match with min timeout (clamped by 30s floor) and max severity. - Load-time validation (sections 5.1, 12.4): rejects missing/mismatched @tier, missing @rule_id, sub-floor timeouts, duplicate rule_ids across tiers, blueprint text > 64 KB, disable entries naming built-in hard-deny rules (finding 9), approval_gate_cap outside [1, 500] (decision 13). Sub-120s @approval_timeout_s emits WARN but accepts (IMPL-25). - Fail-closed posture (section 13): cedarpy parse errors surface via diagnostics.errors -> RuntimeError raised inside _eval_tier -> outer handler returns DENY with reason "fail-closed: <ExceptionType>". TypeError on json.dumps of unhashable tool_input surfaces as distinct "fail-closed: unhashable_tool_input" reason (review finding 5). Built-in policies: - agent/policies/hard_deny.cedar: base_permit catch-all + rm_slash + write_git_internals + write_git_internals_nested + drop_table + pr_review-specific Write/Edit forbids (absolute). - agent/policies/soft_deny.cedar: base_permit (catch-all required in each tier so cedarpy default-deny does not convert no-match into DENY) + force_push_any + force_push_main + push_to_protected_branch + write_env_files + write_credentials. All soft rules carry @tier, @rule_id, @approval_timeout_s, @Severity, @category per section 15.4 starter set. Review findings addressed (1 blocker, 8 significant, plus minor): - blueprint_disable actually disables soft rules at eval time instead of silently no-op (the blocker: test coverage had been a silent-pass). - Legacy extra_policies with @tier/@rule_id rejected to avoid undefined double-annotation behavior. - _matching_rule_ids logs WARN on unknown policy IDs (state-drift signal). - base_permit validator exemption restricted to effect=="permit" so misnamed forbid rules cannot bypass validation (finding 7). - Hard-tier Cedar no_decision logged at WARN (signals missing/malformed base_permit catch-all). - Allowlist whitespace normalization + empty-value rejection. - StrEnum upgrade, Callable moved to TYPE_CHECKING, assert replaced with explicit RuntimeError for S101 compliance. Phase 1 compatibility: - All 39 existing test_policy.py tests pass unchanged via the .allowed property. One test (test_invalid_policy_syntax_fails_closed) updated to patch _hard_policies instead of the removed _policies attribute; docstring explains the rewrite. - extra_policies kwarg preserved; callers with annotated rules must migrate to blueprint_soft_policies / blueprint_hard_policies. Test counts: agent 516 -> 576 (+60: 51 three-outcome + 9 regression fixes). cli 190 unchanged. cdk 1054 unchanged. Carry-forward to Chunk 3: - extra_policies semantic shift (Phase 1 DENY -> Chunk 2 REQUIRE_APPROVAL); .allowed=False preserved but .outcome differs. Switchover happens when hooks.py adopts the three-outcome branching. - Cross-tier action-context asymmetry (review finding 8): document rule-authoring constraint in section 5.5 of design. - Probe entity-shape coverage (finding 10): extend _probe_cedar to exercise Write/Edit/Bash action paths, not just invoke_tool.
1 parent ca76049 commit e32c365

5 files changed

Lines changed: 1796 additions & 174 deletions

File tree

agent/policies/hard_deny.cedar

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Built-in hard-deny policy set for Cedar HITL engine.
2+
//
3+
// Hard-deny is ABSOLUTE: no --pre-approve scope and no blueprint `disable:`
4+
// directive can bypass these rules. See docs/design/CEDAR_HITL_GATES.md
5+
// §12.5 and decision #8.
6+
//
7+
// Every rule in this file MUST carry @tier("hard") + @rule_id annotations.
8+
// Adding a rule here expands the set of categorically-forbidden agent
9+
// actions; removing a rule requires a security review.
10+
11+
// Base catch-all permit. Specific forbid rules below override.
12+
@rule_id("base_permit")
13+
permit (principal, action, resource);
14+
15+
// pr_review tasks may never invoke Write. Absolute; cannot be overridden
16+
// by per-blueprint customization or --pre-approve.
17+
@tier("hard")
18+
@rule_id("pr_review_forbid_write")
19+
forbid (
20+
principal == Agent::TaskAgent::"pr_review",
21+
action == Agent::Action::"invoke_tool",
22+
resource == Agent::Tool::"Write"
23+
);
24+
25+
// pr_review tasks may never invoke Edit.
26+
@tier("hard")
27+
@rule_id("pr_review_forbid_edit")
28+
forbid (
29+
principal == Agent::TaskAgent::"pr_review",
30+
action == Agent::Action::"invoke_tool",
31+
resource == Agent::Tool::"Edit"
32+
);
33+
34+
// Reject `rm -rf /` and similar absolute-root destructive commands.
35+
@tier("hard")
36+
@rule_id("rm_slash")
37+
forbid (principal, action == Agent::Action::"execute_bash", resource)
38+
when { context.command like "*rm -rf /*" };
39+
40+
// Reject writes into `.git/` at the repo root (breaks local git state).
41+
@tier("hard")
42+
@rule_id("write_git_internals")
43+
forbid (principal, action == Agent::Action::"write_file", resource)
44+
when { context.file_path like ".git/*" };
45+
46+
// Reject writes into nested `.git/` directories (submodules, worktrees).
47+
@tier("hard")
48+
@rule_id("write_git_internals_nested")
49+
forbid (principal, action == Agent::Action::"write_file", resource)
50+
when { context.file_path like "*/.git/*" };
51+
52+
// Reject any SQL DROP TABLE through Bash — agents should not be running
53+
// destructive DDL against production or dev databases without a human
54+
// in the loop. Hard-deny because even "just testing locally" is a common
55+
// vector for data loss (wrong DB connected via saved credentials).
56+
@tier("hard")
57+
@rule_id("drop_table")
58+
forbid (principal, action == Agent::Action::"execute_bash", resource)
59+
when { context.command like "*DROP TABLE*" };

agent/policies/soft_deny.cedar

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// Base catch-all permit. Without it, cedarpy's default-deny would turn
2+
// every non-matching Cedar evaluation on this tier into a DENY decision,
3+
// making the soft tier indistinguishable from hard-deny. With it, Cedar
4+
// returns ALLOW (no matching forbid) and our engine's STEP 3 sees only
5+
// the genuine forbid hits as REQUIRE_APPROVAL.
6+
@rule_id("base_permit")
7+
permit (principal, action, resource);
8+
9+
// Built-in soft-deny policy set for Cedar HITL engine.
10+
//
11+
// Soft-deny is the HUMAN-IN-THE-LOOP surface: matching rules pause the
12+
// tool call, write an approval request to DynamoDB, and await a human
13+
// response via `bgagent approve` / `bgagent deny`. See
14+
// docs/design/CEDAR_HITL_GATES.md §§2, 6, 15.4.
15+
//
16+
// Every rule in this file MUST carry:
17+
// @tier("soft")
18+
// @rule_id("...") — stable ID for --pre-approve rule:X
19+
// @approval_timeout_s — integer seconds >= 30 (<120 emits WARN per IMPL-25)
20+
// @severity — "low" | "medium" | "high"
21+
// @category — optional free-form UX grouping
22+
//
23+
// Blueprints may OPT OUT of specific rules here via
24+
// `security.cedarPolicies.disable: [rule_id]`. They may NOT disable any
25+
// rule in hard_deny.cedar (blueprint loader rejects those at task start).
26+
27+
// Gate any git --force / -f push. 300s default approval window, medium severity.
28+
// Covers both long-form (--force) and short-form (-f) variants, including
29+
// the bare `git push -f` invocation with no branch argument.
30+
@tier("soft")
31+
@rule_id("force_push_any")
32+
@approval_timeout_s("300")
33+
@severity("medium")
34+
@category("destructive")
35+
forbid (principal, action == Agent::Action::"execute_bash", resource)
36+
when { context.command like "*git push --force*"
37+
|| context.command like "*git push -f *"
38+
|| context.command like "*git push -f" };
39+
40+
// Force-push to main/prod specifically — longer window, higher severity.
41+
// Multi-match with force_push_any is expected: the engine's annotation
42+
// merging picks min(300, 600)=300s and max(medium, high)=high.
43+
@tier("soft")
44+
@rule_id("force_push_main")
45+
@approval_timeout_s("600")
46+
@severity("high")
47+
@category("destructive")
48+
forbid (principal, action == Agent::Action::"execute_bash", resource)
49+
when { context.command like "*git push --force origin main*"
50+
|| context.command like "*git push --force origin prod*"
51+
|| context.command like "*git push -f origin main*"
52+
|| context.command like "*git push -f origin prod*" };
53+
54+
// Non-force pushes to protected branches — catches the case where an
55+
// agent bypasses PR workflow by pushing directly.
56+
@tier("soft")
57+
@rule_id("push_to_protected_branch")
58+
@approval_timeout_s("300")
59+
@severity("medium")
60+
@category("destructive")
61+
forbid (principal, action == Agent::Action::"execute_bash", resource)
62+
when { context.command like "*git push origin main*"
63+
|| context.command like "*git push origin master*"
64+
|| context.command like "*git push origin prod*"
65+
|| context.command like "*git push origin release/*" };
66+
67+
// Writes to `.env` files typically contain secrets. 600s window, high severity.
68+
@tier("soft")
69+
@rule_id("write_env_files")
70+
@approval_timeout_s("600")
71+
@severity("high")
72+
@category("filesystem")
73+
forbid (principal, action == Agent::Action::"write_file", resource)
74+
when { context.file_path like "*.env" };
75+
76+
// Writes to any path containing "credentials" — SSH keys, AWS creds,
77+
// service-account JSON, etc. 300s window, high severity.
78+
@tier("soft")
79+
@rule_id("write_credentials")
80+
@approval_timeout_s("300")
81+
@severity("high")
82+
@category("auth")
83+
forbid (principal, action == Agent::Action::"write_file", resource)
84+
when { context.file_path like "*credentials*" };

0 commit comments

Comments
 (0)