CtrlK
BlogDocsLog inGet started
Tessl Logo

jbaruch/nanoclaw-trusted

Rules for trusted NanoClaw groups. Shared memory, session bootstrap, cross-group memory updates. Loaded for trusted and main containers only.

76

Quality

96%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

High

Do not use without reviewing

Overview
Quality
Evals
Security
Files

google-rest.pyskills/google-ops/scripts/

"""Native Google REST transport for the OneCLI-brokered Google surfaces.

Replaces `composio-rest.py` (nanoclaw#638). Ships in the trusted
`google-ops` skill (mounted on main + trusted, `jbaruch/nanoclaw-admin#456`)
as the single HTTP primitive for Gmail / Calendar / Tasks — shared by the
trusted-tier ground-truth verification path (`google-calendar.py`,
`google-tasks.py`, its siblings here) and the admin Google-facing skills
(check-email, morning-brief, manage-tasks, …) that load it off this mount.

Credential model (the whole point of #638)
------------------------------------------
This container holds NO Google credential. OneCLI's TLS-MITM gateway owns
the OAuth connection: it injects `Authorization: Bearer` on the wire to
the Google API hosts and auto-refreshes the token via its per-provider
`RefreshConfig`. Callers therefore send NO auth header and read no key
from the environment. `COMPOSIO_API_KEY` / `COMPOSIO_USER_ID` are gone —
a request carrying its own Authorization header would be a bug, not a
fallback.

The gateway reaches this process via `HTTPS_PROXY` + the mounted CA
bundle, both set on the spawn by the orchestrator. Nothing here
configures the proxy; `urllib` honours `HTTPS_PROXY` from the
environment.

Failure modes this maps to actionable errors
--------------------------------------------
Under Composio a missing key surfaced as an actionable `MissingCredentials`.
The native path has no key to miss, but it has two failure modes that a
bare HTTP status hides:

  401 -> `GatewayNotInjecting`. Either the OneCLI gateway is not on this
         process's request path (no HTTPS_PROXY / CA on the spawn, so the
         request went straight to Google unauthenticated), or the Google
         app is not connected in the vault. Both are config errors an
         operator must fix; neither is transient, so callers must not
         retry them as if they were.
  403 with `access_restricted` -> `TierAccessRestricted`. The agent's
         OneCLI `secretMode` is `selective` — the untrusted tier is
         deliberately gated this way (#638) and gets no Google reach. This
         is correct behaviour, not a fault; it is raised distinctly so an
         untrusted-tier caller can report "not available at this tier"
         rather than alarm about a broken gateway.

Every other non-2xx propagates as `urllib.error.HTTPError` for the caller
to wrap into its own per-call error marker, matching composio-rest.py's
contract.

Contract
--------
google_request(method, url, *, params=None, body=None,
               timeout=PER_CALL_TIMEOUT_SECONDS) -> dict
    Issue a native Google REST call and return the parsed JSON object.
    `params` is url-encoded onto the query string; `body` is JSON-encoded.
    A 204 (or any empty body) returns {} — Calendar's DELETE answers 204
    with no content, and callers should not have to special-case that.
    Raises GatewayNotInjecting / TierAccessRestricted / HTTPError /
    URLError / TimeoutError / OSError / json.JSONDecodeError.

Env overrides
-------------
GOOGLE_API_BASES — JSON object remapping the per-surface base URLs. Tests
    point the surfaces at a local fixture server; production uses the
    defaults below. Unset in production.

No I/O beyond the HTTP call. This file is a function library, not a
script — there is no __main__ entry point.
"""

from __future__ import annotations

import io
import json
import os
import urllib.error
import urllib.parse
import urllib.request

# Per-surface API bases. These are the exact hosts OneCLI's app
# connections are bound to — changing a host here without adding the
# matching OneCLI app binding means the gateway won't inject a Bearer and
# the call 401s.
DEFAULT_API_BASES = {
    "gmail": "https://gmail.googleapis.com/gmail/v1",
    "calendar": "https://www.googleapis.com/calendar/v3",
    "tasks": "https://tasks.googleapis.com/tasks/v1",
    "drive": "https://www.googleapis.com/drive/v3",
}

# Per-call wall-clock budget — a real urlopen timeout, not an LLM
# judgement about wall-clock budget. Matches composio-rest.py's budget so
# the migration doesn't silently change per-call latency tolerance.
PER_CALL_TIMEOUT_SECONDS = 90.0

GATEWAY_NOT_INJECTING_HINT = (
    "the OneCLI gateway is not authenticating this request. Check that the spawn "
    "carries HTTPS_PROXY + the mounted CA (src/container-runner.ts), and that the "
    "Google app is still connected in the vault (`onecli apps list` on the NAS)"
)

