Skip to content

feat: per-turn hook audit log (#280 mitigation 3) - #314

Merged
robotrocketscience merged 1 commit into
mainfrom
feat/issue-280-hook-audit-log
Apr 29, 2026
Merged

feat: per-turn hook audit log (#280 mitigation 3)#314
robotrocketscience merged 1 commit into
mainfrom
feat/issue-280-hook-audit-log

Conversation

@robotrocketscience

@robotrocketscience robotrocketscience commented Apr 29, 2026

Copy link
Copy Markdown
Owner

Summary

Adds the per-turn audit log called for in docs/hook_hardening.md (#280 mitigation 3) — a JSONL sibling of the existing hook_telemetry.jsonl that records the exact rendered hook block on every fire. Telemetry records counts; audit records what was actually injected.

  • New file: <git-common-dir>/aelfrice/hook_audit.jsonl.
  • Wired into both user_prompt_submit and session_start.
  • Default-on. Opt-out via AELFRICE_HOOK_AUDIT=0 env or [hook_audit] enabled = false in .aelfrice.toml.
  • 10 MB cap (configurable: [hook_audit] max_bytes); single-slot rotation to hook_audit.jsonl.1 on rollover.
  • Fail-soft: any I/O error traces to stderr and is swallowed; the hook still emits its output block.

Spec references in this repo:

  • docs/hook_hardening.md — full design.
  • docs/hook_hardening.md#3-per-turn-audit-log — record schema (matches what this PR implements).

Mitigations 1 and 2 from the same spec (framing-tag contract + render-time belief-content escape) already shipped via #292; this PR closes the third and final mitigation.

Test plan

  • 21 new tests in tests/test_hook_audit.py covering: default config, env disable, TOML disable, max_bytes override, malformed/wrong-typed TOML graceful degradation, UserPromptSubmit + SessionStart write paths, n_locked / session_id / prompt_prefix capping, no-op when disabled, no-op when retrieval is empty, rotation at threshold, single-slot rotation overwriting prior .1, read API + corruption handling, fail-soft on unwriteable path.
  • Full pytest suite local: 1947 passed, 14 skipped.
  • Discretion grep on diff: clean.

Summary by Sourcery

Introduce a per-turn hook audit log that records rendered hook blocks for user prompt submissions and session starts, with configurable opt-out and size-based rotation.

New Features:

  • Add a JSONL-based hook audit log alongside existing telemetry that captures rendered hook blocks and contextual metadata per hook invocation.
  • Expose a read API for the hook audit log to load parsed audit records from disk.

Enhancements:

  • Wire audit logging into user_prompt_submit and session_start hooks, including best-effort extraction of session IDs and retrieval of baseline hits for count reporting.
  • Add configuration resolution for hook audit behavior via environment variable and .aelfrice.toml, including a configurable file size cap with single-slot rotation and safe fallbacks on malformed config.
  • Refactor baseline retrieval to return both hits and rendered blocks to support shared use by session_start and auditing logic.

Tests:

  • Add comprehensive tests for hook audit configuration resolution, hook integration behavior, rotation semantics, direct read/write APIs, and fail-soft behavior on I/O errors.

Adds hook_audit.jsonl alongside the existing hook_telemetry.jsonl.
Where telemetry records counts, audit records the *payload* — the
exact rendered <aelfrice-memory> / <aelfrice-baseline> block injected
into a given turn — so a reviewer can later see what the hook
actually emitted on the turn a suspect critique landed.

Defaults match the spec in docs/hook_hardening.md:
- Default-on. Opt-out via AELFRICE_HOOK_AUDIT=0 env var or
  [hook_audit] enabled = false in .aelfrice.toml.
- 10 MB cap, configurable via [hook_audit] max_bytes.
- Single-slot rotation: live -> hook_audit.jsonl.1 on rollover.
- Fail-soft: any I/O error is traced to stderr and swallowed.

Wires into both user_prompt_submit (records emitted block + locked
count + n_beliefs + session_id when available) and session_start
(records baseline block, hook='session_start').

The session-start path now also reads session_id from the payload
(best-effort) and exposes _retrieve_baseline_with_block() as the
non-formatting-only retrieval helper so the hook can pass hits to
the audit record without re-querying the store.

Closes the audit-log half of #280; mitigations 1+2 (framing-tag
contract + render-time belief-content escape) already shipped via
PR #292.
@robotrocketscience robotrocketscience added the author-Gylf PR coordination mutex label Apr 29, 2026
@coderabbitai

coderabbitai Bot commented Apr 29, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@robotrocketscience has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 36 minutes and 6 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5bb2bc2b-3fac-40ba-b104-1385335c8ae9

📥 Commits

Reviewing files that changed from the base of the PR and between 8e86351 and 117d0e9.

📒 Files selected for processing (2)
  • src/aelfrice/hook.py
  • tests/test_hook_audit.py
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/issue-280-hook-audit-log

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
Review rate limit: 0/1 reviews remaining, refill in 36 minutes and 6 seconds.

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

@sourcery-ai

sourcery-ai Bot commented Apr 29, 2026

Copy link
Copy Markdown

Reviewer's Guide

Implements a per-turn hook audit logging system that records the exact rendered hook block for user_prompt_submit and session_start into a new JSONL audit file with configurable enablement and rotation, plus supporting config resolution, session_id extraction, and comprehensive tests.

Sequence diagram for per-turn hook audit write on user_prompt_submit

sequenceDiagram
    actor Harness
    participant Hook as user_prompt_submit
    participant Extract as _extract_session_id
    participant Audit as _write_hook_audit_record
    participant Config as load_hook_audit_config
    participant DBPath as db_path
    participant FS as Filesystem

    Harness->>Hook: invoke with stdin payload
    Hook->>Hook: _extract_prompt(raw)
    Hook->>Extract: _extract_session_id(raw)
    Extract-->>Hook: session_id or None
    Hook->>Hook: retrieve(..., token_budget)
    Hook-->>Harness: write rendered_block to stdout
    Hook->>Audit: _write_hook_audit_record(hook, prompt, rendered_block, n_beliefs, n_locked, session_id)

    activate Audit
    Audit->>Config: load_hook_audit_config()
    Config-->>Audit: HookAuditConfig(enabled, max_bytes)
    Audit->>Audit: check enabled
    alt audit disabled
        Audit-->>Hook: return (no-op)
    else audit enabled
        Audit->>DBPath: db_path()
        DBPath-->>Audit: Path to memory.db
        Audit->>Audit: _audit_path_for_db(db_path)
        Audit->>FS: _append_audit(audit_path, record, max_bytes)
        FS-->>Audit: write/rotate or error
        Audit-->>Hook: return
    end
    deactivate Audit
Loading

Sequence diagram for per-turn hook audit write on session_start

sequenceDiagram
    actor Harness
    participant Hook as session_start
    participant Extract as _extract_session_id
    participant Retrieve as _retrieve_baseline_with_block
    participant Audit as _write_hook_audit_record
    participant Config as load_hook_audit_config
    participant DBPath as db_path
    participant FS as Filesystem

    Harness->>Hook: invoke with stdin payload
    Hook->>Hook: read raw from stdin
    Hook->>Extract: _extract_session_id(raw)
    Extract-->>Hook: session_id or None
    Hook->>Retrieve: _retrieve_baseline_with_block(token_budget)
    Retrieve->>Retrieve: retrieve(store, "", token_budget)
    Retrieve-->>Hook: hits, rendered_block
    alt rendered_block empty
        Hook-->>Harness: return 0 (no baseline block)
    else rendered_block non-empty
        Hook-->>Harness: write rendered_block to stdout
        Hook->>Audit: _write_hook_audit_record(hook, prompt="", rendered_block, n_beliefs=len(hits), n_locked, session_id)

        activate Audit
        Audit->>Config: load_hook_audit_config()
        Config-->>Audit: HookAuditConfig(enabled, max_bytes)
        Audit->>Audit: check enabled
        alt audit disabled
            Audit-->>Hook: return (no-op)
        else audit enabled
            Audit->>DBPath: db_path()
            DBPath-->>Audit: Path to memory.db
            Audit->>Audit: _audit_path_for_db(db_path)
            Audit->>FS: _append_audit(audit_path, record, max_bytes)
            FS-->>Audit: write/rotate or error
            Audit-->>Hook: return
        end
        deactivate Audit
    end
Loading

Class diagram for new hook audit structures and helpers

classDiagram
    class HookAuditConfig {
        <<dataclass>>
        bool enabled
        int max_bytes
    }

    class HookAuditModule {
        +int AUDIT_DEFAULT_MAX_BYTES
        +int AUDIT_PROMPT_PREFIX_CAP
        +str AUDIT_FILENAME
        +str AUDIT_ROTATED_SUFFIX
        +str AUDIT_HOOK_USER_PROMPT_SUBMIT
        +str AUDIT_HOOK_SESSION_START
        +HookAuditConfig load_hook_audit_config(start, env, stderr)
        +Path _audit_path_for_db(db_path_val)
        +void _append_audit(audit_path, record, max_bytes, stderr)
        +void _write_hook_audit_record(hook, prompt, rendered_block, n_beliefs, n_locked, session_id, config, stderr)
        +list~dict~ read_hook_audit(path)
        +str _extract_session_id(raw)
        +tuple~list~Belief~~str~ _retrieve_baseline_with_block(token_budget)
    }

    class Belief {
    }

    HookAuditModule --> HookAuditConfig : uses
    HookAuditModule --> Belief : returns list of

    class HookHooks {
        +int user_prompt_submit(token_budget, sin, sout, serr)
        +int session_start(token_budget, sin, sout, serr)
    }

    HookHooks --> HookAuditModule : calls _write_hook_audit_record
    HookHooks --> HookAuditModule : calls _extract_session_id
    HookHooks --> HookAuditModule : calls _retrieve_baseline_with_block
Loading

File-Level Changes

Change Details Files
Add configurable per-turn hook audit logging infrastructure and wire it into user_prompt_submit and session_start.
  • Introduce HookAuditConfig dataclass and load_hook_audit_config to resolve [hook_audit] settings from env and .aelfrice.toml with safe fallbacks and stderr diagnostics.
  • Implement _append_audit and _write_hook_audit_record to build JSON records (including capped prompt_prefix, rendered_block, belief/lock counts, optional session_id) and append them to a new hook_audit.jsonl file with append-then-rotate semantics and fail-soft I/O behavior.
  • Add read_hook_audit helper to read and parse the audit JSONL file, returning object records, skipping non-object JSON, and raising on corrupt lines.
  • Wire audit recording into user_prompt_submit and session_start so that on each successful hook fire, an audit record is written capturing the rendered block and metadata; no-op when disabled or when retrieval yields no hits.
  • Add _extract_session_id helper to best-effort parse session_id from incoming JSON hook payloads for correlation in audit records.
  • Refactor baseline retrieval into _retrieve_baseline_with_block to provide both hits and rendered block for use by session_start and existing formatter wrapper.
src/aelfrice/hook.py
Add tests covering hook audit configuration, behavior, and failure modes.
  • Seed in-memory stores and helper functions to exercise user_prompt_submit and session_start end-to-end with audit logging enabled and disabled.
  • Test default-on behavior, env/TOML disable, TOML max_bytes override, malformed and wrong-typed TOML degradation with stderr messaging, and prompt_prefix length capping.
  • Verify audit records for UserPromptSubmit and SessionStart include correct hook identifiers, prompt_prefix, rendered_block content, belief and locked counts, and session_id handling (present/omitted).
  • Test size-based rotation to hook_audit.jsonl.1 with single-slot semantics, ensuring rotated content and fresh live file behavior.
  • Test read_hook_audit API for missing files, corrupted lines raising ValueError, skipping non-object JSON lines, and fail-soft behavior when the audit path is unwritable while hooks still succeed.
tests/test_hook_audit.py

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hey - I've found 3 issues, and left some high level feedback:

  • Both user_prompt_submit and session_start now parse the JSON payload separately in _extract_prompt and _extract_session_id; consider refactoring to parse once and thread the decoded payload through to avoid redundant work and potential divergence in behavior.
  • _append_audit calls os.fsync on every write, which can be quite expensive on some filesystems; if this log is not strictly durability-critical, consider making fsync configurable or relaxing it (e.g., periodic or best-effort) to reduce latency in hot paths.
  • The test_audit_write_failsoft_on_unwriteable_path test is annotated in comments as POSIX-only but is not conditionally skipped on Windows; consider adding a pytest.mark.skipif or equivalent guard so this test doesn’t become flaky or fail on non-POSIX platforms.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Both `user_prompt_submit` and `session_start` now parse the JSON payload separately in `_extract_prompt` and `_extract_session_id`; consider refactoring to parse once and thread the decoded payload through to avoid redundant work and potential divergence in behavior.
-  `_append_audit` calls `os.fsync` on every write, which can be quite expensive on some filesystems; if this log is not strictly durability-critical, consider making fsync configurable or relaxing it (e.g., periodic or best-effort) to reduce latency in hot paths.
- The `test_audit_write_failsoft_on_unwriteable_path` test is annotated in comments as POSIX-only but is not conditionally skipped on Windows; consider adding a `pytest.mark.skipif` or equivalent guard so this test doesn’t become flaky or fail on non-POSIX platforms.

## Individual Comments

### Comment 1
<location path="src/aelfrice/hook.py" line_range="432-435" />
<code_context>
+    try:
+        audit_path.parent.mkdir(parents=True, exist_ok=True)
+        line = json.dumps(record) + "\n"
+        with open(audit_path, "a", encoding="utf-8") as f:
+            f.write(line)
+            f.flush()
+            os.fsync(f.fileno())
+        if audit_path.stat().st_size > max_bytes:
+            rotated = audit_path.with_name(
</code_context>
<issue_to_address>
**suggestion (performance):** Per-record `fsync` could be expensive under high hook volume.

This gives strong durability but may become a bottleneck on busy or slow filesystems. Consider making the sync policy configurable (e.g., batch/periodic sync or OS buffering only) so operators can tune the durability vs throughput trade-off for their deployment.

Suggested implementation:

```python
        with open(audit_path, "a", encoding="utf-8") as f:
            f.write(line)
            # sync_policy controls durability vs throughput:
            # - "always": flush + fsync each record (strong durability, slowest)
            # - "flush": flush only, rely on OS to sync (middle ground)
            # - "none": rely entirely on OS buffering (fastest)
            if sync_policy in ("always", "flush"):
                f.flush()
            if sync_policy == "always":
                os.fsync(f.fileno())

```

To fully implement configurability of the sync behavior, you should also:

1. Update the function signature that contains this code (likely a hook audit/write helper) to accept a new parameter, for example:
   - `sync_policy: str = "always"`
2. Extend the function docstring to document the `sync_policy` parameter and the supported values: `"always"`, `"flush"`, and `"none"`, and the durability/throughput trade-offs.
3. Propagate the new parameter from configuration:
   - Add a configuration option (CLI flag, config file value, or environment variable) that determines the desired `sync_policy`.
   - Thread that configuration value through to all call sites of this function.
4. Ensure that any direct calls to this function in tests or other modules are updated to either:
   - Rely on the default `sync_policy="always"` for current behavior, or
   - Explicitly pass `"flush"` or `"none"` where stronger throughput is desired.
5. Optionally, validate `sync_policy` inside the function (e.g., raise `ValueError` or fall back to `"always"` if an unknown value is provided) to avoid silent misconfiguration.
</issue_to_address>

### Comment 2
<location path="tests/test_hook_audit.py" line_range="148-157" />
<code_context>
+def test_user_prompt_submit_writes_audit_record(
</code_context>
<issue_to_address>
**suggestion (testing):** Add focused tests for `_extract_session_id` edge cases (invalid JSON, non-dict payload, missing/empty `session_id`).

Current integration tests only cover the happy path; they don’t exercise malformed JSON, non-dict roots, or missing/invalid `session_id` values. Direct unit tests on `_extract_session_id` for these cases would better guard the audit trail behavior and future payload shape changes.

Suggested implementation:

```python
# ---------------------------------------------------------------------------
# _extract_session_id unit tests
# ---------------------------------------------------------------------------


def test_extract_session_id_invalid_json() -> None:
    # Malformed JSON should not raise and should return no session id
    assert _extract_session_id("{not-json") is None


def test_extract_session_id_non_dict_root() -> None:
    # Non-object JSON roots should be ignored
    assert _extract_session_id('["not", "a", "dict"]') is None
    assert _extract_session_id('"plain-string"') is None
    assert _extract_session_id("123") is None
    assert _extract_session_id("null") is None


def test_extract_session_id_missing_key() -> None:
    # Valid JSON object without a session_id key should yield no session id
    assert _extract_session_id("{}") is None
    assert _extract_session_id('{"other_key": "value"}') is None


def test_extract_session_id_empty_or_non_string_value() -> None:
    # Empty or non-string session_id values should be treated as absent
    assert _extract_session_id('{"session_id": ""}') is None
    assert _extract_session_id('{"session_id": null}') is None
    assert _extract_session_id('{"session_id": 123}') is None
    assert _extract_session_id('{"session_id": {}}') is None
    assert _extract_session_id('{"session_id": []}') is None


# ---------------------------------------------------------------------------
# Hook integration: writes a record on UserPromptSubmit fire
# ---------------------------------------------------------------------------

def test_user_prompt_submit_writes_audit_record(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
) -> None:
    db = tmp_path / "memory.db"
    _seed_db(db, [_mk("F1", "the kitchen is full of bananas")])
    _set_db(monkeypatch, db)
    monkeypatch.delenv("AELFRICE_HOOK_AUDIT", raising=False)
    monkeypatch.chdir(tmp_path)
    sin = io.StringIO(_payload("bananas", session_id="sess-abc"))
    sout = io.StringIO()
    rc = user_prompt_submit(stdin=sin, stdout=sout)

```

1. Ensure `_extract_session_id` is imported into `tests/test_hook_audit.py` from the module under test (likely alongside `user_prompt_submit` and `load_hook_audit_config`). For example:
   - `from aelfrice.hook_audit import _extract_session_id`
2. If the actual contract of `_extract_session_id` differs (e.g., it raises on malformed JSON or returns `""` instead of `None`), adjust the assertions accordingly to match the existing implementation.
</issue_to_address>

### Comment 3
<location path="tests/test_hook_audit.py" line_range="79-84" />
<code_context>
+    assert cfg.max_bytes == AUDIT_DEFAULT_MAX_BYTES
+
+
+def test_env_disable_overrides_toml(tmp_path: Path) -> None:
+    (tmp_path / ".aelfrice.toml").write_text(
+        "[hook_audit]\nenabled = true\n", encoding="utf-8",
+    )
+    cfg = load_hook_audit_config(
+        start=tmp_path, env={"AELFRICE_HOOK_AUDIT": "0"},
+    )
+    assert cfg.enabled is False
+
+
</code_context>
<issue_to_address>
**suggestion (testing):** Add a test to confirm that `AELFRICE_HOOK_AUDIT` values other than "0" do *not* disable auditing.

Current tests cover only the disabling case (`AELFRICE_HOOK_AUDIT="0"` and TOML). Since the code checks specifically for `env_val.strip() == "0"`, please add a test where `AELFRICE_HOOK_AUDIT` is set to a non-zero/non-empty value (e.g., "1" or "foo") and verify that auditing stays enabled and config resolution behaves normally. This will lock in the intended env override semantics for future changes.

```suggestion
def test_default_config_is_enabled_with_default_max_bytes(
    tmp_path: Path,
) -> None:
    cfg = load_hook_audit_config(start=tmp_path, env={})
    assert cfg.enabled is True
    assert cfg.max_bytes == AUDIT_DEFAULT_MAX_BYTES


def test_env_nonzero_does_not_disable_toml_enabled(tmp_path: Path) -> None:
    (tmp_path / ".aelfrice.toml").write_text(
        "[hook_audit]\nenabled = true\n",
        encoding="utf-8",
    )
    cfg = load_hook_audit_config(
        start=tmp_path,
        env={"AELFRICE_HOOK_AUDIT": "1"},
    )
    assert cfg.enabled is True
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread src/aelfrice/hook.py
Comment on lines +432 to +435
with open(audit_path, "a", encoding="utf-8") as f:
f.write(line)
f.flush()
os.fsync(f.fileno())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion (performance): Per-record fsync could be expensive under high hook volume.

This gives strong durability but may become a bottleneck on busy or slow filesystems. Consider making the sync policy configurable (e.g., batch/periodic sync or OS buffering only) so operators can tune the durability vs throughput trade-off for their deployment.

Suggested implementation:

        with open(audit_path, "a", encoding="utf-8") as f:
            f.write(line)
            # sync_policy controls durability vs throughput:
            # - "always": flush + fsync each record (strong durability, slowest)
            # - "flush": flush only, rely on OS to sync (middle ground)
            # - "none": rely entirely on OS buffering (fastest)
            if sync_policy in ("always", "flush"):
                f.flush()
            if sync_policy == "always":
                os.fsync(f.fileno())

To fully implement configurability of the sync behavior, you should also:

  1. Update the function signature that contains this code (likely a hook audit/write helper) to accept a new parameter, for example:
    • sync_policy: str = "always"
  2. Extend the function docstring to document the sync_policy parameter and the supported values: "always", "flush", and "none", and the durability/throughput trade-offs.
  3. Propagate the new parameter from configuration:
    • Add a configuration option (CLI flag, config file value, or environment variable) that determines the desired sync_policy.
    • Thread that configuration value through to all call sites of this function.
  4. Ensure that any direct calls to this function in tests or other modules are updated to either:
    • Rely on the default sync_policy="always" for current behavior, or
    • Explicitly pass "flush" or "none" where stronger throughput is desired.
  5. Optionally, validate sync_policy inside the function (e.g., raise ValueError or fall back to "always" if an unknown value is provided) to avoid silent misconfiguration.

Comment thread tests/test_hook_audit.py
Comment on lines +148 to +157
def test_user_prompt_submit_writes_audit_record(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
) -> None:
db = tmp_path / "memory.db"
_seed_db(db, [_mk("F1", "the kitchen is full of bananas")])
_set_db(monkeypatch, db)
monkeypatch.delenv("AELFRICE_HOOK_AUDIT", raising=False)
monkeypatch.chdir(tmp_path)
sin = io.StringIO(_payload("bananas", session_id="sess-abc"))
sout = io.StringIO()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion (testing): Add focused tests for _extract_session_id edge cases (invalid JSON, non-dict payload, missing/empty session_id).

Current integration tests only cover the happy path; they don’t exercise malformed JSON, non-dict roots, or missing/invalid session_id values. Direct unit tests on _extract_session_id for these cases would better guard the audit trail behavior and future payload shape changes.

Suggested implementation:

# ---------------------------------------------------------------------------
# _extract_session_id unit tests
# ---------------------------------------------------------------------------


def test_extract_session_id_invalid_json() -> None:
    # Malformed JSON should not raise and should return no session id
    assert _extract_session_id("{not-json") is None


def test_extract_session_id_non_dict_root() -> None:
    # Non-object JSON roots should be ignored
    assert _extract_session_id('["not", "a", "dict"]') is None
    assert _extract_session_id('"plain-string"') is None
    assert _extract_session_id("123") is None
    assert _extract_session_id("null") is None


def test_extract_session_id_missing_key() -> None:
    # Valid JSON object without a session_id key should yield no session id
    assert _extract_session_id("{}") is None
    assert _extract_session_id('{"other_key": "value"}') is None


def test_extract_session_id_empty_or_non_string_value() -> None:
    # Empty or non-string session_id values should be treated as absent
    assert _extract_session_id('{"session_id": ""}') is None
    assert _extract_session_id('{"session_id": null}') is None
    assert _extract_session_id('{"session_id": 123}') is None
    assert _extract_session_id('{"session_id": {}}') is None
    assert _extract_session_id('{"session_id": []}') is None


# ---------------------------------------------------------------------------
# Hook integration: writes a record on UserPromptSubmit fire
# ---------------------------------------------------------------------------

def test_user_prompt_submit_writes_audit_record(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
) -> None:
    db = tmp_path / "memory.db"
    _seed_db(db, [_mk("F1", "the kitchen is full of bananas")])
    _set_db(monkeypatch, db)
    monkeypatch.delenv("AELFRICE_HOOK_AUDIT", raising=False)
    monkeypatch.chdir(tmp_path)
    sin = io.StringIO(_payload("bananas", session_id="sess-abc"))
    sout = io.StringIO()
    rc = user_prompt_submit(stdin=sin, stdout=sout)
  1. Ensure _extract_session_id is imported into tests/test_hook_audit.py from the module under test (likely alongside user_prompt_submit and load_hook_audit_config). For example:
    • from aelfrice.hook_audit import _extract_session_id
  2. If the actual contract of _extract_session_id differs (e.g., it raises on malformed JSON or returns "" instead of None), adjust the assertions accordingly to match the existing implementation.

Comment thread tests/test_hook_audit.py
Comment on lines +79 to +84
def test_default_config_is_enabled_with_default_max_bytes(
tmp_path: Path,
) -> None:
cfg = load_hook_audit_config(start=tmp_path, env={})
assert cfg.enabled is True
assert cfg.max_bytes == AUDIT_DEFAULT_MAX_BYTES

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion (testing): Add a test to confirm that AELFRICE_HOOK_AUDIT values other than "0" do not disable auditing.

Current tests cover only the disabling case (AELFRICE_HOOK_AUDIT="0" and TOML). Since the code checks specifically for env_val.strip() == "0", please add a test where AELFRICE_HOOK_AUDIT is set to a non-zero/non-empty value (e.g., "1" or "foo") and verify that auditing stays enabled and config resolution behaves normally. This will lock in the intended env override semantics for future changes.

Suggested change
def test_default_config_is_enabled_with_default_max_bytes(
tmp_path: Path,
) -> None:
cfg = load_hook_audit_config(start=tmp_path, env={})
assert cfg.enabled is True
assert cfg.max_bytes == AUDIT_DEFAULT_MAX_BYTES
def test_default_config_is_enabled_with_default_max_bytes(
tmp_path: Path,
) -> None:
cfg = load_hook_audit_config(start=tmp_path, env={})
assert cfg.enabled is True
assert cfg.max_bytes == AUDIT_DEFAULT_MAX_BYTES
def test_env_nonzero_does_not_disable_toml_enabled(tmp_path: Path) -> None:
(tmp_path / ".aelfrice.toml").write_text(
"[hook_audit]\nenabled = true\n",
encoding="utf-8",
)
cfg = load_hook_audit_config(
start=tmp_path,
env={"AELFRICE_HOOK_AUDIT": "1"},
)
assert cfg.enabled is True

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[claim:review:Setr:2026-04-29T16:25:13Z]

@robotrocketscience
robotrocketscience merged commit 2a8f47f into main Apr 29, 2026
16 checks passed
@robotrocketscience
robotrocketscience deleted the feat/issue-280-hook-audit-log branch April 29, 2026 16:26
@robotrocketscience

Copy link
Copy Markdown
Owner Author

[release:review:Setr:2026-04-29T16:26:26Z]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

author-Gylf PR coordination mutex

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant