Skip to content
Draft
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## What is this

ghealth is a CLI wrapping the Google Health API v4. Primary users are AI agents (OpenClaw, Claude Code, Codex, coding agents) that need structured health data access — steps, heart rate, sleep, exercise, weight, SpO2, HRV. Human developers use it too.
ghealth is Google's command-line tool for the Google Health API v4 (https://developers.google.com/health). It is used by developers and by AI coding agents that need structured health data access — steps, heart rate, sleep, exercise, weight, SpO2, HRV.

The CLI handles OAuth, pagination, response simplification, and contextual hints so agents can focus on the health question, not API plumbing.

Expand Down
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
# ghealth
# Google Health CLI (`ghealth`)

CLI for the [Google Health API v4](https://developers.google.com/health) — built for AI agents and developers.
[![Official Google project](https://img.shields.io/badge/Official-Google%20Health%20API-4285F4)](https://developers.google.com/health)
[![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE.md)

The official command-line tool for the [Google Health API](https://developers.google.com/health), maintained by Google.

> **Verify it's official:** this repository is linked from the Google Health API documentation at [developers.google.com/health](https://developers.google.com/health) and published by the `Google-Health-API` GitHub organization.

- **40 verified data types**: steps, heart rate, exercise, sleep, weight, SpO2, HRV, ECG, blood glucose, nutrition, and more
- **Agent-first**: simplified JSON output, deterministic exit codes, `--dry-run`, `--raw`
- **Scriptable**: simplified JSON output, deterministic exit codes, `--dry-run`, `--raw`
- **Single binary**: `go build -o ghealth .`

## Quick Start
Expand Down Expand Up @@ -408,7 +413,8 @@ Errors are always JSON on stderr and may include a `next_steps: []string` array
| `GHEALTH_CONFIG_DIR` | Config directory override |
| `GHEALTH_PROFILE` | Active profile name |
| `GHEALTH_FORMAT` | Default output format (json/table/csv) |
| `GHEALTH_BASE_URL` | Override the API base URL |
| `GHEALTH_BASE_URL` | Override the API base URL (testing only). Restricted to `https://*.googleapis.com` and loopback hosts; other hosts are ignored with a warning unless `GHEALTH_ALLOW_CUSTOM_BASE_URL=1`, because your OAuth token is sent to this host. |
| `GHEALTH_ALLOW_CUSTOM_BASE_URL` | Set to `1` to allow a non-Google `GHEALTH_BASE_URL` (not recommended) |


## License
Expand Down
56 changes: 56 additions & 0 deletions pkg/client/base_url_test.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package client

import (
Expand All @@ -18,3 +32,45 @@ func TestResolveBaseURLOverride(t *testing.T) {
t.Fatalf("override = %q", got)
}
}

func TestResolveBaseURLRejectsUntrustedHost(t *testing.T) {
t.Setenv("GHEALTH_BASE_URL", "https://evil.example.com/v4")
t.Setenv(allowCustomBaseURLEnv, "")
if got := resolveBaseURL(); got != defaultBaseURL {
t.Fatalf("untrusted host should fall back to default, got %q", got)
}
}

func TestResolveBaseURLCustomHostWithOptIn(t *testing.T) {
t.Setenv("GHEALTH_BASE_URL", "https://proxy.corp.example/v4")
t.Setenv(allowCustomBaseURLEnv, "1")
if got := resolveBaseURL(); got != "https://proxy.corp.example/v4" {
t.Fatalf("opt-in custom host = %q", got)
}
}

func TestValidateBaseURL(t *testing.T) {
cases := []struct {
raw string
allowCustom bool
wantErr bool
}{
{"https://health.googleapis.com/v4", false, false},
{"https://staging-health.sandbox.googleapis.com/v4", false, false},
{"http://localhost:8080/v4", false, false},
{"http://[::1]:8080/v4", false, false},
{"http://health.googleapis.com/v4", false, true}, // plain http to Google host
{"https://googleapis.com.evil.example/v4", false, true}, // suffix trick
{"https://evilgoogleapis.com/v4", false, true}, // missing dot boundary
{"https://evil.example.com/v4", false, true},
{"https://evil.example.com/v4", true, false},
{"ftp://health.googleapis.com/v4", true, true},
{"not a url", true, true},
}
for _, c := range cases {
err := validateBaseURL(c.raw, c.allowCustom)
if (err != nil) != c.wantErr {
t.Errorf("validateBaseURL(%q, %v) err = %v, wantErr %v", c.raw, c.allowCustom, err, c.wantErr)
}
}
}
51 changes: 48 additions & 3 deletions pkg/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,56 @@ var BaseURL = resolveBaseURL()

const defaultBaseURL = "https://health.googleapis.com/v4"

// allowCustomBaseURLEnv must be set to "1" to permit a GHEALTH_BASE_URL that
// is neither a Google API host nor a loopback address.
const allowCustomBaseURLEnv = "GHEALTH_ALLOW_CUSTOM_BASE_URL"

// resolveBaseURL returns the API base URL. GHEALTH_BASE_URL may override it,
// but because every request carries the user's OAuth access token, overrides
// are restricted to https://*.googleapis.com and loopback hosts (for local
// testing) unless GHEALTH_ALLOW_CUSTOM_BASE_URL=1 is also set. A rejected
// override falls back to the default and prints a warning to stderr.
func resolveBaseURL() string {
if v := os.Getenv("GHEALTH_BASE_URL"); v != "" {
return v
v := os.Getenv("GHEALTH_BASE_URL")
if v == "" {
return defaultBaseURL
}
if err := validateBaseURL(v, os.Getenv(allowCustomBaseURLEnv) == "1"); err != nil {
fmt.Fprintf(os.Stderr, "warning: ignoring GHEALTH_BASE_URL: %v; using %s\n", err, defaultBaseURL)
return defaultBaseURL
}
if !isTrustedHost(v) {
fmt.Fprintf(os.Stderr, "warning: GHEALTH_BASE_URL points to non-Google host %q; your OAuth access token will be sent there\n", v)
}
return v
}

// validateBaseURL reports whether raw is an acceptable API base URL.
func validateBaseURL(raw string, allowCustom bool) error {
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return fmt.Errorf("%q is not an absolute URL", raw)
}
if u.Scheme != "https" && u.Scheme != "http" {
return fmt.Errorf("unsupported scheme %q", u.Scheme)
}
if isTrustedHost(raw) || allowCustom {
return nil
}
return fmt.Errorf("host %q is not *.googleapis.com or loopback (set %s=1 to allow)", u.Hostname(), allowCustomBaseURLEnv)
}

// isTrustedHost is true for https://*.googleapis.com and for loopback hosts.
func isTrustedHost(raw string) bool {
u, err := url.Parse(raw)
if err != nil {
return false
}
host := strings.ToLower(u.Hostname())
if u.Scheme == "https" && (host == "googleapis.com" || strings.HasSuffix(host, ".googleapis.com")) {
return true
}
return defaultBaseURL
return host == "localhost" || host == "127.0.0.1" || host == "::1"
}

// sleep is swappable so tests can run the retry loop without real backoff.
Expand Down
Loading