TIER_ACCESS_RESTRICTED_HINT = (
    "this agent's OneCLI secretMode is 'selective' — the untrusted tier is gated "
    "from Google by design (nanoclaw#638). Google-backed skills do not run at this tier"
)


class GatewayNotInjecting(RuntimeError):
    """Google answered 401, so no Bearer reached it.

    Distinct from a transient failure: the gateway is either off this
    process's request path or the app is disconnected. Retrying cannot
    fix either. Callers at a process boundary print
    GATEWAY_NOT_INJECTING_HINT to stderr and exit non-zero.
    """


class TierAccessRestricted(RuntimeError):
    """The OneCLI gateway refused to inject for this agent's tier.

    Expected on the untrusted tier (secretMode=selective). Callers report
    "unavailable at this tier" rather than treating it as a fault.
    """


def api_bases():
    """Resolve the per-surface base URLs, applying the GOOGLE_API_BASES
    test override. Read per-call rather than at import so a test can set
    the override after this module is loaded."""
    override = os.environ.get("GOOGLE_API_BASES")
    if not override:
        return dict(DEFAULT_API_BASES)
    bases = dict(DEFAULT_API_BASES)
    bases.update(json.loads(override))
    return bases


def surface_url(surface, path):
    """Join a surface's base with a path. `surface` is a DEFAULT_API_BASES
    key; unknown surfaces raise KeyError rather than silently building a
    URL the gateway has no app binding for."""
    bases = api_bases()
    if surface not in bases:
        raise KeyError(f"unknown Google surface {surface!r}; known: {sorted(bases)}")
    return f"{bases[surface]}/{path.lstrip('/')}"


def _query_value(value):
    """Render one param value for the query string.

    JSON booleans must reach Google as `true`/`false`. Left alone,
    urlencode stringifies Python's bool as `True`/`False`, which Google
    rejects as a malformed value — and the callers hand us booleans
    straight off stdin JSON (`singleEvents`, `showCompleted`,
    `showHidden`). Lists are mapped element-wise so doseq still sees a
    list.
    """
    if isinstance(value, bool):
        return "true" if value else "false"
    if isinstance(value, list):
        return [_query_value(v) for v in value]
    return value


def google_request(method, url, *, params=None, body=None, timeout=PER_CALL_TIMEOUT_SECONDS):
    if params:
        # Google's list endpoints take repeated keys for multi-value params
        # (labelIds, fields); doseq keeps a list arg as repeats rather than
        # url-encoding the Python list repr.
        query = urllib.parse.urlencode(
            {k: _query_value(v) for k, v in params.items()}, doseq=True
        )
        url = f"{url}{'&' if '?' in url else '?'}{query}"

    data = None
    headers = {"accept": "application/json"}
    if body is not None:
        data = json.dumps(body).encode("utf-8")
        headers["content-type"] = "application/json"

    # No Authorization header by design — the OneCLI gateway injects it on
    # the wire. Setting one here would either be overwritten or shadow the
    # injection; either way it means a credential leaked into the container.
    req = urllib.request.Request(url, data=data, headers=headers, method=method)

    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            raw = resp.read()
    except urllib.error.HTTPError as e:
        raise _classify(e) from e

    if not raw:
        # 204 No Content — Calendar/Tasks DELETE. An empty body is success,
        # not a JSONDecodeError for every caller to guard.
        return {}
    return json.loads(raw.decode("utf-8"))


def _classify(e):
    """Map an HTTPError to the actionable error for its config failure
    mode, or return an equivalent HTTPError for the caller to wrap.

    Reads the body once — it is the only place the `access_restricted`
    marker appears. Reading it is destructive, so an ordinary 403 cannot
    simply be handed back: its body would already be drained, and the
    caller would wrap a bare "HTTP Error 403: Forbidden" having lost the
    reason Google actually sent (`rateLimitExceeded`,
    `insufficientPermissions`, `dailyLimitExceeded`). It is rebuilt with
    the detail in `reason` and the body restored, so `str(e)` carries the
    diagnostic and `.read()` still works.
    """
    if e.code not in (401, 403):
        return e
    try:
        raw = e.read()
    except OSError:
        raw = b""
    detail = raw.decode("utf-8", "replace")
    if e.code == 401:
        return GatewayNotInjecting(f"{GATEWAY_NOT_INJECTING_HINT} (Google said: {detail[:300]})")
    if "access_restricted" in detail:
        return TierAccessRestricted(TIER_ACCESS_RESTRICTED_HINT)
    reason = f"{e.reason} ({detail[:300]})" if detail else e.reason
    return urllib.error.HTTPError(e.url, e.code, reason, e.headers, io.BytesIO(raw))

README.md

tile.json