Skip to content

Add permission audit trail — policy-based tool call checking#13

Merged
Siddhant-K-code merged 6 commits intomainfrom
feature/audit-trail
Mar 28, 2026
Merged

Add permission audit trail — policy-based tool call checking#13
Siddhant-K-code merged 6 commits intomainfrom
feature/audit-trail

Conversation

@Siddhant-K-code
Copy link
Copy Markdown
Owner

Closes #7

What

Checks every tool_call in a session against a .agent-scope.json policy file. Reports allowed actions, policy violations, and auto-flags sensitive file access — even without a policy file.

Changes

audit.py (new):

  • Policy — loaded from .agent-scope.json via fnmatch glob patterns
  • audit_session(store, session_id, policy_path) — classifies each tool_call as allowed, denied, or no_policy
  • Three check types: file read/write (glob patterns), command execution (prefix + glob allow/deny), network access (URL host extraction + deny_all with allow exceptions)
  • SENSITIVE_PATTERNS — 18 patterns auto-flagged regardless of policy (.env, *.pem, .ssh/*, .github/workflows/*, etc.)
  • format_audit() — structured report with ✅ / ⚠️ / ❌ / 🔐 sections
  • Exits with code 1 when violations found (CI-friendly)

Policy file format (.agent-scope.json):

{
  "files": {
    "read":  { "allow": ["src/**", "tests/**"], "deny": [".env"] },
    "write": { "allow": ["src/**"], "deny": [".github/**"] }
  },
  "commands": {
    "allow": ["pytest", "uv run", "cat"],
    "deny":  ["curl", "wget", "rm -rf"]
  },
  "network": { "deny_all": true, "allow": ["localhost"] }
}

CLI:

  • agent-strace audit [session-id] [--policy <path>]

Example output

AUDIT: Session abc123 (47 events, 23 tool calls)

✅ Allowed (19):
  Read src/auth.py
  Ran: uv run pytest

⚠️  No policy (2):
  Read README.md  (no file read policy for this path)

❌ Violations (2):
  Read .env  ← denied by files.read.deny
  Ran: curl https://example.com  ← denied by commands.deny

🔐 Sensitive files accessed (1):
  Read .env  (event #12)

Tests

26 new tests (198 total passing)

Siddhant-K-code and others added 3 commits March 25, 2026 06:15
- audit.py: checks every tool_call against .agent-scope.json policy
  (file read/write glob patterns, command allow/deny list, network
  deny_all with allow exceptions via fnmatch)
- Auto-flags sensitive files (.env, *.pem, .ssh/*, .github/workflows/*)
  even without a policy file
- Exits with code 1 when violations found (CI-friendly)
- 26 new tests (198 total passing)

Co-authored-by: Ona <no-reply@ona.com>
- _cmd_matches: prefix match now requires space or end-of-string after
  the pattern, preventing 'curl' from matching 'curling --help'
- _audit_event: use 'or' chaining instead of str(args.get(..., ...))
  to avoid str(None) == 'None' passing the path guard

Co-authored-by: Ona <no-reply@ona.com>
- Policy.load: distinguish json.JSONDecodeError from OSError and write
  a warning to stderr for both. Previously a malformed policy was
  silently treated as missing, giving no indication to the user.
- _audit_event generic branch: clarify reason string to explain that
  no policy rule covers arbitrary tool types, not that no policy file
  exists.

Co-authored-by: Ona <no-reply@ona.com>
@Siddhant-K-code
Copy link
Copy Markdown
Owner Author

Review: permission audit trail

Four issues found. Two were already fixed upstream in 491d2b6; two fixed in 252a6ab.


Bug 1 — _cmd_matches: prefix match had no word-boundary check (fixed in 491d2b6)

if cmd_lower.startswith(pat_lower):  # "curl" matched "curling --help"
    return True

curl in the deny list would match curling --help. Fixed upstream to require a space or end-of-string after the pattern prefix.


Bug 2 — _audit_event: str(None) bypassed path guard (fixed in 491d2b6)

path = str(args.get("file_path", args.get("path", "")))
# args.get("file_path") returns None → str(None) == "None" → truthy

If file_path key exists with value None, str(None) produces "None" which passes the if path: guard and gets audited as a file path. Fixed upstream with or chaining.


Bug 3 — Policy.load: malformed JSON silently treated as missing (fixed in 252a6ab)

except (json.JSONDecodeError, OSError):
    return None  # no indication to the user

A typo in .agent-scope.json would silently disable the policy. Fixed to write a warning to stderr for both error types.


Design note — generic tool fallback always no_policy (clarified in 252a6ab)

MCP tools, Agent, TodoWrite, etc. always get no_policy even when a policy file is loaded. This is by design (no policy rule type covers arbitrary tools), but the reason string was misleading. Updated to "no policy rule for this tool type" to distinguish from the no-file case.


Remaining design note — *secret* in SENSITIVE_PATTERNS

This pattern will match src/secret_manager.py, lib/secrets_util.go, etc. — likely false positives. Consider tightening to *secrets* (plural, matching common config file names) or restricting to specific extensions/paths. Not a blocker.


No remaining bugs. ✅ Ready to merge.

@Siddhant-K-code Siddhant-K-code marked this pull request as ready for review March 28, 2026 15:50
# Matching helpers
# ---------------------------------------------------------------------------

def _glob_match(path: str, patterns: list[str]) -> bool:
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Bug: fnmatch does not treat ** as a recursive glob — src/** will not match src/auth.py.

fnmatch.fnmatch uses shell-style patterns where * matches everything except /. So fnmatch.fnmatch("src/auth.py", "src/**") returns False because ** expands to two * wildcards, neither of which matches across path separators.

The test test_glob_pattern asserts _glob_match("src/auth.py", ["src/**"]) is True — this test will fail when run. The filename fallback (fnmatch.fnmatch(name, pat)) doesn't help here because name is "auth.py" and the pattern is "src/**".

Fix options:

  1. Use pathlib.PurePath.match() which does support ** as a recursive wildcard: Path(path).match(pat)
  2. Or normalise patterns: replace ** with * and accept that src/* only matches one level deep (document this limitation)

pathlib.PurePath.match is stdlib and zero-dependency — recommended.

args = data.get("arguments", {}) or {}

# --- File read ---
if tool_name in ("read", "view"):
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Logic gap: tool_name == "view" is handled as a file read, but tool_name == "glob" and tool_name == "grep" are not.

Glob and Grep both read the filesystem (directory listings and file contents respectively) and are used by Claude Code. They fall through to the generic else branch and get no_policy regardless of any file read policy. A user who sets files.read.deny: [".env"] would not catch Grep .env or Glob .env*.

At minimum, grep should be treated as a file read (using args.get("pattern") or args.get("path")). glob is trickier since it matches multiple files, but flagging the pattern against deny rules is better than ignoring it.

))

# --- Bash / command execution ---
elif tool_name == "bash":
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

When a Bash command contains a URL that is denied by network policy, an AuditEntry for the network violation is added, and then the command itself is also evaluated against cmd_allow/cmd_deny. If curl is in cmd_deny, the same command produces two denied entries — one for Network access example.com and one for Ran: curl .... This double-counting inflates the violation count and makes the audit report confusing. Consider skipping the command policy check when a network violation has already been recorded for the same event, or document that this is intentional.

# Sensitive file patterns (auto-flagged regardless of policy)
# ---------------------------------------------------------------------------

SENSITIVE_PATTERNS: list[str] = [
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

*secret* will match src/secret_manager.py, test_secrets.py, no_secrets_here.py etc. — any file with "secret" anywhere in the name. This will produce a lot of false positives in normal codebases. Consider tightening to secrets.py, *secrets.json, *secrets.yaml or similar, and document the pattern rationale.

@Siddhant-K-code
Copy link
Copy Markdown
Owner Author

Review: PR #13 — Permission audit trail

The overall design is solid: policy loading is defensive (malformed JSON → warning, not crash), the AuditReport properties make filtering clean, and the CI-friendly exit code 1 on violations is the right call. The sensitive-pattern auto-flagging without requiring a policy file is a good UX choice.

One blocking bug (line 131): fnmatch doesn't support ** as a recursive glob. src/** will not match src/auth.py. The test test_glob_pattern will fail when run. Fix with pathlib.PurePath.match().

Two logic gaps (lines 193, 233):

  1. Glob and Grep tool calls bypass file read policy entirely — they fall to the generic no_policy branch
  2. A curl command with a denied URL produces two denied entries (network + command) — double-counting

One nit (line 26): *secret* is too broad and will false-positive on secret_manager.py, test files, etc.

Test gaps:

  • test_glob_pattern will fail once the fnmatch/** bug is fixed — the test is correct, the implementation is wrong
  • No test for Grep or Glob tool calls against a file read policy
  • No test for the double-entry case (URL in a denied command)
  • No test for a malformed .agent-scope.json (should warn to stderr and continue with no policy)
  • No test for Policy.load with an unreadable file (OSError path)

What's good:

  • _cmd_matches prefix logic correctly requires a word boundary — curl won't match curling (and there's a test for it)
  • _extract_urls strips ports before host comparison — localhost:8080 correctly matches localhost in the allow list
  • Policy.load returns None on missing file rather than raising — callers handle the no-policy case uniformly
  • format_audit caps the allowed/no-policy lists at 20 entries — avoids flooding output for large sessions
  • The AuditReport properties (allowed, denied, no_policy, sensitive_accesses) make the report easy to consume programmatically

Siddhant-K-code and others added 3 commits March 28, 2026 16:07
…ensitive patterns

audit.py:
- _glob_match: replace fnmatch with pathlib.PurePath.match() so src/**
  correctly matches src/auth.py and src/utils/path.py (fnmatch treats **
  as two * wildcards and does not match across path separators)
- Add Grep and Glob to the file-read branch so file read policy applies
  to directory listings and content searches, not just Read/View calls
- Network+command double-entry: skip command policy check when a network
  violation has already been recorded for the same event
- SENSITIVE_PATTERNS: remove *secret* (too broad — matches secret_manager.py,
  test_secrets.py, etc.); replace with explicit secrets.json/yaml/toml/yml

tests:
- test_glob_pattern_nested: src/utils/path.py matches src/**
- test_secret_manager_not_sensitive: source files no longer false-positive
- test_secrets_json/yaml_is_sensitive: explicit secrets files still caught
- TestGrepGlobPolicy: Grep denied path, Glob allowed path, Grep sensitive flag
- TestNetworkCommandDoubleEntry: curl to denied URL = 1 entry not 2;
  curl to allowed host still checked against command policy
- TestMalformedPolicy: malformed JSON returns None, audit continues as no-policy

Co-authored-by: Ona <no-reply@ona.com>
PurePath.match() with ** is unreliable across Python 3.10-3.13 (the
semantics changed in 3.12). Replace with a recursive segment-by-segment
matcher (_match_parts) that correctly handles ** as zero-or-more path
components on all supported versions.

src/**  now matches src/auth.py, src/utils/path.py, src/a/b/c/deep.py.

Co-authored-by: Ona <no-reply@ona.com>
@Siddhant-K-code Siddhant-K-code merged commit 06e10bf into main Mar 28, 2026
4 checks passed
@Siddhant-K-code Siddhant-K-code deleted the feature/audit-trail branch March 28, 2026 17:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

v0.7.0: Permission audit trail

1 participant