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.
- package/THE-ARKAOS-GUIDE.md +1 -1
- package/VERSION +1 -1
- package/core/egress/__init__.py +20 -0
- package/core/egress/allowlist.py +123 -0
- package/core/egress/audit.py +127 -0
- package/core/egress/policy.py +261 -0
- package/core/egress/redact.py +46 -0
- package/core/governance/evidence_checks.py +116 -37
- package/core/harness/__init__.py +22 -0
- package/core/harness/drift.py +317 -0
- package/core/harness/json_store.py +119 -0
- package/core/harness/manifest.py +87 -0
- package/core/harness/paths.py +72 -0
- package/core/harness/spec.py +182 -0
- package/harness/codex/AGENTS.md +1 -1
- package/harness/copilot/copilot-instructions.md +1 -1
- package/harness/cursor/rules/arkaos.mdc +2 -2
- package/harness/gemini/GEMINI.md +1 -1
- package/harness/opencode/AGENTS.md +1 -1
- package/harness/opencode/agents/arka-architect-gabriel.md +1 -1
- package/harness/opencode/agents/arka-brand-director-valentina.md +1 -1
- package/harness/opencode/agents/arka-cfo-helena.md +1 -1
- package/harness/opencode/agents/arka-chief-of-staff-afonso.md +1 -1
- package/harness/opencode/agents/arka-community-strategist-beatriz.md +1 -1
- package/harness/opencode/agents/arka-content-strategist-rafael.md +1 -1
- package/harness/opencode/agents/arka-conversion-strategist-ines.md +1 -1
- package/harness/opencode/agents/arka-coo-sofia.md +1 -1
- package/harness/opencode/agents/arka-copy-director-eduardo.md +1 -1
- package/harness/opencode/agents/arka-cqo-marta.md +1 -1
- package/harness/opencode/agents/arka-cto-marco.md +1 -1
- package/harness/opencode/agents/arka-design-ops-lead-iris.md +1 -1
- package/harness/opencode/agents/arka-ecom-director-ricardo.md +1 -1
- package/harness/opencode/agents/arka-knowledge-director-clara.md +1 -1
- package/harness/opencode/agents/arka-leadership-director-rodrigo.md +1 -1
- package/harness/opencode/agents/arka-marketing-director-luna.md +1 -1
- package/harness/opencode/agents/arka-ops-lead-daniel.md +1 -1
- package/harness/opencode/agents/arka-pm-director-carolina.md +1 -1
- package/harness/opencode/agents/arka-revops-lead-vicente.md +1 -1
- package/harness/opencode/agents/arka-saas-strategist-tiago.md +1 -1
- package/harness/opencode/agents/arka-sales-director-miguel.md +1 -1
- package/harness/opencode/agents/arka-strategy-director-tomas.md +1 -1
- package/harness/opencode/agents/arka-tech-director-francisca.md +1 -1
- package/harness/opencode/agents/arka-tech-lead-paulo.md +1 -1
- package/harness/opencode/agents/arka-video-producer-simao.md +1 -1
- package/harness/zed/.rules +1 -1
- package/knowledge/skills-manifest.json +1 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
package/THE-ARKAOS-GUIDE.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# The ArkaOS Guide
|
|
2
2
|
|
|
3
|
-
> v4.
|
|
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.
|
|
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]
|