Skip to content

Commit 513ebc9

Browse files
author
bgagent
committed
fix(agent): address review findings — push -f gap, UserMessage handling, doc notes
- Add Cedar deny pattern for bare `git push -f` (no trailing args) - Track UserMessage in message_counts, log string content - Extract _format_tool_result helper to deduplicate ToolResultBlock formatting - Parametrize quote-handling tests - Document sentinel resource ID constraint for custom Cedar policies in policy.py module docstring and SECURITY.md
1 parent 722bf04 commit 513ebc9

4 files changed

Lines changed: 69 additions & 43 deletions

File tree

agent/src/policy.py

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,27 @@
22
33
Uses cedarpy (in-process Cedar evaluation) to enforce per-task-type
44
tool restrictions. No network calls, no AWS Verified Permissions.
5+
6+
Custom Cedar policies (via Blueprint ``security.cedarPolicies``) must
7+
use ``context`` conditions in ``when`` clauses — not ``resource ==``
8+
matching — for ``write_file`` and ``execute_bash`` actions. The engine
9+
passes fixed sentinel resource IDs (``Agent::File::"file"``,
10+
``Agent::BashCommand::"command"``) because Cedar entity UIDs cannot
11+
contain the special characters found in file paths and bash commands.
12+
The actual values are available in ``context.file_path`` and
13+
``context.command`` respectively. For ``invoke_tool`` actions, the
14+
resource ID is the real tool name (e.g. ``Agent::Tool::"Write"``), so
15+
``resource ==`` matching works normally there.
16+
17+
Example — correct custom policy::
18+
19+
forbid (principal, action == Agent::Action::"execute_bash", resource)
20+
when { context.command like "*curl*" };
21+
22+
Example — WILL NOT WORK (resource is always ``"command"``)::
23+
24+
forbid (principal, action == Agent::Action::"execute_bash",
25+
resource == Agent::BashCommand::"curl http://evil.com");
526
"""
627

728
import time
@@ -39,6 +60,8 @@
3960
when { context.command like "*git push --force*" };
4061
forbid (principal, action == Agent::Action::"execute_bash", resource)
4162
when { context.command like "*git push -f *" };
63+
forbid (principal, action == Agent::Action::"execute_bash", resource)
64+
when { context.command like "*git push -f" };
4265
"""
4366

4467

@@ -200,39 +223,32 @@ def evaluate_tool_use(self, tool_name: str, tool_input: dict) -> PolicyDecision:
200223
)
201224
return PolicyDecision(allowed=False, reason=reason, duration_ms=elapsed)
202225

203-
# Additional checks for Write/Edit: check file path.
204-
# Use a fixed sentinel resource_id ("file") because file paths
205-
# may contain quotes/special chars that break Cedar UID parsing.
206-
# The actual path is in context.file_path where policies match it.
226+
# Write/Edit: check file path against write_file policies.
227+
# Sentinel resource_id avoids Cedar UID parsing issues (see _evaluate docstring).
207228
if tool_name in ("Write", "Edit"):
208229
file_path = tool_input.get("file_path", "")
209230
if file_path:
210-
file_context = {**base_context, "file_path": file_path}
211231
allowed, deny_reason = self._evaluate(
212232
"write_file",
213233
"Agent::File",
214234
"file",
215-
file_context,
235+
{**base_context, "file_path": file_path},
216236
)
217237
if not allowed:
218238
elapsed = (time.monotonic() - start) * 1000
219239
reason = deny_reason or f"Cedar policy denied write to {file_path}"
220240
return PolicyDecision(allowed=False, reason=reason, duration_ms=elapsed)
221241

