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
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,45 @@
# Changelog

## [1.2.46] - 2026-10-09

### Security
- Human-boundary v2 (t_c91076d0, delta of t_e7e74526 + t_25908a44),
refreshed onto the released v1.2.36 base including the live per-class
token caps (refresh t_f68e8bed; v2 semantics unchanged). Pin 1.2.46 is
the next collision-free version per the v1.2.29 guard contract:
1.2.37/1.2.38/1.2.39/1.2.40/1.2.41/1.2.42/1.2.44/1.2.45 are pinned by
open PR heads and 1.2.43 is announced in an open PR title (pin drift),
so 1.2.46 is the first value free of both pins and announced claims.
Class X1 updater boundary — any invocation, inspection, planning
or dependency on the external auto-updater is denied before any other
classification (``updater_dependency``); detection is word-bounded
over tool names, terminal commands/workdirs and path/URL arguments,
prose describing the boundary stays writable.
- Decision-namespace forgery guard: board free-text writes (kanban bodies,
comments, reasons) authored by a worker context cannot mint company or
owner authority — the 2026-09-04 worker-comment company-binding
regression class is closed at the classifier level.
- Hard-gate bindings carry the full ADR-001 v2 tuple: nonce (issued at
approve), expires_at (24h TTL, enforced at consumption; an expired grant
is refused, surfaced as expired, and re-armed as a fresh pending cycle),
principal_ref fingerprint, decision channel, amount_rub and scope
digest; legacy rows keep v1 semantics (NULL expiry = never expires).

### Added
- A4 non-blocking product review path: ordinary product/UX/brand-risk
changes (exact lexical vocabulary) queue one ``a4_review_notice`` event
and proceed — review never blocks execution (ADR-001 v2 section 4).
- ``tests/test_v1246_human_boundary_v2.py``: 23 contract tests — X1
boundaries (tool/command/workdir vs prose), decision-namespace forgery,
binding tuple fields, legacy compatibility, nonce rotation on
expiry-at-consume, consume-once, A4 non-blocking + single notice,
revocation immutability, worker decide guard, v2 schema/heal.

### Docs
- ``docs/FLEET_POLICY.md``: owner principal + binding tuple + X1 boundary +
A4 review path documented alongside the mandate (same-PR doc sync per
ADR-001 consequences).

## [1.2.36] - 2026-10-04

### Added
Expand Down
11 changes: 11 additions & 0 deletions docs/FLEET_POLICY.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,17 @@ Exact Kanban comments from independent roles unlock routine actions:

Missing gates block the task with actionable diagnostics; they do not create a user approval request.

## Owner principal, hard-gate bindings and boundary classes (ADR-001 v2)

Serious-class approvals are exact one-time bindings owned by an authenticated owner principal. Since v1.2.18:

- binding tuple: every pending approval row carries `nonce` (issued at approve), `expires_at` (24h TTL, enforced at consumption; an expired grant is refused and re-armed as a fresh pending cycle), `principal_ref` (sha256 fingerprint of owner user/direct-chat ids — never the raw ids), decision `channel`, `amount_rub` and `scope` digest. Legacy rows keep v1 semantics (`NULL` expiry = never expires);
- decision-namespace forge guard: worker board writes (card bodies, comments, reasons) cannot mint company or owner authority markers — such a write is denied at the classifier (`decision_namespace_forgery`). Repo docs/ADRs may still quote the markers;
- class X1: any invocation, inspection, planning or dependency on the external auto-updater is denied before any other classification (`updater_dependency`). Detection is word-bounded over tool names, terminal commands/workdirs and path/URL arguments, so prose describing the boundary stays writable;
- A4 ordinary product/UX/brand risk is non-blocking: the call queues exactly one `a4_review_notice` event and proceeds; the owner may veto within the review window. A4 review never blocks execution.

`APPROVALS.md` remains the protected canonical escalation list; this section mirrors its wording for the classifier and cannot drift without a doc-sync PR.

## Finance and capabilities

