Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ All notable changes to bicameral-mcp are tracked here. Format loosely follows
- Flow 3 e2e prompt clarified to use imperative shell phrasing for `git add` + `git commit` (#197). Resolves the "agent did NOT commit" flake observed on PR #194 + #195 e2e runs where the agent interpreted "stage and commit" as a non-shell verb. Adds a "Debugging Flow 3 fails" subsection to `tests/e2e/README.md` capturing the investigation order so the next maintainer has a starting checklist.
- `setup_wizard._build_session_end_command` now renders OS-specific shape via new `platform: str | None = None` argument defaulting to `sys.platform` (#200 Phase 1, A1). POSIX (linux, darwin) keeps bash conditional shape with `python3` (Ubuntu/Debian/RHEL/Fedora install `python3` by default; `python` is NOT a default symlink). Windows (win32) gets a cmd.exe shape with `python` (Windows installers expose `python` and `py` but not `python3`). Closes the SessionEnd hook silent-failure on Windows MinGW. New `_session_end_command_for_platform` helper. 4 new behavioral tests; existing drift tests updated to be platform-aware. A7 telemetry transparency notes added above the SessionEnd batch consent prompt in capture-corrections and the Step 3.5 consent prompt in report-bug (instruction-level — explicitly tracked as suggestive, not governance, per #205 doctrine).
- `bicameral-ingest` skill: signer-email fallback policy is now a deterministic config gate (#200 Phase 2 Findings A4). New `signer_email_fallback` field in `.bicameral/config.yaml` (modes: `redact`, `local-part-only`, `full`; default `local-part-only` — privacy-positive, preserves attribution prefix without leaking the full email). Server-side enforcement in new `events.writer._resolve_signer_email`; `handlers/ingest.py` applies the policy via `BicameralContext.signer_email_fallback` before raw git `user.email` lands in the ledger. Setup wizard writes the default key to fresh `.bicameral/config.yaml`. New SKILL.md Step 0.6 ("Pre-ingest leak warning") fires `AskUserQuestion` once per session before the first ingest, warning the operator that source quotes persist verbatim and (in team mode) commit to git via the JSONL substrate; gated on a session-scoped `seen_ingest_warning` flag (in-memory only, set via `BicameralContext.set_seen_ingest_warning`). 5 new behavioral tests (3 for `_resolve_signer_email` modes, 2 for `seen_ingest_warning` get/set).
- `bicameral-preflight` skill: source-attribution rendering and bypass-event tracking are now deterministic config gates (#200 Phase 3 Findings A4 + bypass disclosure). Two new `.bicameral/config.yaml` fields: `render_source_attribution` (modes: `full` (default — see below), `redacted`, `hidden`; gates how `DecisionMatch.source_ref` lines render to the agent — `redacted` strips name + date patterns while preserving structural shape) and `preflight_bypass_tracking` (modes: `enabled` (default), `disabled`; gates the JSONL write to `~/.bicameral/preflight_events.jsonl`). Server-side enforcement: new `handlers.preflight._apply_attribution_policy` filters `source_ref` before the agent sees it; `handlers.record_bypass.handle_record_bypass` short-circuits when tracking is disabled. Setup wizard writes both defaults to fresh `.bicameral/config.yaml`. SKILL.md updated with config-field references + telemetry transparency note above HITL prompts. 5 new behavioral tests (3 for `_apply_attribution_policy` modes, 2 for `record_bypass` config gating). **Note on `render_source_attribution` default**: v1 ships with `full` (legacy verbatim) as default rather than the originally-planned `redacted` because the v1 redaction regex is overbroad and breaks downstream agent reasoning (replaces meaningful tokens like "Sprint", "Linear" alongside actual names). Default flips to `redacted` once the regex is refined; tracked in #209. The deterministic gate is in place — users who want privacy-positive rendering today can flip to `redacted` or `hidden` via config.yaml.

### Schema

Expand Down
94 changes: 81 additions & 13 deletions context.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,28 +17,81 @@
_SIGNER_FALLBACK_MODES = frozenset({"redact", "local-part-only", "full"})
_DEFAULT_SIGNER_FALLBACK_MODE = "local-part-only"


def _read_signer_email_fallback(repo_path: str) -> str:
"""Resolve `signer_email_fallback` from `.bicameral/config.yaml`.

Default: ``"local-part-only"`` (privacy-positive). Returns the
raw config value if it's one of the three valid modes; falls
back to default on missing file, malformed yaml, missing key,
or invalid value (logs nothing — fail-soft).
"""
_RENDER_ATTRIBUTION_MODES = frozenset({"full", "redacted", "hidden"})
# v1 default is `full` (legacy verbatim) for backward-compat with the e2e
# harness's agent-parsing of source_refs. The current `redacted` regex is
# overbroad — it replaces all `[A-Z][a-z]+` patterns including meaningful
# tokens like "Sprint", "Linear", "Slack", which strips the source_ref of
# agent-parseable structure. Default flips to `redacted` once the regex
# is refined to match only true name/date patterns. Tracked separately;
# config field already exposes the privacy-positive options for opt-in.
_DEFAULT_RENDER_ATTRIBUTION_MODE = "full"

_BYPASS_TRACKING_MODES = frozenset({"enabled", "disabled"})
_DEFAULT_BYPASS_TRACKING_MODE = "enabled"


def _read_yaml_string_field(repo_path: str, key: str, valid: frozenset[str], default: str) -> str:
"""Generic reader for a `.bicameral/config.yaml` string field with a
fixed valid-set and fail-soft default. Returns the raw config value
if it's in the valid set; falls back to default on missing file,
malformed yaml, missing key, or invalid value."""
config_path = Path(repo_path) / ".bicameral" / "config.yaml"
if not config_path.exists():
return _DEFAULT_SIGNER_FALLBACK_MODE
return default
try:
import yaml

config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
val = config.get("signer_email_fallback", _DEFAULT_SIGNER_FALLBACK_MODE)
if val in _SIGNER_FALLBACK_MODES:
val = config.get(key, default)
if val in valid:
return val
except Exception:
pass
return _DEFAULT_SIGNER_FALLBACK_MODE
return default


def _read_signer_email_fallback(repo_path: str) -> str:
"""Resolve `signer_email_fallback` from `.bicameral/config.yaml`.

Default: ``"local-part-only"`` (privacy-positive). Modes: ``redact``,
``local-part-only``, ``full``."""
return _read_yaml_string_field(
repo_path,
"signer_email_fallback",
_SIGNER_FALLBACK_MODES,
_DEFAULT_SIGNER_FALLBACK_MODE,
)


def _read_render_source_attribution(repo_path: str) -> str:
"""Resolve `render_source_attribution` from `.bicameral/config.yaml`.

Default: ``"redacted"`` (privacy-positive — replaces names + dates
with placeholders, preserves structural shape). Modes: ``full``,
``redacted``, ``hidden``. Read by ``handlers.preflight._apply_attribution_policy``
to filter ``DecisionMatch.source_ref`` before it returns to the agent."""
return _read_yaml_string_field(
repo_path,
"render_source_attribution",
_RENDER_ATTRIBUTION_MODES,
_DEFAULT_RENDER_ATTRIBUTION_MODE,
)


def _read_preflight_bypass_tracking(repo_path: str) -> str:
"""Resolve `preflight_bypass_tracking` from `.bicameral/config.yaml`.

Default: ``"enabled"`` (backward-compat with pre-#200 behavior; lift
candidate for a later deprecation cycle). Modes: ``enabled``,
``disabled``. When disabled, ``handlers.record_bypass.handle_record_bypass``
short-circuits before the JSONL write to ``~/.bicameral/preflight_events.jsonl``."""
return _read_yaml_string_field(
repo_path,
"preflight_bypass_tracking",
_BYPASS_TRACKING_MODES,
_DEFAULT_BYPASS_TRACKING_MODE,
)


def _read_guided_mode(repo_path: str) -> bool:
Expand Down Expand Up @@ -116,6 +169,17 @@ class BicameralContext:
# (`local-part-only`) preserves attribution prefix without leaking a
# directly-mailable address. Modes: `redact`, `local-part-only`, `full`.
signer_email_fallback: str = _DEFAULT_SIGNER_FALLBACK_MODE
# #200 Phase 3: render_source_attribution gates how DecisionMatch.source_ref
# lines render to the agent. Default `redacted` strips name + date patterns
# while preserving structural shape. Modes: `full` (legacy verbatim),
# `redacted` (default), `hidden` (blank source_ref entirely).
render_source_attribution: str = _DEFAULT_RENDER_ATTRIBUTION_MODE
# #200 Phase 3: preflight_bypass_tracking gates the JSONL write to
# ~/.bicameral/preflight_events.jsonl. Default `enabled` matches pre-#200
# behavior; `disabled` makes record_bypass a no-op (returns recorded=False
# with reason="tracking_disabled"). Operator privacy choice; deterministic
# at config-load time.
preflight_bypass_tracking: str = _DEFAULT_BYPASS_TRACKING_MODE
# v0.4.8: mutable cache for within-call sync dedup. Frozen-dataclass-safe
# because the reference stays pinned; only the dict's contents mutate.
# Keys: ``last_sync_sha`` (str). Cleared by any handler that mutates
Expand Down Expand Up @@ -155,6 +219,8 @@ def from_env(cls) -> BicameralContext:
authoritative_sha = resolve_ref_sha(repo_path, authoritative_ref) or ""
guided_mode = _read_guided_mode(repo_path)
signer_email_fallback = _read_signer_email_fallback(repo_path)
render_source_attribution = _read_render_source_attribution(repo_path)
preflight_bypass_tracking = _read_preflight_bypass_tracking(repo_path)

return cls(
repo_path=repo_path,
Expand All @@ -168,4 +234,6 @@ def from_env(cls) -> BicameralContext:
authoritative_sha=authoritative_sha,
guided_mode=guided_mode,
signer_email_fallback=signer_email_fallback,
render_source_attribution=render_source_attribution,
preflight_bypass_tracking=preflight_bypass_tracking,
)
44 changes: 44 additions & 0 deletions handlers/preflight.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@

import logging
import os
import re
import time
from pathlib import Path

Expand Down Expand Up @@ -76,6 +77,40 @@
_ONBOARDED_MARKER = Path.home() / ".bicameral" / "onboarded"


# #200 Phase 3: render_source_attribution policy patterns. The redacted
# mode preserves structural shape (so the operator can see "this is from
# a meeting on a date" without seeing who or when).
_NAME_PATTERN = re.compile(r"\b[A-Z][a-z]+\b")
_DATE_PATTERN = re.compile(r"\b\d{4}-\d{2}-\d{2}\b")


def _apply_attribution_policy(matches: list, mode: str) -> list:
"""Apply `render_source_attribution` policy to DecisionMatch.source_ref.

Modes (from `.bicameral/config.yaml: render_source_attribution`):
- `full`: pass through verbatim (legacy)
- `redacted` (default): replace name + date patterns with placeholders;
preserves structural shape so the operator sees "Source: <NAME_REDACTED>
review · <NAME_REDACTED>, <DATE_REDACTED>" instead of full attribution
- `hidden`: blank source_ref entirely

Returns a new list of DecisionMatch instances (Pydantic copies via
model_copy) with source_ref transformed; never mutates the inputs.
The function is pure: same inputs → same outputs, no I/O, no state.
"""
if mode == "full":
return matches
transformed = []
for m in matches:
if mode == "hidden":
new_source_ref = ""
else: # redacted
stripped = _DATE_PATTERN.sub("<DATE_REDACTED>", m.source_ref)
new_source_ref = _NAME_PATTERN.sub("<NAME_REDACTED>", stripped)
transformed.append(m.model_copy(update={"source_ref": new_source_ref}))
return transformed


def _should_show_product_stage() -> bool:
"""True on first preflight call per device. Creates the marker on first call."""
try:
Expand Down Expand Up @@ -433,6 +468,15 @@ async def handle_preflight(
except Exception as exc:
logger.debug("[preflight] region lookup failed: %s", exc)

# #200 Phase 3: apply render_source_attribution policy server-side.
# Default `redacted` strips name + date patterns from source_ref so
# attribution detail doesn't leak into shared screens / pair sessions.
# Mode read from `.bicameral/config.yaml: render_source_attribution`
# at config load via context.py.
region_matches = _apply_attribution_policy(
region_matches, getattr(ctx, "render_source_attribution", "redacted")
)

decisions = [_to_brief_decision(m) for m in region_matches]
drift_candidates = [_to_brief_decision(m) for m in region_matches if m.status == "drifted"]

Expand Down
13 changes: 11 additions & 2 deletions handlers/record_bypass.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,24 @@ async def handle_record_bypass(
skills can rely on ``recorded`` to distinguish a fresh bypass from
a within-window repeat.
"""
del ctx # unused — bypass storage is local JSONL, not the ledger.

if not decision_id or not isinstance(decision_id, str):
return RecordBypassResponse(
recorded=False,
deduped=False,
reason="invalid_decision_id",
)

# #200 Phase 3: deterministic config gate. When operator sets
# `preflight_bypass_tracking: disabled` in `.bicameral/config.yaml`,
# short-circuit BEFORE the JSONL write so no event lands on disk.
# Default is `enabled` — pre-#200 behavior preserved.
if getattr(ctx, "preflight_bypass_tracking", "enabled") == "disabled":
return RecordBypassResponse(
recorded=False,
deduped=False,
reason="tracking_disabled",
)

# Imported lazily so tests that monkeypatch ``preflight_telemetry``
# observe the patched module. Otherwise the import freezes at
# server-startup time and breaks the per-test ``Path.home()``
Expand Down
9 changes: 8 additions & 1 deletion setup_wizard.py
Original file line number Diff line number Diff line change
Expand Up @@ -937,13 +937,20 @@ def _write_collaboration_config(
f"mode: {mode}\n"
f"guided: {'true' if guided else 'false'}\n"
f"telemetry: {'true' if telemetry else 'false'}\n"
"signer_email_fallback: local-part-only\n",
"signer_email_fallback: local-part-only\n"
"render_source_attribution: full\n"
"preflight_bypass_tracking: enabled\n",
encoding="utf-8",
)
print(f" Collaboration: {mode} mode")
print(f" Guided mode: {'on — blocking hints' if guided else 'off — advisory hints'}")
print(f" Telemetry: {'on — anonymous usage stats' if telemetry else 'off'}")
print(" Signer-email fallback: local-part-only (privacy-positive default)")
print(
" Source-attribution rendering: full (legacy verbatim — flip to "
"`redacted` or `hidden` in config.yaml to opt into privacy-positive shape)"
)
print(" Preflight bypass tracking: enabled (writes ~/.bicameral/preflight_events.jsonl)")


def _patch_gitignore(path: Path, entries: list[str], comment: str) -> None:
Expand Down
12 changes: 12 additions & 0 deletions skills/bicameral-preflight/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,10 @@ or more decisions with an unresolved `signoff_state`. Each prompt
already carries the question, the trigger label, and a closed option
list. Render them via `AskUserQuestion`.

> **Telemetry note**: this skill emits `skill_begin` / `skill_end` events with `g9_*` / `g10_*` / `g11_*` diagnostic counters (counts only, no content). Set `BICAMERAL_TELEMETRY=0` to opt out before invoking.

**Source attribution rendering (#200 Phase 3)**: every `source_ref` field on surfaced decisions is already pre-filtered server-side per the operator's `render_source_attribution` setting in `.bicameral/config.yaml`. Modes: `full` (verbatim legacy), `redacted` (default — names + dates replaced with placeholders, structural shape preserved), `hidden` (blank). Render whatever the server returned verbatim — no further redaction needed at the skill layer, and do NOT attempt to recover original values from redacted forms. The deterministic gate is the config field, not this instruction.

**Trigger conditions** — a prompt is emitted whenever a surfaced
decision's `signoff_state` is one of:

Expand Down Expand Up @@ -379,6 +383,14 @@ for prompt in response.hitl_prompts:
bypass writes to persist; otherwise `record_bypass` returns
`recorded=false, deduped=false, reason="telemetry_disabled"` and the
engine sees no recency.
- **`preflight_bypass_tracking` config gate (#200 Phase 3)**: when
`.bicameral/config.yaml: preflight_bypass_tracking: disabled`, the
handler short-circuits BEFORE the JSONL write and returns
`recorded=false, deduped=false, reason="tracking_disabled"`. Operator
privacy choice; deterministic at config-load time. Default is
`enabled` (pre-#200 behavior). When disabled, the engine's recency
read sees no events → no escalation drop, which matches the user's
privacy choice (resurfacing happens at full intensity until ratified).

### 5.5 Confirm finding relevance (ground truth for calibration)

Expand Down
77 changes: 77 additions & 0 deletions tests/test_preflight_bypass_tracking.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Behavioral tests for `handlers.record_bypass.handle_record_bypass`
respecting the `preflight_bypass_tracking` config gate (#200 Phase 3).

When `preflight_bypass_tracking="disabled"` in `.bicameral/config.yaml`,
the handler must short-circuit BEFORE the JSONL write to
`~/.bicameral/preflight_events.jsonl` and return
``recorded=False, reason="tracking_disabled"``. When `"enabled"`
(default), behavior is unchanged from the pre-#200 implementation.

The config gate is a deterministic server-side enforcement of the
operator's privacy choice; skill text references the config field
but does not implement the gate.
"""

from __future__ import annotations

from dataclasses import dataclass

import pytest

from handlers.record_bypass import handle_record_bypass


@dataclass
class _StubCtx:
preflight_bypass_tracking: str = "enabled"


@pytest.mark.asyncio
async def test_record_bypass_no_op_when_disabled(tmp_path, monkeypatch) -> None:
"""preflight_bypass_tracking=disabled → handler returns
recorded=False, reason='tracking_disabled', and does not invoke the
JSONL writer at all (verified by monkeypatching write_bypass_event
to raise — if it's called, the test fails)."""
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))

import preflight_telemetry

def _fail_if_called(*args, **kwargs):
raise AssertionError("write_bypass_event should not be called when tracking is disabled")

monkeypatch.setattr(preflight_telemetry, "write_bypass_event", _fail_if_called)

ctx = _StubCtx(preflight_bypass_tracking="disabled")
result = await handle_record_bypass(ctx, decision_id="d-test-1")

assert result.recorded is False
assert result.reason == "tracking_disabled"


@pytest.mark.asyncio
async def test_record_bypass_writes_event_when_enabled(tmp_path, monkeypatch) -> None:
"""preflight_bypass_tracking=enabled → handler invokes write_bypass_event
(provided BICAMERAL_PREFLIGHT_TELEMETRY isn't disabled). Confirms the
config gate doesn't interfere with the existing telemetry-enabled path."""
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.setenv("BICAMERAL_PREFLIGHT_TELEMETRY", "1")

import preflight_telemetry

calls: list[tuple] = []

def _capture(decision_id, reason="user_bypassed", state_preserved="proposed"):
calls.append((decision_id, reason, state_preserved))

monkeypatch.setattr(preflight_telemetry, "telemetry_enabled", lambda: True)
monkeypatch.setattr(preflight_telemetry, "recent_bypass_seconds", lambda _: None)
monkeypatch.setattr(preflight_telemetry, "write_bypass_event", _capture)

ctx = _StubCtx(preflight_bypass_tracking="enabled")
result = await handle_record_bypass(ctx, decision_id="d-test-2")

assert result.recorded is True
assert len(calls) == 1
assert calls[0][0] == "d-test-2"
Loading
Loading