Skip to content

fix: govern - wire audit_file to FileAuditSink so file-based audit persistence actually works - #3916

Merged
MohammadHaroonAbuomar merged 7 commits into
microsoft:mainfrom
fer-marino:fix/audit-file-persistence
Sep 17, 2026
Merged

MohammadHaroonAbuomar merged 7 commits into
microsoft:mainfrom
fer-marino:fix/audit-file-persistence

Conversation

@fer-marino

@fer-marino Fernando Marino` (fer-marino) commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

…rsistence actually works

GovernanceConfig.audit_file is documented ("Path for file-based audit log. None = in-memory only.") but GovernedCallable.init never read it: self._audit = AuditLog() if config.audit else None always built a sink-less AuditLog, so every audit trail was lost on process exit regardless of what the caller configured. The top-level govern() factory - the module's documented 2-line-integration entrypoint - didn't even expose audit_file as a parameter.

The library already has everything needed to make this work (FileAuditSink: hash-chained, HMAC-signed JSON-lines, and AuditLog(sink=...)) - govern() just never connected them.

Adds audit_secret_key alongside audit_file: FileAuditSink requires an HMAC key, which a bare path string has no way to supply. A missing key is auto-generated per instance so audit_file alone is still enough to get persistence; a caller who needs signatures to verify across process restarts can supply their own.

Fixes #3915.

Related Issue

If no related issue is linked above, you must complete "Problem & Solution", "Impact on Your Work", and "Alternatives Considered" below.

Problem & Solution

Impact on Your Work

Timeline

Alternatives Considered

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update
  • Maintenance (dependency updates, CI/CD, refactoring)
  • Security fix

Package(s) Affected

Core & runtime:

  • agent-governance-toolkit-core
  • agent-primitives
  • agent-os
  • agent-mesh
  • agent-runtime
  • agent-sre
  • agent-compliance

Governance & security:

  • agent-mcp-governance
  • agent-rag-governance
  • agent-sandbox
  • agent-discovery
  • agt-policies
  • policy-engine

Platform & tooling:

  • agent-hypervisor
  • agent-lightning
  • agent-marketplace
  • agent-governance-toolkit-cli
  • agent-governance-toolkit-integrations
  • agent-governance-toolkit-protocols
  • agentmesh-integrations (framework integrations)

CLI plugins:

  • agent-governance CLI plugins (copilot-cli / claude-code / opencode / antigravity-cli)

Shared / other:

  • schemas
  • action (GitHub Action)
  • examples
  • docs / root

Testing

Unit Testing

Manual Testing

Checklist

  • I have linked a related issue above, or completed "Problem & Solution", "Impact on Your Work", and "Alternatives Considered"
  • My code follows the project style guidelines (ruff check)
  • I have added tests that prove my fix/feature works
  • All new and existing tests pass (pytest)
  • I have updated documentation as needed
  • I have signed the Microsoft CLA

Attribution & Prior Art

  • This contribution does not contain code copied or derived from other projects without attribution
  • Any external projects that inspired this design are credited in code comments or documentation
  • If this PR implements functionality similar to an existing open-source project, I have listed it below

Prior art / related projects (if any):

AI Assistance

  • I can explain every meaningful change in this PR: what it does, why, and what tradeoffs were considered
  • I have run tests and verification appropriate for this change
  • No part of this PR was autonomously submitted by an AI agent without my review
  • I have not used AI to generate review comments on others' PRs

If AI tools materially shaped this change, briefly note what was used:

Claude (Anthropic) drafted this change end-to-end - found the bug by reading govern.py/audit.py/audit_backends.py, implemented the fix, wrote the tests, and ran them. Fernando reviewed this PR before it was submitted.

IP, Patents, and Licensing

  • This contribution does not implement patent-pending or patent-encumbered techniques
  • This contribution does not require an NDA or licensing agreement to understand or use
  • Any AI tools used have terms compatible with the MIT License

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
There may be pipelines that require an authorized user to comment /azp run to run.

@github-actions github-actions Bot added tests agent-mesh agent-mesh package size/M Medium PR (< 200 lines) labels Sep 9, 2026
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

PR Review Summary

Check Status Details
🔍 Code Review ⚠️ Missing No current-run comment
🛡️ Security Scan ⚠️ Missing No current-run comment
🔄 Breaking Changes ⚠️ Missing No current-run comment
📝 Docs Sync ⚠️ Missing No current-run comment
🧪 Test Coverage ⚠️ Missing No current-run comment

Verdict: ⚠️ AI review incomplete; ready for human review

AI review comments are untrusted advisory output. The summary reports workflow-generated completion status only, not model-authored pass/fail claims.

Comment thread agent-governance-python/agent-mesh/src/agentmesh/governance/govern.py Outdated
Comment thread agent-governance-python/agent-mesh/src/agentmesh/governance/govern.py Outdated
@github-actions github-actions Bot added size/L Large PR (< 500 lines) and removed size/M Medium PR (< 200 lines) labels Sep 10, 2026
@fer-marino Fernando Marino` (fer-marino) changed the title fix: govern - wire audit_file to FileAuditSink so file-based audit pe… fix: govern - wire audit_file to FileAuditSink so file-based audit persistence actually works Sep 10, 2026

@MohammadHaroonAbuomar MohammadHaroonAbuomar left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

  • govern.py (_get_shared_audit_sink) — Path(path).resolve() runs before the sink sees the path, so govern(audit_file=) writes into the symlink target and the sink's O_NOFOLLOW never fires (direct FileAuditSink() correctly refuses with ELOOP). Please check for a symlink before resolving (lstat) and refuse, keeping the realpath only as the registry key.
  • Path validation is still first-call: a nonexistent parent directory constructs fine and raises FileNotFoundError on the first governed call (fail-closed, but the ask was construction-time); a directory path fails only by accident via IsADirectoryError in _read_last_hash. Please validate the path (parent exists, not a directory, not a symlink) at construction next to the key check. Minor: after an external rotation (rename), a new govern() on the same path reuses the cached sink and the new file starts with a stale previous_hash (verify_integrity breaks at entry 0); and a second instance that omits the key reuses the first sink even when AGT_AUDIT_SECRET_KEY holds a different key.

@github-actions github-actions Bot added size/XL Extra large PR (500+ lines) and removed size/L Large PR (< 500 lines) labels Sep 10, 2026
@fer-marino

Copy link
Copy Markdown
Contributor Author

Fixed in 97e870e:

Symlink before resolve: _get_shared_audit_sink now lstats the raw path (Path.is_symlink()) and refuses before calling resolve(), so the check can't be silently defeated by resolve() already having followed the symlink away by the time a sink would see it. The realpath is still what keys the registry.

Construction-time path validation: FileAuditSink.init now checks (in order) that the path isn't a symlink, isn't an existing directory, and that its parent directory exists - raising ValueError/IsADirectoryError/FileNotFoundError at construction, next to the key check, instead of on the first write().

External rotation (minor): write()/write_batch() now compare (st_dev, st_ino) against what was last synced and re-derive previous_hash if the file's identity changed - covers both a rename-away (nothing left at that inode) and a same-path replace by another writer.

Env-var key mismatch on reuse (minor): the effective key (explicit arg, falling back to AGT_AUDIT_SECRET_KEY) is now resolved and checked against the existing sink on every reuse, not only when the caller passed secret_key explicitly - while a reuse with no key information available at all (no arg, no env var) still just reuses, unchanged from before.

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.

Reviewed current head 63afec3 and simulated its merge into current main cleanly. The two targeted files pass 74 tests both before and after that merge. The prior symlink, construction-time validation, registry, and rotation findings are materially improved, but adversarial checks found four fail-closed/integrity gaps noted inline. Ruff also reports 61 findings on the changed files versus 54 on current main, including newly unsorted imports; please clean the changed-line delta before the next pass.

Comment thread agent-governance-python/agent-mesh/src/agentmesh/governance/audit_backends.py Outdated
@fer-marino

Copy link
Copy Markdown
Contributor Author

