hermes(hux): force restricted sensitivity on secret-bearing artifact content
User artifacts are never rewritten, but a secret pattern in a version marks the artifact restricted and audits the reason (review a2-C, SO-12). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RNPhwu2bsaRNg3DETSAZoM
This commit is contained in:
parent
681b040885
commit
a75ca29934
@ -130,6 +130,17 @@ def _string(body: dict[str, Any], key: str, limit: int, required: bool = True) -
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def secret_hits(data: bytes) -> list[str]:
|
||||||
|
"""Secret-pattern classes found in text content; binary content is not scanned (SO-12)."""
|
||||||
|
from hux.redaction import scrub_text
|
||||||
|
|
||||||
|
try:
|
||||||
|
text = data.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
return []
|
||||||
|
return scrub_text(text)[1]
|
||||||
|
|
||||||
|
|
||||||
def _content(body: dict[str, Any]) -> tuple[bytes, str]:
|
def _content(body: dict[str, Any]) -> tuple[bytes, str]:
|
||||||
"""Decode the submitted content and return ``(bytes, mime)``."""
|
"""Decode the submitted content and return ``(bytes, mime)``."""
|
||||||
text, encoded = body.get("content"), body.get("content_base64")
|
text, encoded = body.get("content"), body.get("content_base64")
|
||||||
@ -243,6 +254,9 @@ def create(request: Request) -> Response:
|
|||||||
sensitivity = body.get("sensitivity", "personal")
|
sensitivity = body.get("sensitivity", "personal")
|
||||||
if sensitivity not in SENSITIVITIES:
|
if sensitivity not in SENSITIVITIES:
|
||||||
raise Invalid("unknown sensitivity")
|
raise Invalid("unknown sensitivity")
|
||||||
|
hits = secret_hits(_content(body)[0])
|
||||||
|
if hits:
|
||||||
|
sensitivity = "restricted"
|
||||||
stamp = now_iso()
|
stamp = now_iso()
|
||||||
version: dict[str, Any] = {"version": 1, "created_at": stamp, "created_by": _actor(request.identity), "content_ref": _store_content(request.store, body)}
|
version: dict[str, Any] = {"version": 1, "created_at": stamp, "created_by": _actor(request.identity), "content_ref": _store_content(request.store, body)}
|
||||||
for field, limit in (("message_id", 120), ("note", 200)):
|
for field, limit in (("message_id", 120), ("note", 200)):
|
||||||
@ -273,7 +287,7 @@ def create(request: Request) -> Response:
|
|||||||
_check(artifact)
|
_check(artifact)
|
||||||
stored = request.store.put(FAMILY, artifact)
|
stored = request.store.put(FAMILY, artifact)
|
||||||
remember(request.store, FAMILY, key, stored["id"])
|
remember(request.store, FAMILY, key, stored["id"])
|
||||||
request.audit("artifacts.create", stored["id"])
|
request.audit("artifacts.create", stored["id"], reason="secret_pattern_restricted" if hits else "")
|
||||||
emit_event(request.store, request.identity, stored, "artifact.created", f"Created {stored['type']} artifact", {"artifact_id": stored["id"], "version": 1})
|
emit_event(request.store, request.identity, stored, "artifact.created", f"Created {stored['type']} artifact", {"artifact_id": stored["id"], "version": 1})
|
||||||
return Response(201, stored, {"ETag": str(stored["revision"])})
|
return Response(201, stored, {"ETag": str(stored["revision"])})
|
||||||
|
|
||||||
@ -318,6 +332,9 @@ def add_version(request: Request) -> Response:
|
|||||||
if expected != artifact["revision"]:
|
if expected != artifact["revision"]:
|
||||||
raise Conflict(f"revision {expected} does not match current revision {artifact['revision']}", [str(artifact["revision"])])
|
raise Conflict(f"revision {expected} does not match current revision {artifact['revision']}", [str(artifact["revision"])])
|
||||||
number = artifact["current_version"] + 1
|
number = artifact["current_version"] + 1
|
||||||
|
hits = secret_hits(_content(body)[0])
|
||||||
|
if hits:
|
||||||
|
artifact = {**artifact, "sensitivity": "restricted"}
|
||||||
entry: dict[str, Any] = {"version": number, "created_at": now_iso(), "created_by": _actor(request.identity), "content_ref": _store_content(request.store, body), "diff_from": artifact["current_version"]}
|
entry: dict[str, Any] = {"version": number, "created_at": now_iso(), "created_by": _actor(request.identity), "content_ref": _store_content(request.store, body), "diff_from": artifact["current_version"]}
|
||||||
if body.get("diff_from") is not None:
|
if body.get("diff_from") is not None:
|
||||||
entry["diff_from"] = _version(artifact, body["diff_from"])["version"]
|
entry["diff_from"] = _version(artifact, body["diff_from"])["version"]
|
||||||
@ -329,7 +346,7 @@ def add_version(request: Request) -> Response:
|
|||||||
entry["lineage"] = lineage
|
entry["lineage"] = lineage
|
||||||
stored = append_version(request.store, artifact, entry, expected)
|
stored = append_version(request.store, artifact, entry, expected)
|
||||||
remember(request.store, FAMILY, key, f"{stored['id']}@{number}")
|
remember(request.store, FAMILY, key, f"{stored['id']}@{number}")
|
||||||
request.audit("artifacts.version", f"{stored['id']}@{number}")
|
request.audit("artifacts.version", f"{stored['id']}@{number}", reason="secret_pattern_restricted" if hits else "")
|
||||||
emit_event(request.store, request.identity, stored, "artifact.version", f"New version {number}", {"artifact_id": stored["id"], "version": number})
|
emit_event(request.store, request.identity, stored, "artifact.version", f"New version {number}", {"artifact_id": stored["id"], "version": number})
|
||||||
return Response(201, stored, {"ETag": str(stored["revision"])})
|
return Response(201, stored, {"ETag": str(stored["revision"])})
|
||||||
|
|
||||||
|
|||||||
@ -128,3 +128,19 @@ def test_audit_rows_name_family_verbs(router, artifact):
|
|||||||
rows = audit.recent(store.TenantStore(router.data_root, identity.Identity("slot-3", "usr_0123456789abcdef", "chat", "router")))
|
rows = audit.recent(store.TenantStore(router.data_root, identity.Identity("slot-3", "usr_0123456789abcdef", "chat", "router")))
|
||||||
assert [r["action"] for r in rows] == ["artifacts.create", "artifacts.get"]
|
assert [r["action"] for r in rows] == ["artifacts.create", "artifacts.get"]
|
||||||
assert all(contracts.validate("common.schema.json", r, SCHEMAS, "/$defs/audit_outcome") == [] for r in rows)
|
assert all(contracts.validate("common.schema.json", r, SCHEMAS, "/$defs/audit_outcome") == [] for r in rows)
|
||||||
|
|
||||||
|
|
||||||
|
def test_secret_bearing_content_is_kept_but_forced_restricted(router):
|
||||||
|
"""Review a2-C / SO-12: user artifacts are not rewritten, but a secret pattern forces restricted sensitivity and is audited."""
|
||||||
|
token = "ghp_" + "A" * 36
|
||||||
|
status, record = call(router, "POST", "/hux/v1/artifacts", {"type": "code", "title": "env", "content": f"TOKEN={token}", "sensitivity": "personal"})
|
||||||
|
assert status == 201 and record["sensitivity"] == "restricted"
|
||||||
|
status, version = call(router, "GET", f"/hux/v1/artifacts/{record['id']}/versions/1")
|
||||||
|
assert status == 200 and token in version["content"]
|
||||||
|
clean = call(router, "POST", "/hux/v1/artifacts", {"type": "code", "title": "clean", "content": "x = 1"})[1]
|
||||||
|
assert clean["sensitivity"] == "personal"
|
||||||
|
status, bumped = call(router, "POST", f"/hux/v1/artifacts/{clean['id']}/versions", {"content": f"key: {token}"}, {**OWNER, "If-Match": "1"})
|
||||||
|
assert status == 201 and bumped["sensitivity"] == "restricted"
|
||||||
|
from hux import audit, store
|
||||||
|
reasons = [r.get("reason") for r in audit.recent(store.TenantStore(router.data_root, identity.resolve(OWNER, {}))) if r["action"].startswith("artifacts.")]
|
||||||
|
assert reasons.count("secret_pattern_restricted") == 2
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user