Skip to content

feat(ai): constrain ai task to allowed_targets scope (AI-hardening round 1) - #1217

Draft
ocervell wants to merge 2 commits into
mainfrom
feat/ai-hardening
Draft

feat(ai): constrain ai task to allowed_targets scope (AI-hardening round 1)#1217
ocervell wants to merge 2 commits into
mainfrom
feat/ai-hardening

Conversation

@ocervell

@ocervell ocervell commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

AI-hardening round 1 — secator core (NOT yet deployed)

Stops the ai task (and the runners it spawns) from drifting outside the authorized workspace scope by adding a platform-supplied target allow-list.

What's done

  • allowed_targets opt on the ai task (secator/tasks/ai.py): a list of allowed target strings/regexes, marked internal: True (set by the platform, not the user — like context). Read in _init_options and passed to the PermissionEngine.
  • PermissionEngine wiring (secator/ai/guardrails.py): PermissionEngine.__init__ now takes allowed_targets. Entries are compiled as regexes (invalid regex falls back to an escaped literal). When set, they:
    • force the target-check step to run (_has_rules_for("target") returns True), so out-of-scope targets are constrained rather than silently allowed;
    • allow a target(...) value (or its URL host / host:port components) when it matches an entry — checked after deny rules (deny still wins) and before config/runtime allow rules.
  • Tests (tests/unit/test_ai_guardrails.py, new TestAllowedTargets): literal/regex/URL-host matches, out-of-scope constraint, deny precedence, task-target scope, invalid-regex fallback.

Verification

  • tests/unit/test_ai_guardrails.py: 126 passed (9 new).
  • flake8 (project .flake8): clean on all changed lines (the 5 pre-existing E501/F841/E30x in the test file are on origin/main too, outside this change).

Companion PR

Pairs with freelabz/secator-api#200 (attaches validated mandates as allowed_targets, adds context.allowed_targets, and DISALLOWED_RUNNER_OPTIONS). This is AI-hardening round 1, not yet deployed.

🤖 Generated with Claude Code

https://claude.ai/code/session_01P5vSjfkBuGAAHdKxHS3ySm

Summary by CodeRabbit

  • New Features

    • Added an allow-list for approved network targets, so AI-driven actions can stay within specified target patterns or exact matches.
    • Added a new task option to configure these allowed targets.
  • Bug Fixes

    • Improved target checking so allowed targets are enforced even when no target-specific rules are defined.
    • Invalid pattern entries now safely fall back to literal matching.
    • Explicit deny rules still take precedence over allowed targets.

Add an internal `allowed_targets` opt to the `ai` task — a platform-set
allow-list of target strings/regexes (e.g. validated workspace mandates).
It is marked internal (set by the platform, not the user, like `context`).

Wire it into PermissionEngine: when `allowed_targets` is set it forces the
target-check step to run and a proposed `target(...)` action is allowed only
if the value (or its URL host/host:port components) matches one of the
regexes. Deny rules still take precedence. Invalid regexes fall back to a
literal (escaped) match.

Tests cover literal/regex/url-host matches, out-of-scope constraint, deny
precedence, and the invalid-regex fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P5vSjfkBuGAAHdKxHS3ySm
@coderabbitai

coderabbitai Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 93bc1638-9274-497d-a672-4a802030b66a

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

PermissionEngine gains an allowed_targets parameter that compiles each entry into a regex allow-list. A new _matches_allowed_targets helper and two targeted hooks in _has_rules_for and _check_value enforce the mandate. The ai task exposes and threads through this option, and a new TestAllowedTargets suite validates all matching and precedence behaviors.

Changes

allowed_targets Guardrail Constraint

Layer / File(s) Summary
PermissionEngine allowed_targets logic
secator/ai/guardrails.py
__init__ compiles allowed_targets entries to regexes (with escaped-literal fallback). _has_rules_for reports target rules present when the list is non-empty. _check_value inserts a post-deny mandate check that immediately returns Allowed by mandate on a match.
ai task wiring and TestAllowedTargets suite
secator/tasks/ai.py, tests/unit/test_ai_guardrails.py
ai.opts adds an internal allowed_targets list entry; _init_options reads it with an empty-list default; PermissionEngine construction passes allowed_targets=self.allowed_targets. TestAllowedTargets covers literal, regex, out-of-scope, forced check, deny precedence, invalid-regex fallback, and URL host matching.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Poem

🐇 A new list of targets, allowed and blessed,
Compiled to regexes, put through the test.
If the host matches, the mandate rings clear—
"Allowed by mandate!" echoes far and near.
Deny still wins if it had its say first,
But valid targets may now quench their thirst. 🌐

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: constraining the AI task to an allowed_targets scope.
Docstring Coverage ✅ Passed Docstring coverage is 83.33% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ai-hardening

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
tests/unit/test_ai_guardrails.py (1)