Carlos Hernandez (@carloshvp) All four fixed in af43caf:

  • Chain resume now verifies the whole existing chain (HMAC + continuity) under the sink's key before trusting it, and fails closed rather than silently extending an unauthenticated/tampered file - both at construction and on external-rotation resync.
    • read_entries()/verify_integrity()/resume now share one helper that consistently skips unparsable lines, so a file that recovered from one crash doesn't stay permanently unreadable past that point.
    • _append_line now fchmods to 0o600 after opening - the mode argument to os.open only applies when the call actually creates the file.
    • audit_secret_key now rejects anything under 32 bytes (HMAC-SHA256's output size), for both an explicit key and one from AGT_AUDIT_SECRET_KEY.
      One existing test was asserting the vulnerable resume behavior directly; split it into same-key (still resyncs) and different-key (now fails closed) cases. New tests cover the other three. Also sorted the imports Ruff flagged as new - left the pre-existing style findings in these files alone, out of scope here.

@MohammadHaroonAbuomar MohammadHaroonAbuomar left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

  • Wiring audit_file into govern() makes AuditLog.log() able to raise from a real FileAuditSink, and the audit call inside GovernedCallable._run_advisory sits inside the try/except that converts any exception into AdvisoryDecision(action='allow'). An audit-sink write failure that first appears between the policy_evaluation write and the advisory_check write of the same call (ENOSPC at close, EPERM from fchmod, ELOOP from a planted symlink, or the sink's own fail-closed ValueError when the file was replaced by a chain that does not verify under the key) silently downgrades an advisory BLOCK to allow and the wrapped function executes. The deterministic policy path is fail-closed (the earlier write raises out of call), so this is confined to the advisory layer and to a one-call window, but it is a security control being bypassed on an error path introduced by this PR, and the fix is to move the audit_log call out of the fail-open try (or catch only advisory.check). File: agent-governance-python/agent-mesh/src/agentmesh/governance/govern.py:285-286 (sink wired), :733-760 (_run_advisory; audit log at :743-744 inside the try, except Exception -> allow at :758-759).

@fer-marino

Copy link
Copy Markdown
Contributor Author

MohammadHaroonAbuomar Fixed in 6775209: narrowed _run_advisory's try/except to wrap only advisory.check() - the audit_log() call now sits outside it, so it fails closed the same way the deterministic policy_evaluation write already does (unguarded, propagates out of __call__). Added a regression test (TestGovernAdvisoryAuditFailure) that fails against the old code and passes against the fix, verified both ways. Also added the cspell words this PR's own commits had already introduced (fchmod, ELOOP, serialisation, etc.) but never registered, since the same "Spell-check changed files" gate that just caught #3914 would hit this PR too once its fork workflows are approved to run.

@MohammadHaroonAbuomar MohammadHaroonAbuomar left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

  • DCO: commit 6775209 ('fix: address review — don't fail open on an audit-write failure during the advisory check') has no Signed-off-by trailer (verified via gh api .../pulls/3916/commits and a local replay of .github/workflows/dco.yml over 1a896e7..3733cef: MISS 6775209, OK for the five earlier commits; the merge 3733cef is exempt). Fix: git commit --amend -s --no-edit on that commit (or git rebase --signoff 591b3687) and force-push; the DCO Check run for this head is fork-gated (action_required) so it has not surfaced yet.
  • .cspell-repo-terms.txt: the Spell Check gate still fails at this head even though 6775209 set out to fix it. Replaying .github/workflows/spell-check.yml (scripts/ci/changed_lines.py --base origin/main --mode added-lines, then cspell@8.17.3 --config .cspell.json) exits 1 with unknown words EPERM (audit_backends.py _append_line docstring and govern.py _run_advisory docstring, tests/governance/test_audit_backends.py, tests/test_govern.py:667), fileno (audit_backends.py os.fchmod(fh.fileno(), 0o600)), fromhex (govern.py:114 bytes.fromhex(env_value)), fstat (tests/governance/test_audit_backends.py). None of the four is in .cspell-repo-terms.txt or .cspell.json on head or main. Fix: add EPERM, fileno, fromhex, fstat to the '# --- audit_backends.py / govern.py fail-closed-error comment terms (PR #3916) ---' block in .cspell-repo-terms.txt.
  • .cspell-repo-terms.txt: the conflict resolution in merge 3733cef re-appended two blocks main already contains ('# --- Documentation site and ACS integration terms ---' AgentRateLimiter..Vercel and '# --- cloud-board cryptography 50.0.0 audit terms ---' AESGCM..SPKI), leaving 26 duplicate entries (sort | uniq -d = 26 at head, 0 on origin/main; e.g. AESGCM at line 44 and again in the appended block). Fix: delete those two appended blocks (lines 1083-1111 of head's file) so only the PR's own 8-term block is added below main's list.

@fer-marino

Copy link
Copy Markdown
Contributor Author

Both fixed in af912a5.

DCO on 6775209: rebased with --signoff onto current main and force-pushed - that also picked up main's own drift since this branch's last merge cleanly.

The bigger one: the merge conflict resolution in 3733cef didn't just duplicate the two blocks you flagged via sort | uniq -d - it also silently dropped about 35 lines of terms that exist ONLY on main (African-regulatory: POPIA/NDPA/NFIU/CBN/BVN/NIN/..., TRACE/identity-chain, Flowise, Azure credential-pattern, and some misc test-code terms - all added by unrelated PRs merged after this branch forked). Merging this PR as it stood would have regressed the spell-check gate for every one of those words, not just left harmless duplicates. Rebuilt the file as origin/main's own content plus this PR's block (now with the four missing words: EPERM, fileno, fromhex, fstat), verified with the actual CI gate: scripts/ci/changed_lines.py --base origin/main --mode added-lines piped into cspell@8.17.3 --config .cspell.json exits 0, and sort | uniq -d on the result now matches main's own pre-existing baseline (44, unrelated to this PR) instead of the 26 extra duplicates.

@MohammadHaroonAbuomar MohammadHaroonAbuomar left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verified at af912a5: rebase --signoff left every tree unchanged, the spell-check gate replays clean with the four terms added, and the cspell file is main's content plus the PR's block with no duplicates. The advisory fail-open fix holds: the try wraps only advisory.check(), and the four real-sink failure probes raise instead of allowing.

@MohammadHaroonAbuomar

Copy link
Copy Markdown
Collaborator

Approved and ready, but the branch now conflicts with main after #3914 landed (both touch govern.py and the cspell terms). Please rebase onto current main and force-push; nothing else is needed.

@MohammadHaroonAbuomar

Copy link
Copy Markdown
Collaborator

Carlos Hernandez (@carloshvp) I re-verified your four items in code at af912a5 so the stale request can be cleared: (1) audit_backends.py:384-387 runs os.fchmod(fh.fileno(), 0o600) before every write, and a pre-existing 0644 file ends up 0600 after the first write and again after being widened between writes; (2) _read_last_hash (:429-450) verifies continuity and the HMAC of every existing entry under the sink's key at construction and on inode change, and a chain signed under another key makes write() raise with the file untouched; (3) unparsable lines are skipped consistently by read_entries, verify_file and resume through one helper (:222-247), so after a truncated line and a resumed write verify_integrity returns true and read_entries returns both real entries, with a test at test_audit_backends.py:268-287; (4) govern.py:82 and :107-118 enforce a 32-byte minimum on explicit and env-derived keys, so empty, 1-byte and 31-byte keys raise. Ruff on the changed files went from 60 findings on main to 51, with unsorted imports at zero. Two optional follow-ups, not blockers: the same key-length check on FileAuditSink.init for the direct API, and verify_file returning a skipped-line count. Dismissing your review on that basis; reopen if you disagree.

@MohammadHaroonAbuomar
MohammadHaroonAbuomar dismissed Carlos Hernandez (carloshvp)’s stale review September 15, 2026 17:45

All four items verified fixed in code at af912a5 (fchmod on every write, authenticated predecessor chain, consistent parse-skip with verified integrity, 32-byte minimum key); evidence in the PR comment above.

Fernando Marino` (fer-marino) added a commit to fer-marino/agent-governance-toolkit that referenced this pull request Sep 15, 2026
The added-lines cspell gate flags these against the current main after
rebasing feature/audit-file-persistence — same terms class as the
existing PR microsoft#3916 os-constant/fd-handling entries in this section.

Signed-off-by: Fernando Marino <fernando.marino85@gmail.com>

@MohammadHaroonAbuomar MohammadHaroonAbuomar left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

  • .cspell-repo-terms.txt — the rebase re-appended 30 lines at the tail (the "Documentation site and ACS integration terms" block and the "cloud-board cryptography 50.0.0 audit terms" block) that main already carries at lines 14-76, so the file now has duplicate entries. Please delete those two appended blocks and keep only this PR's own 12-word block (EPERM, fileno, fromhex, fstat and the rest are needed: cspell reports 36 hits on the added lines without them, 0 with them). Everything else is verified identical to the approved head: 26/26 fail-closed probes pass, Carlos's four items hold, 36 + 60 tests pass, ruff clean.

Fernando Marino` (fer-marino) added a commit to fer-marino/agent-governance-toolkit that referenced this pull request Sep 16, 2026
…olution

The rebase's conflict resolution on .cspell-repo-terms.txt kept both
sides of a tail conflict, but main already carries the "Documentation
site and ACS integration terms" and "cloud-board cryptography 50.0.0
audit terms" blocks earlier in the same alphabetized file - so the
merge duplicated 30 lines instead of deduping them. Removes the
duplicate; this PR's own PR-microsoft#3916 block is unaffected.

Per MohammadHaroonAbuomar's review: 36 cspell hits on the added lines
without this fix, 0 with it.

Signed-off-by: Fernando Marino <fernando.marino85@gmail.com>
@fer-marino

Copy link
Copy Markdown
Contributor Author

Fixed in 12b440d - the rebase's conflict resolution had kept both sides of a tail conflict in .cspell-repo-terms.txt, duplicating the "Documentation site and ACS integration terms" and "cloud-board cryptography 50.0.0 audit terms" blocks that main already carries earlier in the same alphabetized file. Removed the 30 duplicate lines; this PR's own 12-word block (EPERM, fileno, fromhex, fstat, etc.) is untouched. Re-ran the cspell added-lines gate (0 hits) and the full test_govern.py/test_audit_backends.py suite (81 passed) after the fix.

@MohammadHaroonAbuomar MohammadHaroonAbuomar left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verified at 12b440d: the 30 duplicated word-list lines are removed and the file adds only this PR's 12-word block on top of main; code is unchanged from the twice-approved head, 96 tests pass, fail-closed probes hold.

@MohammadHaroonAbuomar

Copy link
Copy Markdown
Collaborator

Approved and green, but this morning's merges put the branch in conflict with main again (the shared cspell term list). One more rebase onto current main and force-push, please; I will merge as soon as it lands.

…rsistence actually works

GovernanceConfig.audit_file is documented ("Path for file-based audit
log. None = in-memory only.") but GovernedCallable.__init__ never
read it: self._audit = AuditLog() if config.audit else None always
built a sink-less AuditLog, so every audit trail was lost on process
exit regardless of what the caller configured. The top-level govern()
factory - the module's documented 2-line-integration entrypoint -
didn't even expose audit_file as a parameter.

The library already has everything needed to make this work
(FileAuditSink: hash-chained, HMAC-signed JSON-lines, and
AuditLog(sink=...)) - govern() just never connected them.

Adds audit_secret_key alongside audit_file: FileAuditSink requires an
HMAC key, which a bare path string has no way to supply. A missing
key is auto-generated per instance so audit_file alone is still
enough to get persistence; a caller who needs signatures to verify
across process restarts can supply their own.

Fixes microsoft#3915.

Signed-off-by: Fernando Marino <fernando.marino85@gmail.com>
…plicit key

Per MohammadHaroonAbuomar's review:
- Two govern() calls pointed at the same audit_file each built their own
  FileAuditSink with an independent in-memory previous_hash. Interleaved
  writes desynced the chain and verify_integrity() reported a break that
  never happened from either writer's own perspective. Sinks are now
  shared per resolved path via a module-level registry.
- A missing audit_secret_key silently generated a random one per
  instance, which could never verify across a restart and, combined with
  the above, meant two instances sharing a file could each sign with a
  different key even in-process. The key is now required — pass it
  explicitly or set AGT_AUDIT_SECRET_KEY (hex-encoded) — rather than
  auto-generated.
- A corrupt trailing line (write interrupted by a crash) made
  FileAuditSink's constructor raise, permanently blocking every future
  append to that file. Chain resumption now skips back to the last line
  that parses instead, leaving the corrupt line in place for forensics.
- FileAuditSink wrote through open(path, "a"), which creates a
  world-readable 0644 file and follows a symlink planted at the
  configured path. Appends now use O_CREAT|O_NOFOLLOW with mode 0600.

Signed-off-by: Fernando Marino <fernando.marino85@gmail.com>
…onstruction-time path validation, rotation resync

Per MohammadHaroonAbuomar's follow-up review:
- _get_shared_audit_sink resolved the path before the sink ever saw it,
  so a symlinked audit_file was silently followed: by the time
  FileAuditSink opened it, the symlink component was already gone and
  its O_NOFOLLOW had nothing left to refuse. Now lstats (Path.is_symlink)
  and refuses before resolving, keeping the realpath only as the
  registry key.
- FileAuditSink's path was still only validated lazily, on the first
  write() (FileNotFoundError for a missing parent, an accidental
  IsADirectoryError for a directory). Now checked at construction, next
  to the existing secret-key validation.
- A file replaced out from under a long-lived sink (external log
  rotation, or another process) left the cached previous_hash pointing
  at a chain that no longer exists at that path; verify_integrity() on
  the replacement broke at its first entry. write()/write_batch() now
  detect the identity change (st_dev/st_ino) and resync before signing
  the next entry.
- A second govern() call omitting secret_key reused an existing shared
  sink unconditionally, even when AGT_AUDIT_SECRET_KEY now held a
  different key than the sink was actually signing with. The key is
  resolved (env var included) and compared on every reuse now, not only
  when the caller passed secret_key explicitly - while still allowing a
  reuse with no key information available at all, exactly as before.

Signed-off-by: Fernando Marino <fernando.marino85@gmail.com>
…, reject weak keys

Four issues from review, each a real gap in the audit trail's integrity
guarantees:

- FileAuditSink resumed an existing file's hash chain by reading its
  last content_hash without verifying it authenticated under this
  sink's key. A file swapped in by another process, or an attacker,
  with a different key got silently extended as if it were ours.
  _read_last_hash now verifies the whole existing chain (HMAC +
  continuity) before trusting it, and raises rather than resuming onto
  something unauthenticated - at construction and on external-rotation
  resync alike.

- A single unparsable line (crash mid-write) was already tolerated when
  resuming, but read_entries()/verify_integrity() still treated any
  such line anywhere as a hard failure, so a file that had recovered
  from one crash stayed permanently unreadable/unverifiable past that
  point. All three now share one _iter_parsed_entries helper that skips
  unparsable lines consistently - this doesn't weaken the chain, since
  a skipped line is simply excluded from it and a real entry swapped
  for garbage still breaks chain continuity at the next real one.

- os.open(path, O_CREAT, 0o600)'s mode only applies if the call creates
  the file; one that already existed at a looser mode kept it,
  silently, for every subsequent write of an entry that can carry call
  arguments. _append_line now fchmods to 0o600 after opening too.

- govern(audit_secret_key=...) accepted any non-None bytes, including
  b"" or one-byte keys, as an HMAC key. Now rejected below 32 bytes
  (HMAC-SHA256's output size), for both an explicit key and one read
  from AGT_AUDIT_SECRET_KEY.

Also sorted the imports ruff flagged as new noise on top of the
existing, unrelated style findings in these files (left alone - out of
scope here).

One existing test asserted the vulnerable resume behavior directly
(external replacement by a different-key file silently continuing the
chain); split it into same-key (still resyncs) and different-key
(now fails closed) cases. New tests cover the other three findings.

Signed-off-by: Fernando Marino <fernando.marino85@gmail.com>
…iling write

_append_line's fchmod ran between os.open and os.fdopen: if it raised
(EPERM, the file owned by another user) the fd from os.open was never
wrapped in anything that would close it, leaking one per failed write -
confirmed 5 failed writes -> 5 leaked fds. Moved inside the fdopen
block, on the file object's own descriptor, so a failing fchmod now
closes it via the same exception path a failing write already did.

A file this process can't chmod now fails every write instead of
silently keeping its existing, looser mode - documented in
_append_line's docstring alongside the already-documented one it can
chmod being silently tightened.

Signed-off-by: Fernando Marino <fernando.marino85@gmail.com>
…g the advisory check

_run_advisory's try/except was meant to fail open only when
advisory.check() itself fails (non-deterministic, defense-in-depth).
It also wrapped the audit log() call, so a failing write from the now
file-backed sink (ENOSPC, EPERM, ELOOP, a tamper-detected chain) looked
identical to the classifier failing and silently turned a BLOCK into
allow. Narrowed the try to advisory.check() only, matching the
deterministic policy_evaluation write a few lines up, which already
fails closed by being unguarded.

Also adds the words this and earlier commits introduced (ELOOP,
fchmod, serialisation, etc.) to the repo's cspell term list - the
'Spell-check changed files' gate would have failed on them once the
fork's workflows are approved to run, same class of failure flagged
on microsoft#3914.

Signed-off-by: Fernando Marino <fernando.marino85@gmail.com>
The added-lines cspell gate flags these against the current main after
rebasing feature/audit-file-persistence — same terms class as the
existing PR microsoft#3916 os-constant/fd-handling entries in this section.

Signed-off-by: Fernando Marino <fernando.marino85@gmail.com>

@MohammadHaroonAbuomar MohammadHaroonAbuomar left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verified at da2190a: rebase-only update; code files are byte-identical to the approved head and the word list adds only this PR's own block with no duplicates. Seven signed commits.

@MohammadHaroonAbuomar
MohammadHaroonAbuomar merged commit df6b2e1 into microsoft:main Sep 17, 2026
124 checks passed
Yuvraj Singh (yuvrajsingh2428) pushed a commit to yuvrajsingh2428/agent-governance-toolkit that referenced this pull request Oct 1, 2026
…rsistence actually works (microsoft#3916)

* fix: govern - wire audit_file to FileAuditSink so file-based audit persistence actually works

GovernanceConfig.audit_file is documented ("Path for file-based audit
log. None = in-memory only.") but GovernedCallable.__init__ never
read it: self._audit = AuditLog() if config.audit else None always
built a sink-less AuditLog, so every audit trail was lost on process
exit regardless of what the caller configured. The top-level govern()
factory - the module's documented 2-line-integration entrypoint -
didn't even expose audit_file as a parameter.

The library already has everything needed to make this work
(FileAuditSink: hash-chained, HMAC-signed JSON-lines, and
AuditLog(sink=...)) - govern() just never connected them.

Adds audit_secret_key alongside audit_file: FileAuditSink requires an
HMAC key, which a bare path string has no way to supply. A missing
key is auto-generated per instance so audit_file alone is still
enough to get persistence; a caller who needs signatures to verify
across process restarts can supply their own.

Fixes microsoft#3915.

Signed-off-by: Fernando Marino <fernando.marino85@gmail.com>

* fix: address review — share one FileAuditSink per path, require an explicit key

Per MohammadHaroonAbuomar's review:
- Two govern() calls pointed at the same audit_file each built their own
  FileAuditSink with an independent in-memory previous_hash. Interleaved
  writes desynced the chain and verify_integrity() reported a break that
  never happened from either writer's own perspective. Sinks are now
  shared per resolved path via a module-level registry.
- A missing audit_secret_key silently generated a random one per
  instance, which could never verify across a restart and, combined with
  the above, meant two instances sharing a file could each sign with a
  different key even in-process. The key is now required — pass it
  explicitly or set AGT_AUDIT_SECRET_KEY (hex-encoded) — rather than
  auto-generated.
- A corrupt trailing line (write interrupted by a crash) made
  FileAuditSink's constructor raise, permanently blocking every future
  append to that file. Chain resumption now skips back to the last line
  that parses instead, leaving the corrupt line in place for forensics.
- FileAuditSink wrote through open(path, "a"), which creates a
  world-readable 0644 file and follows a symlink planted at the
  configured path. Appends now use O_CREAT|O_NOFOLLOW with mode 0600.

Signed-off-by: Fernando Marino <fernando.marino85@gmail.com>

* fix: address second round of review — symlink check before resolve, construction-time path validation, rotation resync

Per MohammadHaroonAbuomar's follow-up review:
- _get_shared_audit_sink resolved the path before the sink ever saw it,
  so a symlinked audit_file was silently followed: by the time
  FileAuditSink opened it, the symlink component was already gone and
  its O_NOFOLLOW had nothing left to refuse. Now lstats (Path.is_symlink)
  and refuses before resolving, keeping the realpath only as the
  registry key.
- FileAuditSink's path was still only validated lazily, on the first
  write() (FileNotFoundError for a missing parent, an accidental
  IsADirectoryError for a directory). Now checked at construction, next
  to the existing secret-key validation.
- A file replaced out from under a long-lived sink (external log
  rotation, or another process) left the cached previous_hash pointing
  at a chain that no longer exists at that path; verify_integrity() on
  the replacement broke at its first entry. write()/write_batch() now
  detect the identity change (st_dev/st_ino) and resync before signing
  the next entry.
- A second govern() call omitting secret_key reused an existing shared
  sink unconditionally, even when AGT_AUDIT_SECRET_KEY now held a
  different key than the sink was actually signing with. The key is
  resolved (env var included) and compared on every reuse now, not only
  when the caller passed secret_key explicitly - while still allowing a
  reuse with no key information available at all, exactly as before.

Signed-off-by: Fernando Marino <fernando.marino85@gmail.com>

* fix: address review — fail closed on chain resume, fix permission gap, reject weak keys

Four issues from review, each a real gap in the audit trail's integrity
guarantees:

- FileAuditSink resumed an existing file's hash chain by reading its
  last content_hash without verifying it authenticated under this
  sink's key. A file swapped in by another process, or an attacker,
  with a different key got silently extended as if it were ours.
  _read_last_hash now verifies the whole existing chain (HMAC +
  continuity) before trusting it, and raises rather than resuming onto
  something unauthenticated - at construction and on external-rotation
  resync alike.

- A single unparsable line (crash mid-write) was already tolerated when
  resuming, but read_entries()/verify_integrity() still treated any
  such line anywhere as a hard failure, so a file that had recovered
  from one crash stayed permanently unreadable/unverifiable past that
  point. All three now share one _iter_parsed_entries helper that skips
  unparsable lines consistently - this doesn't weaken the chain, since
  a skipped line is simply excluded from it and a real entry swapped
  for garbage still breaks chain continuity at the next real one.

- os.open(path, O_CREAT, 0o600)'s mode only applies if the call creates
  the file; one that already existed at a looser mode kept it,
  silently, for every subsequent write of an entry that can carry call
  arguments. _append_line now fchmods to 0o600 after opening too.

- govern(audit_secret_key=...) accepted any non-None bytes, including
  b"" or one-byte keys, as an HMAC key. Now rejected below 32 bytes
  (HMAC-SHA256's output size), for both an explicit key and one read
  from AGT_AUDIT_SECRET_KEY.

Also sorted the imports ruff flagged as new noise on top of the
existing, unrelated style findings in these files (left alone - out of
scope here).

One existing test asserted the vulnerable resume behavior directly
(external replacement by a different-key file silently continuing the
chain); split it into same-key (still resyncs) and different-key
(now fails closed) cases. New tests cover the other three findings.

Signed-off-by: Fernando Marino <fernando.marino85@gmail.com>

* fix: address review — close the fd on a failing fchmod, not just a failing write

_append_line's fchmod ran between os.open and os.fdopen: if it raised
(EPERM, the file owned by another user) the fd from os.open was never
wrapped in anything that would close it, leaking one per failed write -
confirmed 5 failed writes -> 5 leaked fds. Moved inside the fdopen
block, on the file object's own descriptor, so a failing fchmod now
closes it via the same exception path a failing write already did.

A file this process can't chmod now fails every write instead of
silently keeping its existing, looser mode - documented in
_append_line's docstring alongside the already-documented one it can
chmod being silently tightened.

Signed-off-by: Fernando Marino <fernando.marino85@gmail.com>

* fix: address review — don't fail open on an audit-write failure during the advisory check

_run_advisory's try/except was meant to fail open only when
advisory.check() itself fails (non-deterministic, defense-in-depth).
It also wrapped the audit log() call, so a failing write from the now
file-backed sink (ENOSPC, EPERM, ELOOP, a tamper-detected chain) looked
identical to the classifier failing and silently turned a BLOCK into
allow. Narrowed the try to advisory.check() only, matching the
deterministic policy_evaluation write a few lines up, which already
fails closed by being unguarded.

Also adds the words this and earlier commits introduced (ELOOP,
fchmod, serialisation, etc.) to the repo's cspell term list - the
'Spell-check changed files' gate would have failed on them once the
fork's workflows are approved to run, same class of failure flagged
on microsoft#3914.

Signed-off-by: Fernando Marino <fernando.marino85@gmail.com>

* chore: add EPERM/fileno/fromhex/fstat to cspell wordlist

The added-lines cspell gate flags these against the current main after
rebasing feature/audit-file-persistence — same terms class as the
existing PR microsoft#3916 os-constant/fd-handling entries in this section.

Signed-off-by: Fernando Marino <fernando.marino85@gmail.com>

---------

Signed-off-by: Fernando Marino <fernando.marino85@gmail.com>
Signed-off-by: yuvrajsingh2428 <offcyuvi2428@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent-mesh agent-mesh package size/XL Extra large PR (500+ lines) tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: govern() config.audit_file is documented but never wired to AuditLog - audit is always in-memory only

3 participants