A financial tool call must provide `amount_rub` and `capability_id`. Capabilities are one-time owner grants:
Expand Down
2 changes: 1 addition & 1 deletion integrations/hermes/fleet-policy-plugin/plugin.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: fleet-policy
version: "1.2.36"
version: "1.2.46"
description: "Fail-closed policy gate, budgets, loop guard, and event log for the universal Hermes fleet."
author: "company"
hooks:
Expand Down
2 changes: 1 addition & 1 deletion plugin.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: fleet-policy
version: "1.2.36"
version: "1.2.46"
description: "Fail-closed policy gate, budgets, loop guard, and event log for the universal Hermes fleet."
author: "company"
provides_hooks:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "ventures-fleet-policy"
version = "1.2.36"
version = "1.2.46"
requires-python = ">=3.11"
dependencies = ["PyYAML>=6,<7"]

Expand Down
2 changes: 1 addition & 1 deletion src/fleet_policy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@
from .models import PolicyDecision
from .runtime import FleetPolicyRuntime

__version__ = "1.2.36"
__version__ = "1.2.46"

__all__ = ["FleetPolicyRuntime", "PolicyDecision", "__version__"]
86 changes: 86 additions & 0 deletions src/fleet_policy/policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -971,6 +971,60 @@ def _sqlite_stage_is_read_only(tokens: list[str]) -> bool:
)


# ADR-001 v2 section 6 — class X1 updater boundary. The external Hermes
# auto-updater is out of fleet scope: agents never invoke, inspect, plan, or
# depend on it. Detection is lexical over command/target text and argument
# values, word-bounded so prose merely DESCRIBING the boundary stays safe.
_UPDATER_PROGRAMS = frozenset({"hermes-updater", "updater"})
_UPDATER_WORDS_RE = re.compile(
r"(?<![\w.-])(?:hermes[-_ ]updater|auto[-_]?update[r]?|updater\.exe)(?![\w-])"
)
_UPDATER_SUBCOMMAND_RE = re.compile(r"(?<![\w.-])hermes\s+(?:updater|update|upgrade)(?![\w-])")
_UPDATER_APPDIR_MARKERS = ("hermes", ".hermes")
_UPDATER_APPDIR_SUBDIRS = ("updates", "update")

# ADR-001 v2 section 2 — decision-namespace authority markers. A comment or
# other board free-text write carrying these prefixes is an attempt to author
# company/owner authority; only the matching principal (never a generic
# worker) may mint them. Word-bounded so prose quoting history stays safe.
_DECISION_PREFIX_RE = re.compile(r"(?<![\w-])(decision:company|decision:owner)\s*=", re.I)
_BOARD_TEXT_KEYS = frozenset({"body", "reason", "message", "summary", "result", "title", "comment"})


def _mentions_updater(text: str) -> bool:
lowered = text.lower()
if not lowered:
return False
if _UPDATER_WORDS_RE.search(lowered) or _UPDATER_SUBCOMMAND_RE.search(lowered):
return True
# Path dependency: any operand reaching into the updater's own
# application directory (.../<hermes|\.hermes>/<updates|update>/...).
if any(marker in lowered for marker in _UPDATER_APPDIR_MARKERS) and any(
f"/{part}/" in lowered or f"\\{part}\\" in lowered
for part in _UPDATER_APPDIR_SUBDIRS
):
return True
return False


def _is_updater_call(name: str, arguments: dict[str, Any]) -> bool:
"""Class X1: updater invocation, inspection, planning or dependency.

Action-bearing carriers only: the tool name itself, terminal commands
(command/workdir), and path- or URL-shaped arguments of other tools.
Prose in free-text content bodies merely DESCRIBING the boundary is not
an updater interaction (docs stay writable)."""
if "updater" in name:
return True
if name in TERMINAL_TOOLS:
values = [arguments.get(key) for key in ("command", "cmd", "workdir")]
else:
values = [arguments.get(key) for key in ("path", "file_path", "workdir", "url",
"target", "file", "filename",
"dir", "directory", "module", "import")]
return _mentions_updater(" ".join(str(value) for value in values if value))


def _stage_is_read_only(segment: str) -> bool:
if READ_COMMAND.match(segment):
return True
Expand Down Expand Up @@ -1596,6 +1650,38 @@ def classify(tool_name: str, arguments: dict[str, Any], config: dict[str, Any],
):
return Classification("state_change", "policy_control_plane_mutation", "approval_required", "control-plane security changes require owner approval")

