arkaos 4.45.0 → 4.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/THE-ARKAOS-GUIDE.md +1 -1
  2. package/VERSION +1 -1
  3. package/core/egress/__init__.py +20 -0
  4. package/core/egress/allowlist.py +123 -0
  5. package/core/egress/audit.py +127 -0
  6. package/core/egress/policy.py +261 -0
  7. package/core/egress/redact.py +46 -0
  8. package/core/governance/evidence_checks.py +116 -37
  9. package/core/harness/__init__.py +22 -0
  10. package/core/harness/drift.py +317 -0
  11. package/core/harness/json_store.py +119 -0
  12. package/core/harness/manifest.py +87 -0
  13. package/core/harness/paths.py +72 -0
  14. package/core/harness/spec.py +182 -0
  15. package/harness/codex/AGENTS.md +1 -1
  16. package/harness/copilot/copilot-instructions.md +1 -1
  17. package/harness/cursor/rules/arkaos.mdc +2 -2
  18. package/harness/gemini/GEMINI.md +1 -1
  19. package/harness/opencode/AGENTS.md +1 -1
  20. package/harness/opencode/agents/arka-architect-gabriel.md +1 -1
  21. package/harness/opencode/agents/arka-brand-director-valentina.md +1 -1
  22. package/harness/opencode/agents/arka-cfo-helena.md +1 -1
  23. package/harness/opencode/agents/arka-chief-of-staff-afonso.md +1 -1
  24. package/harness/opencode/agents/arka-community-strategist-beatriz.md +1 -1
  25. package/harness/opencode/agents/arka-content-strategist-rafael.md +1 -1
  26. package/harness/opencode/agents/arka-conversion-strategist-ines.md +1 -1
  27. package/harness/opencode/agents/arka-coo-sofia.md +1 -1
  28. package/harness/opencode/agents/arka-copy-director-eduardo.md +1 -1
  29. package/harness/opencode/agents/arka-cqo-marta.md +1 -1
  30. package/harness/opencode/agents/arka-cto-marco.md +1 -1
  31. package/harness/opencode/agents/arka-design-ops-lead-iris.md +1 -1
  32. package/harness/opencode/agents/arka-ecom-director-ricardo.md +1 -1
  33. package/harness/opencode/agents/arka-knowledge-director-clara.md +1 -1
  34. package/harness/opencode/agents/arka-leadership-director-rodrigo.md +1 -1
  35. package/harness/opencode/agents/arka-marketing-director-luna.md +1 -1
  36. package/harness/opencode/agents/arka-ops-lead-daniel.md +1 -1
  37. package/harness/opencode/agents/arka-pm-director-carolina.md +1 -1
  38. package/harness/opencode/agents/arka-revops-lead-vicente.md +1 -1
  39. package/harness/opencode/agents/arka-saas-strategist-tiago.md +1 -1
  40. package/harness/opencode/agents/arka-sales-director-miguel.md +1 -1
  41. package/harness/opencode/agents/arka-strategy-director-tomas.md +1 -1
  42. package/harness/opencode/agents/arka-tech-director-francisca.md +1 -1
  43. package/harness/opencode/agents/arka-tech-lead-paulo.md +1 -1
  44. package/harness/opencode/agents/arka-video-producer-simao.md +1 -1
  45. package/harness/zed/.rules +1 -1
  46. package/knowledge/skills-manifest.json +1 -1
  47. package/package.json +1 -1
  48. package/pyproject.toml +1 -1
@@ -1,6 +1,6 @@
1
1
  # The ArkaOS Guide
2
2
 
3
- > v4.45.0 — 89 agents, 17 departments, 332 skills, 297 commands, 18 ADRs.
3
+ > v4.46.0 — 89 agents, 17 departments, 332 skills, 297 commands, 19 ADRs.
4
4
  > One file, everything you need to start. Generated by `scripts/guide_gen.py` — never hand-edited.
5
5
 
6
6
  ## What it is