222-
# Additional checks for Bash: check command.
223-
# Use a fixed sentinel resource_id ("command") because bash
224-
# commands contain quotes/special chars that break Cedar UID
225-
# parsing. The actual command is in context.command where
226-
# policies match it.
242+
# Bash: check command against execute_bash policies.
243+
# Sentinel resource_id avoids Cedar UID parsing issues (see _evaluate docstring).
227244
if tool_name == "Bash":
228245
command = tool_input.get("command", "")
229246
if command:
230-
bash_context = {**base_context, "command": command}
231247
allowed, deny_reason = self._evaluate(
232248
"execute_bash",
233249
"Agent::BashCommand",
234250
"command",
235-
bash_context,
251+
{**base_context, "command": command},
236252
)
237253
if not allowed:
238254
elapsed = (time.monotonic() - start) * 1000

agent/src/runner.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@
99
from telemetry import _TrajectoryWriter
1010

1111

12+
def _format_tool_result(block) -> tuple[str, str]:
13+
"""Extract status label and content string from a ToolResultBlock."""
14+
status = "ERROR" if block.is_error else "ok"
15+
content = block.content if isinstance(block.content, str) else str(block.content)
16+
return status, content
17+
18+
1219
def _setup_agent_env(config: dict) -> tuple[str | None, str | None]:
1320
"""Configure process environment for the Claude Code CLI subprocess.
1421
@@ -139,6 +146,7 @@ async def run_agent(
139146
ThinkingBlock,
140147
ToolResultBlock,
141148
ToolUseBlock,
149+
UserMessage,
142150
)
143151

144152
_setup_agent_env(config)
@@ -257,10 +265,7 @@ def _on_stderr(line: str) -> None:
257265
log("TOOL", f"{block.name}: {truncate(str(tool_input))}")
258266
turn_tool_calls.append({"name": block.name, "input": tool_input})
259267
elif isinstance(block, ToolResultBlock):
260-
status = "ERROR" if block.is_error else "ok"
261-
content = (
262-
block.content if isinstance(block.content, str) else str(block.content)
263-
)
268+
status, content = _format_tool_result(block)
264269
log("RESULT", f"[{status}] {truncate(content)}")
265270
turn_tool_results.append(
266271
{
@@ -352,6 +357,19 @@ def _on_stderr(line: str) -> None:
352357
usage=usage_dict,
353358
)
354359

360+
elif isinstance(message, UserMessage):
361+
message_counts["other"] += 1
362+
# UserMessage carries tool results fed back to the model.
363+
# For hook-denied calls, content is a ToolResultBlock with
364+
# is_error=True and the denial reason.
365+
if isinstance(message.content, list):
366+
for block in message.content:
367+
if isinstance(block, ToolResultBlock):
368+
status, content = _format_tool_result(block)
369+
log("RESULT", f"[{status}] {truncate(content)}")
370+
elif isinstance(message.content, str):
371+
log("USER", truncate(message.content))
372+
355373
else:
356374
message_counts["other"] += 1
357375
log(

agent/tests/test_policy.py

Lines changed: 17 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,11 @@ def test_denies_git_push_f(self):
123123
result = engine.evaluate_tool_use("Bash", {"command": "git push -f origin main"})
124124
assert result.allowed is False
125125

126+
def test_denies_git_push_f_no_trailing_args(self):
127+
engine = PolicyEngine(task_type="new_task", repo="owner/repo")
128+
result = engine.evaluate_tool_use("Bash", {"command": "git push -f"})
129+
assert result.allowed is False
130+
126131
def test_allows_normal_bash(self):
127132
engine = PolicyEngine(task_type="new_task", repo="owner/repo")
128133
result = engine.evaluate_tool_use("Bash", {"command": "npm test"})
@@ -137,32 +142,19 @@ def test_allows_mise_run_build(self):
137142
class TestBashCommandsWithQuotes:
138143
"""Commands containing double quotes must not cause NoDecision."""
139144

140-
def test_allows_git_commit_with_message(self):
141-
engine = PolicyEngine(task_type="new_task", repo="owner/repo")
142-
result = engine.evaluate_tool_use("Bash", {"command": 'git commit -m "fix: login bug"'})
143-
assert result.allowed is True
144-
145-
def test_allows_git_commit_tree(self):
146-
engine = PolicyEngine(task_type="new_task", repo="owner/repo")
147-
cmd = 'git commit-tree HEAD^{tree} -m "squash"'
148-
result = engine.evaluate_tool_use("Bash", {"command": cmd})
149-
assert result.allowed is True
150-
151-
def test_allows_gh_pr_create(self):
152-
engine = PolicyEngine(task_type="new_task", repo="owner/repo")
153-
cmd = 'gh pr create --title "my PR" --body "desc"'
154-
result = engine.evaluate_tool_use("Bash", {"command": cmd})
155-
assert result.allowed is True
156-
157-
def test_allows_gh_api_post(self):
158-
engine = PolicyEngine(task_type="new_task", repo="owner/repo")
159-
cmd = 'gh api --method POST /repos/o/r/pulls -f title="PR"'
160-
result = engine.evaluate_tool_use("Bash", {"command": cmd})
161-
assert result.allowed is True
162-
163-
def test_allows_heredoc_commit(self):
145+
@pytest.mark.parametrize(
146+
"cmd",
147+
[
148+
'git commit -m "fix: login bug"',
149+
'git commit-tree HEAD^{tree} -m "squash"',
150+
'gh pr create --title "my PR" --body "desc"',
151+
'gh api --method POST /repos/o/r/pulls -f title="PR"',
152+
"git commit -m \"$(cat <<'EOF'\nFix the bug\nEOF\n)\"",
153+
],
154+
ids=["git-commit-msg", "git-commit-tree", "gh-pr-create", "gh-api-post", "heredoc-commit"],
155+
)
156+
def test_allows_command_with_quotes(self, cmd):
164157
engine = PolicyEngine(task_type="new_task", repo="owner/repo")
165-
cmd = "git commit -m \"$(cat <<'EOF'\nFix the bug\nEOF\n)\""
166158
result = engine.evaluate_tool_use("Bash", {"command": cmd})
167159
assert result.allowed is True
168160

docs/design/SECURITY.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ AgentCore Memory has **no native backup mechanism**. This is a significant gap f
257257
- **No customer-managed KMS** — all encryption at rest uses AWS-managed keys. Customer-managed KMS can be added if required by compliance policy.
258258
- **CORS is fully open**`ALL_ORIGINS` is configured for CLI consumption. Restrict origins when exposing browser clients.
259259
- **DNS Firewall IP bypass** — DNS Firewall does not block direct IP connections (see [NETWORK_ARCHITECTURE.md](./NETWORK_ARCHITECTURE.md#dns-firewall)).
260-
- **Partial tool access control** — Cedar-based policy enforcement (`agent/src/policy.py`) provides per-task-type tool restrictions (e.g. `pr_review` agents cannot use `Write`/`Edit`), path-based write protection, and destructive command blocking. Per-repo custom Cedar policies are supported via Blueprint `security.cedarPolicies`. Full tiered tool access (capability tiers, MCP server allowlisting) is planned for Iteration 5.
260+
- **Partial tool access control** — Cedar-based policy enforcement (`agent/src/policy.py`) provides per-task-type tool restrictions (e.g. `pr_review` agents cannot use `Write`/`Edit`), path-based write protection, and destructive command blocking. Per-repo custom Cedar policies are supported via Blueprint `security.cedarPolicies`. **Important:** custom policies for `write_file` and `execute_bash` actions must use `context.file_path` / `context.command` in `when` clauses — not `resource ==` matching — because the engine uses fixed sentinel resource IDs to avoid Cedar entity UID parsing failures on special characters. `invoke_tool` actions use the real tool name as resource ID, so `resource ==` matching works for tool-level policies. Full tiered tool access (capability tiers, MCP server allowlisting) is planned for Iteration 5.
261261

262262
## Reference
263263

0 commit comments

Comments
 (0)