# ADR-001 v2 section 6 — class X1. Fleet agents never invoke, inspect,
# plan, or depend on the external auto-updater; any such call is out of
# fleet scope and denied before any other classification (kill criterion:
# an updater interaction appearing at all).
if _is_updater_call(name, arguments):
return Classification(
"state_change", "updater_dependency", "deny",
"the external auto-updater is out of fleet scope (class X1)",
)

# ADR-001 v2 section 2 — forged-input resistance. decision-namespace
# authority (company/owner) may be authored only by the matching
# principal; a dispatcher worker context can never mint it, and company
# authority cannot satisfy owner-only serious classes. Live regression
# 2026-09-04: a worker card comment wrote a company binding right after a
# serious-risk deny. Scoped to board free-text fields (comment/block/
# create bodies): repo file writes legitimately QUOTE the markers in
# docs/ADRs and stay writable; the board is where authority claims are
# minted.
if worker and name.startswith("kanban_"):
board_texts = " ".join(
str(value)
for key, value in arguments.items()
if key in _BOARD_TEXT_KEYS and value
)
match = _DECISION_PREFIX_RE.search(board_texts)
if match:
return Classification(
"state_change", "decision_namespace_forgery", "deny",
f"a worker cannot author {match.group(1)} authority; fail-closed per ADR-001 v2 section 2",
)

if effect == "read":
return Classification(effect, "read_only", "allow", "read-only action")

Expand Down
108 changes: 102 additions & 6 deletions src/fleet_policy/runtime.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,36 @@
from __future__ import annotations

from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
import os
import time
import re
from pathlib import Path
from typing import Any

from .config import load_config
from .models import PolicyDecision, remediation_for
from .policy import Classification, classify, infer_task_type, is_lifecycle_tool
from .policy import Classification, _normalize_tool_name, _is_updater_call, classify, infer_task_type, is_lifecycle_tool
from .redaction import args_hash, redact, stable_id
from .storage import PolicyStore, utc_now
from .storage import BINDING_TTL_HOURS, PolicyStore, iso_later_hours, utc_now

# ADR-001 v2 section 4 — ordinary product/UX/brand risk (class A4) lexicon.
# Exact word-bounded vocabulary; an unclassifiable call stays on the default
# path (fail-closed classification upstream), and NO A6 class is implied.
_A4_REVIEW_RE = re.compile(
r"\b(?:brand (?:refresh|update)|ui copy|visual style|logo variant|marketing copy|"
r"wording change|layout tweak|color palette)\b",
re.I,
)


def _risk_subject_for(tool_name: str, arguments: dict[str, Any]) -> str:
"""Subject text for the A4 lexicon scan: commands, targets and free-text
payloads (brand copy travels in write bodies, not only in commands)."""
if tool_name in {"terminal", "shell", "bash", "exec"}:
values = [arguments.get("command") or arguments.get("cmd") or ""]
else:
values = list(arguments.values())
return " ".join(str(value) for value in values if value)