package/VERSION CHANGED
@@ -1 +1 @@
1
- 4.45.0
1
+ 4.46.0
@@ -0,0 +1,20 @@
1
+ """Egress policy — deny-by-default guard for anything leaving the machine.
2
+
3
+ Workstream D of the 2026-07 repair campaign integrates notebooklm-py,
4
+ and everything uploaded there leaves for a Google account. The operator
5
+ serves confidential clients, so this package ships FIRST and every
6
+ other D-slice depends on it:
7
+
8
+ - ``policy`` — ``evaluate`` (never raises) / ``enforce`` (raises
9
+ :class:`~core.egress.policy.EgressDeniedError`), ordered checks
10
+ - ``redact`` — fail-closed redaction over
11
+ ``core.evals.sanitizer.sanitize_text``
12
+ - ``allowlist`` — expiring exceptions; client identifiers are never
13
+ allowlistable
14
+ - ``audit`` — hashed JSONL trail; no raw payloads, ever
15
+
16
+ Fail-closed is the operator's standing directive (2026-07-26): with no
17
+ ``~/.arkaos/redaction-clients.json`` on disk there is nothing to prove
18
+ a payload clean, so nothing leaves. ADR:
19
+ ``docs/adr/2026-07-31-egress-policy.md``.
20
+ """
@@ -0,0 +1,123 @@
1
+ """Expiring egress exceptions — narrow, auditable, never for clients.
2
+
3
+ An allowlist entry permits ONE finding kind + token pair to leave,
4
+ until it expires. Two kinds are unallowlistable by construction:
5
+ ``client-identifier`` (confidentiality is non-negotiable — the v2.18.0
6
+ npm leak precedent) and ``redaction-config-missing`` (an exception to
7
+ fail-closed would BE the open-by-default this package exists to
8
+ prevent). Malformed or expired entries permit nothing; a broken
9
+ allowlist degrades to deny, never to allow.
10
+
11
+ File: ``~/.arkaos/egress/allowlist.json`` —
12
+ ``{"entries": [{"kind": "secret" | "home-path", "token": "<exact>",
13
+ "reason": "...", "expires": "<ISO-8601>",
14
+ "destinations": ["notebooklm"]}]}``. ``destinations`` is optional;
15
+ absent means any destination.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from dataclasses import dataclass
22
+ from datetime import UTC, datetime
23
+ from pathlib import Path
24
+
25
+ ALLOWLISTABLE_KINDS: frozenset[str] = frozenset({"secret", "home-path"})
26
+
27
+
28
+ def default_allowlist_path(home: Path | None = None) -> Path:
29
+ return (home or Path.home()) / ".arkaos" / "egress" / "allowlist.json"
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class AllowlistEntry:
34
+ """One live exception, already validated and unexpired."""
35
+
36
+ kind: str
37
+ token: str
38
+ reason: str
39
+ expires: datetime
40
+ destinations: tuple[str, ...] # empty = any destination
41
+
42
+ def permits(self, kind: str, token: str, destination: str) -> bool:
43
+ return (
44
+ self.kind == kind
45
+ and self.token == token
46
+ and (not self.destinations or destination in self.destinations)
47
+ )
48
+
49
+
50
+ def active_entries(
51
+ path: Path, now: datetime | None = None
52
+ ) -> list[AllowlistEntry]:
53
+ """Live entries in the allowlist file; never raises.
54
+
55
+ Anything unparseable, incomplete, of an unallowlistable kind, or
56
+ past its expiry is dropped — a defect in the file narrows what is
57
+ permitted; it never widens it. The catch is deliberately broad
58
+ (QG D1 r1: a latin-1 file raised UnicodeDecodeError through the
59
+ original three-exception tuple; a naive ``now`` raised TypeError
60
+ at the expiry comparison): any failure here means "no exceptions
61
+ are in force", which is the deny direction.
62
+ """
63
+ moment = now or datetime.now(UTC)
64
+ try:
65
+ data = json.loads(path.read_text(encoding="utf-8"))
66
+ raw_entries = data.get("entries", [])
67
+ if not isinstance(raw_entries, list):
68
+ return []
69
+ return [
70
+ entry
71
+ for raw in raw_entries
72
+ if (entry := _parse_entry(raw)) is not None
73
+ and entry.expires > moment
74
+ ]
75
+ except Exception:
76
+ return []
77
+
78
+
79
+ def permits(
80
+ entries: list[AllowlistEntry], kind: str, token: str, destination: str
81
+ ) -> bool:
82
+ """True when a live entry covers this exact finding."""
83
+ if kind not in ALLOWLISTABLE_KINDS:
84
+ return False
85
+ return any(e.permits(kind, token, destination) for e in entries)
86
+
87
+
88
+ def _parse_entry(raw: object) -> AllowlistEntry | None:
89
+ if not isinstance(raw, dict):
90
+ return None
91
+ kind = raw.get("kind")
92
+ token = raw.get("token")
93
+ reason = raw.get("reason")
94
+ if kind not in ALLOWLISTABLE_KINDS or not token or not reason:
95
+ return None
96
+ expires = _parse_expiry(raw.get("expires"))
97
+ if expires is None:
98
+ return None
99
+ destinations = raw.get("destinations", [])
100
+ if not isinstance(destinations, list):
101
+ return None
102
+ return AllowlistEntry(
103
+ kind=str(kind),
104
+ token=str(token),
105
+ reason=str(reason),
106
+ expires=expires,
107
+ destinations=tuple(str(d) for d in destinations),
108
+ )
109
+
110
+
111
+ def _parse_expiry(value: object) -> datetime | None:
112
+ """A usable, timezone-aware expiry — or None (entry dropped).
113
+
114
+ An entry with no expiry, a malformed one, or a naive timestamp is
115
+ invalid: an exception that cannot expire is a permanent hole.
116
+ """
117
+ if not isinstance(value, str):
118
+ return None
119
+ try:
120
+ expires = datetime.fromisoformat(value)
121
+ except ValueError:
122
+ return None
123
+ return expires if expires.tzinfo is not None else None
@@ -0,0 +1,127 @@
1
+ """Hashed egress audit trail — evidence of every decision, leak of none.
2
+
3
+ One JSONL line per ``evaluate`` outcome (allow AND deny) in
4
+ ``~/.arkaos/egress/audit.jsonl`` (dir 0700, file 0600); a failed
5
+ write is reported to the caller rather than swallowed. The line
6
+ carries digests and finding KINDS with salted-hash tokens — never the
7
+ payload, never a client name, never a secret value, never a raw path.
8
+ The audit file itself must be safe to read aloud.
9
+
10
+ ``record`` returns False instead of raising: the caller
11
+ (``policy.evaluate``) treats a failed audit as fail-closed — an ALLOW
12
+ that cannot be audited flips to denied, and the flip itself is
13
+ re-recorded best-effort.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import hmac
20
+ import json
21
+ import os
22
+ import time
23
+ from datetime import UTC, datetime
24
+ from pathlib import Path
25
+
26
+
27
+ def default_audit_path(home: Path | None = None) -> Path:
28
+ return (home or Path.home()) / ".arkaos" / "egress" / "audit.jsonl"
29
+
30
+
31
+ def default_salt_path(home: Path | None = None) -> Path:
32
+ return (home or Path.home()) / ".arkaos" / "egress" / ".audit-salt"
33
+
34
+
35
+ def load_or_create_salt(path: Path) -> bytes:
36
+ """Per-install random salt for token hashing; created on first use.
37
+
38
+ The finding tokens this package hashes are LOW-ENTROPY and
39
+ enumerable — the client list, the fixed secret-label vocabulary,
40
+ paths under a known home — so an unsalted digest is a
41
+ confirm-a-guess lookup for anyone holding the candidate list (QG
42
+ D1 r2, Francisca M1: reversed in microseconds). Keyed with a salt,
43
+ the tokens confirm nothing to a reader who holds only the audit
44
+ file. A salt that cannot be read or created degrades to ``b""`` —
45
+ hashes stay raw-material-free; only the guess-confirmation
46
+ resistance is lost. An EMPTY read is treated as not-yet-created (a
47
+ concurrent creator's file exists before its bytes land — QG D1 r3
48
+ F-M1), so the create path retries briefly before degrading.
49
+ """
50
+ data = _read_salt(path)
51
+ if data:
52
+ return data
53
+ created = _create_salt(path)
54
+ if created is not None:
55
+ return created
56
+ for _ in range(3): # a concurrent creator may have won; its write
57
+ time.sleep(0.005) # may be in flight — bounded retry, degrade
58
+ data = _read_salt(path)
59
+ if data:
60
+ return data
61
+ return b""
62
+
63
+
64
+ def _create_salt(path: Path) -> bytes | None:
65
+ """The new salt, or None when the file already exists (concurrent
66
+ creator) or the filesystem refuses."""
67
+ salt = os.urandom(16)
68
+ try:
69
+ path.parent.mkdir(parents=True, exist_ok=True)
70
+ os.chmod(path.parent, 0o700)
71
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
72
+ with os.fdopen(fd, "wb") as handle:
73
+ handle.write(salt)
74
+ return salt
75
+ except OSError:
76
+ return None
77
+
78
+
79
+ def _read_salt(path: Path) -> bytes:
80
+ try:
81
+ return path.read_bytes()
82
+ except OSError:
83
+ return b""
84
+
85
+
86
+ def hash_token(token: str, salt: bytes = b"") -> str:
87
+ """Keyed 16-hex digest for a finding token.
88
+
89
+ Long enough to correlate entries across lines, and it carries no
90
+ raw material. With the per-install salt it is also not a
91
+ confirm-a-guess oracle: the tokens are low-entropy (client names,
92
+ secret labels, home paths), so an UNSALTED truncated hash would
93
+ confirm a candidate for anyone who already holds the list.
94
+ """
95
+ payload = token.encode("utf-8", errors="surrogatepass")
96
+ return hmac.new(salt, payload, hashlib.sha256).hexdigest()[:16]
97
+
98
+
99
+ def record(entry: dict, path: Path, now: datetime | None = None) -> bool:
100
+ """Append one audit line; True on durable success, False otherwise.
101
+
102
+ The catch is broad by design: any failure — filesystem OR a
103
+ payload that will not serialize — reads as "not audited", and the
104
+ caller flips an unaudited ALLOW to denied. ``os.chmod(path, ...)``
105
+ runs on every write, not only at creation, so a pre-existing file
106
+ with looser permissions is repaired rather than trusted (QG D1 r1
107
+ M2).
108
+ """
109
+ try:
110
+ stamped = {
111
+ "ts": (now or datetime.now(UTC)).isoformat(),
112
+ **entry,
113
+ }
114
+ line = json.dumps(stamped, ensure_ascii=False, sort_keys=True)
115
+ path.parent.mkdir(parents=True, exist_ok=True)
116
+ os.chmod(path.parent, 0o700)
117
+ fd = os.open(
118
+ path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600
119
+ )
120
+ with os.fdopen(fd, "a", encoding="utf-8") as handle:
121
+ os.chmod(path, 0o600)
122
+ handle.write(line + "\n")
123
+ handle.flush()
124
+ os.fsync(handle.fileno())
125
+ return True
126
+ except Exception:
127
+ return False
@@ -0,0 +1,261 @@
1
+ """Deny-by-default egress policy — the guard every upload passes.
2
+
3
+ Check order, each one sufficient to deny:
4
+
5
+ 1. **Redaction, fail-closed** — no redaction config on disk means the
6
+ payload cannot be proven clean, so it does not leave (operator
7
+ directive 2026-07-26). What leaves is always the REDACTED text.
8
+ 2. **Residual client identifiers** — the redacted text is re-scanned
9
+ with the same pattern source; any hit means redaction failed.
10
+ Never allowlistable.
11
+ 3. **Secrets** — ``secret_labels`` vocabulary; allowlistable per exact
12
+ label, with expiry.
13
+ 4. **Operator home paths** — absolute paths under the operator's home
14
+ AND their tilde form, matched case-insensitively; both expose
15
+ local project structure; allowlistable per exact path.
16
+ 5. **Audit** — every decision is recorded; an ALLOW that cannot be
17
+ audited flips to denied (``audit-unavailable``).
18
+
19
+ ``evaluate`` never raises. ``enforce`` is the raising convenience for
20
+ call sites that want the guard inline: redacted text back, or
21
+ :class:`EgressDeniedError`.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import hashlib
27
+ import re
28
+ from dataclasses import dataclass, field
29
+ from datetime import datetime
30
+ from pathlib import Path
31
+
32
+ from core.egress import allowlist, audit, redact
33
+ from core.governance.harness_scanner import secret_labels
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Finding:
38
+ """One reason a payload may not leave."""
39
+
40
+ kind: str # redaction-config-missing | redaction-failed
41
+ # # | client-identifier | secret | home-path
42
+ # # | audit-unavailable | payload-not-text | guard-failure
43
+ token: str
44
+
45
+ def to_audit(self, salt: bytes = b"") -> dict:
46
+ return {
47
+ "kind": self.kind,
48
+ "token_sha16": (
49
+ audit.hash_token(self.token, salt) if self.token else ""
50
+ ),
51
+ }
52
+
53
+
54
+ @dataclass
55
+ class EgressDecision:
56
+ """Outcome of one egress evaluation."""
57
+
58
+ allowed: bool
59
+ destination: str
60
+ payload_sha256: str
61
+ findings: list[Finding] = field(default_factory=list)
62
+ allowlisted: list[Finding] = field(default_factory=list)
63
+ redacted_text: str | None = None
64
+ redacted_sha256: str = ""
65
+ audited: bool = False
66
+
67
+ def to_audit(self, salt: bytes = b"") -> dict:
68
+ return {
69
+ "allowed": self.allowed,
70
+ "destination": self.destination,
71
+ "payload_sha256": self.payload_sha256,
72
+ "redacted_sha256": self.redacted_sha256,
73
+ "findings": [f.to_audit(salt) for f in self.findings],
74
+ "allowlisted": [f.to_audit(salt) for f in self.allowlisted],
75
+ }
76
+
77
+
78
+ class EgressDeniedError(RuntimeError):
79
+ """Raised by ``enforce`` when the payload may not leave."""
80
+
81
+ def __init__(self, decision: EgressDecision):
82
+ reasons = ", ".join(f.kind for f in decision.findings) or "denied"
83
+ super().__init__(
84
+ f"egress to {decision.destination} denied: {reasons} "
85
+ f"(audit: {'recorded' if decision.audited else 'FAILED'})"
86
+ )
87
+ self.decision = decision
88
+
89
+
90
+ def evaluate(
91
+ text: object,
92
+ destination: str,
93
+ *,
94
+ config_path: Path | None = None,
95
+ home: Path | None = None,
96
+ allowlist_path: Path | None = None,
97
+ audit_path: Path | None = None,
98
+ now: datetime | None = None,
99
+ ) -> EgressDecision:
100
+ """Judge one payload against the policy. Never raises.
101
+
102
+ ``home`` scopes the home-path check and the default allowlist /
103
+ audit locations; ``now`` pins allowlist expiry for tests.
104
+ """
105
+ destination = _safe_str(destination)
106
+ # Digest computed ONCE, before any failure path, so no handler
107
+ # ever re-executes the operation that failed (QG D1 r2 E-B1).
108
+ payload_digest = _sha256(text) if isinstance(text, str) else ""
109
+ try:
110
+ decision = _judge(
111
+ text, destination, config_path, home, allowlist_path, now
112
+ )
113
+ except Exception as exc: # never-raises boundary — deny, not crash
114
+ decision = EgressDecision(
115
+ allowed=False, destination=destination,
116
+ payload_sha256=payload_digest,
117
+ findings=[Finding("guard-failure", type(exc).__name__)],
118
+ )
119
+ return _audited(decision, home, audit_path, now)
120
+
121
+
122
+ def _safe_str(value: object) -> str:
123
+ try:
124
+ return str(value)
125
+ except Exception:
126
+ return f"<unprintable {type(value).__name__}>"
127
+
128
+
129
+ def _audited(
130
+ decision: EgressDecision,
131
+ home: Path | None,
132
+ audit_path: Path | None,
133
+ now: datetime | None,
134
+ ) -> EgressDecision:
135
+ try:
136
+ target = audit_path or audit.default_audit_path(home)
137
+ salt = audit.load_or_create_salt(audit.default_salt_path(home))
138
+ except Exception: # a home that cannot even form paths (M5)
139
+ target, salt = None, b""
140
+ decision.audited = target is not None and audit.record(
141
+ decision.to_audit(salt), target, now
142
+ )
143
+ if decision.allowed and not decision.audited:
144
+ # No audit trail, no egress — record the flip best-effort.
145
+ decision.allowed = False
146
+ decision.redacted_text = None
147
+ decision.findings.append(Finding("audit-unavailable", ""))
148
+ if target is not None:
149
+ audit.record(decision.to_audit(salt), target, now)
150
+ return decision
151
+
152
+
153
+ def enforce(text: object, destination: str, **kwargs) -> str:
154
+ """The redacted text cleared to leave, or :class:`EgressDeniedError`."""
155
+ decision = evaluate(text, destination, **kwargs)
156
+ if not decision.allowed or decision.redacted_text is None:
157
+ raise EgressDeniedError(decision)
158
+ return decision.redacted_text
159
+
160
+
161
+ def _judge(
162
+ text: object,
163
+ destination: str,
164
+ config_path: Path | None,
165
+ home: Path | None,
166
+ allowlist_path: Path | None,
167
+ now: datetime | None,
168
+ ) -> EgressDecision:
169
+ if not isinstance(text, str):
170
+ return EgressDecision(
171
+ allowed=False, destination=destination, payload_sha256="",
172
+ findings=[Finding("payload-not-text", type(text).__name__)],
173
+ )
174
+ decision = EgressDecision(
175
+ allowed=False, destination=destination,
176
+ payload_sha256=_sha256(text),
177
+ )
178
+ clean, failure = _redacted(text, config_path)
179
+ if failure is not None:
180
+ decision.findings.append(failure)
181
+ return decision
182
+ _collect_findings(
183
+ decision, clean, destination, config_path, home, allowlist_path, now
184
+ )
185
+ if not decision.findings:
186
+ decision.allowed = True
187
+ decision.redacted_text = clean
188
+ decision.redacted_sha256 = _sha256(clean)
189
+ return decision
190
+
191
+
192
+ def _sha256(text: str) -> str:
193
+ """Total over any str — surrogatepass, because a payload holding a
194
+ lone surrogate (routine from errors="surrogateescape" decoding or
195
+ json.loads of an escape) must be DIGESTIBLE to be denied with an
196
+ audit line (QG D1 r2 E-B1)."""
197
+ payload = text.encode("utf-8", errors="surrogatepass")
198
+ return hashlib.sha256(payload).hexdigest()
199
+
200
+
201
+ def _redacted(
202
+ text: str, config_path: Path | None
203
+ ) -> tuple[str | None, Finding | None]:
204
+ """``(clean_text, None)`` or ``(None, denial_finding)``.
205
+
206
+ A redaction that CRASHES proves nothing about the payload — same
207
+ posture as a missing config: denied, never allowlistable (QG D1
208
+ r1 B1: U+017F KeyError, non-list clients config, latin-1 file).
209
+ """
210
+ try:
211
+ clean, _counts = redact.redact(text, config_path)
212
+ return clean, None
213
+ except redact.SanitizerConfigMissing:
214
+ return None, Finding("redaction-config-missing", "")
215
+ except Exception as exc:
216
+ return None, Finding("redaction-failed", type(exc).__name__)
217
+
218
+
219
+ def _collect_findings(
220
+ decision: EgressDecision,
221
+ clean: str,
222
+ destination: str,
223
+ config_path: Path | None,
224
+ home: Path | None,
225
+ allowlist_path: Path | None,
226
+ now: datetime | None,
227
+ ) -> None:
228
+ for token in redact.residual_identifiers(clean, config_path):
229
+ # Never allowlistable — a residual identifier is a redaction
230
+ # failure, not an operator choice.
231
+ decision.findings.append(Finding("client-identifier", token))
232
+ entries = allowlist.active_entries(
233
+ allowlist_path or allowlist.default_allowlist_path(home), now
234
+ )
235
+ candidates = [
236
+ Finding("secret", label) for label in secret_labels(clean)
237
+ ] + [Finding("home-path", p) for p in _home_paths(clean, home)]
238
+ for finding in candidates:
239
+ cleared = allowlist.permits(
240
+ entries, finding.kind, finding.token, destination
241
+ )
242
+ (decision.allowlisted if cleared else decision.findings).append(
243
+ finding
244
+ )
245
+
246
+
247
+ def _home_paths(text: str, home: Path | None) -> list[str]:
248
+ """Distinct operator-home paths found in *text*.
249
+
250
+ Matches the absolute form AND the ``~/`` tilde form, case-
251
+ insensitively — macOS filesystems resolve case variants to the
252
+ same file, and a tilde path names the home just as precisely
253
+ (QG D1 r1 M3). Over-capture of trailing punctuation is accepted:
254
+ it is the deny direction.
255
+ """
256
+ root = re.escape(str(home or Path.home()))
257
+ pattern = re.compile(
258
+ rf"(?:{root}(?:/[^\s'\"`)\]]*)?|~/[^\s'\"`)\]]*)",
259
+ re.IGNORECASE,
260
+ )
261
+ return sorted({m.group(0) for m in pattern.finditer(text)})
@@ -0,0 +1,46 @@
1
+ """Fail-closed redaction for egress payloads.
2
+
3
+ Thin, deliberate wrapper: the identifier list and the replacement
4
+ semantics belong to ``core.evals.sanitizer`` (one source of truth with
5
+ the release leak-scanner), and this module adds only the egress-side
6
+ contract — redaction that cannot be skipped, plus a residual check
7
+ that is DELIBERATELY looser than the sanitizer, so a payload the
8
+ sanitizer cannot fully redact is denied instead of leaking.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+
15
+ from core.evals.sanitizer import SanitizerConfigMissing, sanitize_text
16
+ from core.governance.leak_scanner import load_redaction_patterns
17
+
18
+ __all__ = ["SanitizerConfigMissing", "redact", "residual_identifiers"]
19
+
20
+
21
+ def redact(text: str, config_path: Path | None = None) -> tuple[str, dict[str, int]]:
22
+ """Redact client identifiers; ``(clean_text, placeholder_counts)``.
23
+
24
+ Raises :class:`SanitizerConfigMissing` when the redaction config is
25
+ absent or empty — the caller (``policy.evaluate``) converts that
26
+ into a denial, never into a pass-through.
27
+ """
28
+ return sanitize_text(text, config_path=config_path)
29
+
30
+
31
+ def residual_identifiers(text: str, config_path: Path | None = None) -> list[str]:
32
+ """Configured identifiers still present in *text*, as substrings.
33
+
34
+ Defence in depth only counts when the second layer differs from
35
+ the first (QG D1 r1, Francisca B2): the sanitizer replaces
36
+ word-boundary matches, so a compound token like ``<client>2026``
37
+ survives redaction untouched. This check is therefore
38
+ SUBSTRING-based — no boundaries, case-insensitive. Any hit on the
39
+ redacted text denies; the false-positive cost of the looser match
40
+ is a denial, never a leak.
41
+ """
42
+ patterns = load_redaction_patterns(config_path)
43
+ if not patterns:
44
+ return []
45
+ lowered = text.lower()
46
+ return [p for p in patterns if p in lowered]