353-373: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Strengthen the negative mandate assertions.

The suite still misses two important regressions: a literal allow-list entry should reject suffix hosts like example.com.evil.com, and the out-of-scope cases should assert "deny" rather than just "not allow" so an ask path cannot slip through unnoticed.

Suggested test additions
 	def test_allowed_target_literal_match(self):
 		engine = self._make_engine(allow=["shell(nmap)"], allowed_targets=["example.com"])
 		result = engine.check_action({"action": "shell", "command": "nmap example.com"})
 		self.assertEqual(result.decision, "allow")
 
+	def test_allowed_target_literal_does_not_prefix_match(self):
+		engine = self._make_engine(allow=["shell(nmap)"], allowed_targets=["example.com"])
+		result = engine.check_action({"action": "shell", "command": "nmap example.com.evil.com"})
+		self.assertEqual(result.decision, "deny")
+
 	def test_target_outside_allowed_targets_is_constrained(self):
 		"""A target not matching any allowed_targets regex must NOT be silently allowed."""
 		engine = self._make_engine(allow=["shell(nmap)"], allowed_targets=[r".*\.example\.com"])
 		result = engine.check_action({"action": "shell", "command": "nmap evil.attacker.com"})
-		self.assertNotEqual(result.decision, "allow")
+		self.assertEqual(result.decision, "deny")
 
 	def test_allowed_targets_presence_forces_target_check(self):
 		"""Even with no config target rules, allowed_targets makes the target step run."""
 		engine = self._make_engine(allow=["task(*)"], allowed_targets=["10.0.0.1"])
 		result = engine.check_action({"action": "task", "name": "nmap", "targets": ["8.8.8.8"]})
-		self.assertNotEqual(result.decision, "allow")
+		self.assertEqual(result.decision, "deny")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/test_ai_guardrails.py` around lines 353 - 373, Strengthen the
guardrails tests in test_ai_guardrails by adding a negative case for literal
allowed_targets so the check in test_allowed_target_literal_match rejects suffix
lookalikes like example.com.evil.com, and update the out-of-scope assertions in
test_target_outside_allowed_targets_is_constrained and
test_allowed_targets_presence_forces_target_check to assert a deny decision
instead of only not allow. Use the existing _make_engine and check_action flow
to verify the target-validation path enforces denial rather than falling back to
ask.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@secator/ai/guardrails.py`:
- Around line 554-558: The target allow-list check in _matches_allowed_targets
is too permissive because rx.match() turns literal entries into prefix matches;
remove the fallback and rely on rx.fullmatch() only so allow-list patterns
remain exact. Keep the change localized to _matches_allowed_targets in
guardrails.py, since _check_value() already normalizes URL targets into host and
host:port before this comparison.
- Around line 729-735: The target allow-list handling in
`Guardrails._check_values()` currently only allows matching `allowed_targets`
but does not explicitly reject non-matching targets, letting later
config/runtime paths in `_check_values()`, `prompt_target()`, and the workspace
auto-approve flow in `secator/tasks/ai.py` override the mandate. Update the
`rule_type == "target"` branch to return a deny/forbid `PermissionResult` when
`allowed_targets` is set and none of `values_to_check` match, so out-of-scope
targets are blocked before any other allow path is consulted.

---

Nitpick comments:
In `@tests/unit/test_ai_guardrails.py`:
- Around line 353-373: Strengthen the guardrails tests in test_ai_guardrails by
adding a negative case for literal allowed_targets so the check in
test_allowed_target_literal_match rejects suffix lookalikes like
example.com.evil.com, and update the out-of-scope assertions in
test_target_outside_allowed_targets_is_constrained and
test_allowed_targets_presence_forces_target_check to assert a deny decision
instead of only not allow. Use the existing _make_engine and check_action flow
to verify the target-validation path enforces denial rather than falling back to
ask.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 3f6a9905-698a-4824-bd86-fef33867a5d7

📥 Commits

Reviewing files that changed from the base of the PR and between 8d8ec83 and baf30c3.

📒 Files selected for processing (3)
  • secator/ai/guardrails.py
  • secator/tasks/ai.py
  • tests/unit/test_ai_guardrails.py

Comment thread secator/ai/guardrails.py
Comment on lines +554 to +558
def _matches_allowed_targets(self, value: str) -> bool:
"""Check if a target value matches any platform-supplied allowed_targets regex."""
for rx in self.allowed_targets:
if rx.fullmatch(value) or rx.match(value):
return True

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

match() widens literal allow-list entries into prefix matches.