class FleetPolicyRuntime:
Expand All @@ -29,6 +49,19 @@ def __init__(self, root: str | Path, *, config_path: str | Path | None = None,
self.store = PolicyStore(db_path or self.root / ".state" / "fleet-policy.db")
self.store.migrate()

def _owner_principal_ref(self) -> str:
"""ADR-001 v2 section 2: fingerprint of the configured Telegram owner
principal (gateway-mapped user + direct chat). The mapping itself
lives only in the gateway configuration, never in audit rows; an
unset mapping yields an empty fingerprint (binding stays grantable
via the TTY break-glass path, which decides channel='tty')."""
principal = self.config.get("owner_principal") or {}
if not isinstance(principal, dict):
return ""
user = str(principal.get("owner_user_id") or "")
chat = str(principal.get("direct_chat_id") or "")
return PolicyStore.principal_ref(user, chat) if user and chat else ""

def task_type(self, context: dict[str, Any]) -> tuple[str | None, str | None]:
return infer_task_type(context.get("task_body"), context.get("comments"), context.get("skills"))

Expand Down Expand Up @@ -310,7 +343,16 @@ def pre_tool_call(self, tool_name: str, arguments: dict[str, Any], context: dict
elif worker and task_error:
result = Classification("state_change", "missing_or_unknown_task_type", "deny", task_error)
else:
result = classify(tool_name, arguments, self.config, worker=worker)
# ADR-001 v2 section 6 — class X1: the external auto-updater is
# out of fleet scope. Invocation, inspection, planning, and
# dependency are all denied before any other classification runs.
if _is_updater_call(_normalize_tool_name(tool_name), arguments):
result = Classification(
"state_change", "updater_dependency", "deny",
"the external auto-updater is out of fleet scope (class X1)",
)
else:
result = classify(tool_name, arguments, self.config, worker=worker)
if (
worker
and context.get("task_status") == "blocked"
Expand Down Expand Up @@ -414,11 +456,65 @@ def pre_tool_call(self, tool_name: str, arguments: dict[str, Any], context: dict
elif self.store.consume_exact_approval(task_id, tool_name, target, hashed):
decision, rule_id, reason = "allow", "approved_once", "exact one-time approval consumed"
else:
# ADR-001 v2 section 2: hard-gate binding carries the full
# tuple (nonce, expiry, principal fingerprint, channel,
# amount, scope). The nonce is issued by decide_approval on
# the approved transition; expiry is enforced at consumption
# and a stale grant rotates into a fresh pending cycle.
amount = self._amount_rub(arguments) if self._amount_rub(arguments) else None
binding_extras = dict(
nonce="",
expires_at=iso_later_hours(BINDING_TTL_HOURS),
principal_ref=self._owner_principal_ref(),
channel="telegram",
amount_rub=amount,
scope=stable_id(result.category, task_id)[:32],
)
rule_key = stable_id(task_id, tool_name, target, hashed)
self.store.ensure_approval(rule_key, task_id, tool_name, target, hashed,
str(context.get("board") or ""))
if self.store.ensure_approval(rule_key, task_id, tool_name, target, hashed,
str(context.get("board") or ""), **binding_extras):
event_id = stable_id(task_id, tool_name, target, hashed, rule_id, "policy")
self.store.link_approval_notice(rule_key, event_id)
else:
# Binding exists already. If it was re-armed after an
# expiry-at-consume rotation (new pending row under the
# same key), re-bind the card fields; the stale approved
# row can never be consumed again, so keep surfacing the
# pending cycle to the owner instead of a silent loop.
self.store.rebind_pending_binding(rule_key, **binding_extras)
approval_card = self._approval_card(context, result.category, target, hashed, rule_key)

# ADR-001 v2 section 4 — ordinary product/UX/brand risk is NOT a hard
# gate: one non-blocking review notice is queued, the call proceeds,
# and the owner may veto within the review window (kill criterion:
# this path must never block execution). No A6 class exists; the
# classification is an exact lexical match on the A4 vocabulary.
if (
decision == "allow"
and result.effect == "state_change"
and _A4_REVIEW_RE.search(_risk_subject_for(tool_name, arguments))
and not is_lifecycle_tool(tool_name)
):
notice_id = stable_id(task_id, tool_name, target, hashed, "a4_review_notice")
self.store.record_event(
notice_id,
str(context.get("run_id") or context.get("session_id") or notice_id),
task_id or None,
"a4_review_notice",
{
"decision": "allow", "rule_id": "a4_product_review_notice",
"reason": "ordinary product risk: non-blocking owner review notice",
"task_id": task_id, "project": context.get("project", ""),
"profile": context.get("profile", ""), "action": tool_name,
"target": target, "args_hash": hashed, "timestamp": utc_now(),
"budget_snapshot": snapshot,
"task_status": str(context.get("task_status") or "unknown"),
"board": str(context.get("board") or ""),
"run_key": self._run_key(context) or "session",
},
True,
)

if decision == "allow" and spend and task_id:
amount, capability_id, project = spend
spend_id = stable_id(task_id, tool_call_id, "spend")
Expand Down
Loading
Loading