Line 557 lets allowed_targets=["example.com"] match example.com.evil.com, and the escaped-literal fallback inherits the same bug. _check_value() already expands URLs into host and host:port, so fullmatch() alone preserves the intended scope boundary.

Suggested fix
 def _matches_allowed_targets(self, value: str) -> bool:
 	"""Check if a target value matches any platform-supplied allowed_targets regex."""
 	for rx in self.allowed_targets:
-		if rx.fullmatch(value) or rx.match(value):
+		if rx.fullmatch(value):
 			return True
 	return False
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def _matches_allowed_targets(self, value: str) -> bool:
"""Check if a target value matches any platform-supplied allowed_targets regex."""
for rx in self.allowed_targets:
if rx.fullmatch(value) or rx.match(value):
return True
def _matches_allowed_targets(self, value: str) -> bool:
"""Check if a target value matches any platform-supplied allowed_targets regex."""
for rx in self.allowed_targets:
if rx.fullmatch(value):
return True
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@secator/ai/guardrails.py` around lines 554 - 558, The target allow-list check
in _matches_allowed_targets is too permissive because rx.match() turns literal
entries into prefix matches; remove the fallback and rely on rx.fullmatch() only
so allow-list patterns remain exact. Keep the change localized to
_matches_allowed_targets in guardrails.py, since _check_value() already
normalizes URL targets into host and host:port before this comparison.

Comment thread secator/ai/guardrails.py
Comment on lines +729 to +735
# Platform-supplied allowed_targets (regex) allow-list — checked after deny
# (deny still wins) but before config/runtime allow rules.
if rule_type == "target" and self.allowed_targets:
for v in values_to_check:
if self._matches_allowed_targets(v):
return PermissionResult(decision="allow", reason=f"Allowed by mandate: target({v})")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

Reject out-of-scope targets before consulting other allow paths.

When allowed_targets is present and none of values_to_check match, this code falls through to config/runtime target(...) rules. In this file, that also means _check_values() can downgrade the miss into ask, and prompt_target() or the workspace auto-approve path in secator/tasks/ai.py can add a runtime allow for an out-of-scope target. The mandate needs an explicit deny on mismatch.

Suggested fix
 		# Platform-supplied allowed_targets (regex) allow-list — checked after deny
 		# (deny still wins) but before config/runtime allow rules.
 		if rule_type == "target" and self.allowed_targets:
 			for v in values_to_check:
 				if self._matches_allowed_targets(v):
 					return PermissionResult(decision="allow", reason=f"Allowed by mandate: target({v})")
+			return PermissionResult(
+				decision="deny",
+				reason=f"Outside allowed_targets mandate: target({value})",
+			)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Platform-supplied allowed_targets (regex) allow-list — checked after deny
# (deny still wins) but before config/runtime allow rules.
if rule_type == "target" and self.allowed_targets:
for v in values_to_check:
if self._matches_allowed_targets(v):
return PermissionResult(decision="allow", reason=f"Allowed by mandate: target({v})")
# Platform-supplied allowed_targets (regex) allow-list — checked after deny
# (deny still wins) but before config/runtime allow rules.
if rule_type == "target" and self.allowed_targets:
for v in values_to_check:
if self._matches_allowed_targets(v):
return PermissionResult(decision="allow", reason=f"Allowed by mandate: target({v})")
return PermissionResult(
decision="deny",
reason=f"Outside allowed_targets mandate: target({value})",
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@secator/ai/guardrails.py` around lines 729 - 735, The target allow-list
handling in `Guardrails._check_values()` currently only allows matching
`allowed_targets` but does not explicitly reject non-matching targets, letting
later config/runtime paths in `_check_values()`, `prompt_target()`, and the
workspace auto-approve flow in `secator/tasks/ai.py` override the mandate.
Update the `rule_type == "target"` branch to return a deny/forbid
`PermissionResult` when `allowed_targets` is set and none of `values_to_check`
match, so out-of-scope targets are blocked before any other allow path is
consulted.

Symmetric to allowed_targets: PermissionEngine now accepts a platform-set
denied_targets list (single/regex patterns). A target(...) value (or its URL
host / host:port) matching a denied_targets entry is DENIED, and deny takes
precedence over allowed_targets (a target matching both is denied), mirroring
the mandate scope matcher's deny-wins. Presence of denied_targets also forces
the target-check step on. The ai task gains an internal denied_targets opt that
flows to the engine.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P5vSjfkBuGAAHdKxHS3ySm
@ocervell
ocervell marked this pull request as draft July 8, 2026 08:37
@ocervell ocervell added the superseded Folded into a unified/newer PR; do not merge label Jul 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

superseded Folded into a unified/newer PR; do not merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant