A sandboxed Google Workspace CLI that registers a gog command into a
SwiftBash Shell, so a local LLM (or any
shell automation) can read and write Google Drive, Gmail, Calendar, Contacts,
Tasks, Docs, Sheets, Slides, Chat, Forms, YouTube, and Admin Directory — all
confined by SwiftBash's MountedFileSystem and allow-listed network layer.
gog drive ls --max 20 --json | jq '.files[].name'
gog gmail messages -q 'newer_than:7d' --json
gog calendar freebusy --jsongog behaves like any other sandbox command: structured data to stdout,
hints/progress/errors to stderr, composable through pipes.
- The host owns auth.
gogperforms no OAuth — no browser flow, no token endpoint, no Keychain. The host injects a Google access token per run via aGogCredentialProvider; on a401,gogasks the host to refresh once, then fails closed (exit 7) for the host to handle. - Credentials stay out-of-band. The token is never placed in the shell
environment, the command argv, or the mounted filesystem — it is only attached
as an
Authorizationheader by the HTTP layer.printenv/echo $TOKENcannot surface it. - Files stay in the sandbox. All I/O goes through
Shell.fileSystem(MountedFileSystem); paths outside the mounts are rejected, so download and upload targets must be sandbox paths. - Network is allow-listed. Only the Google API hosts you configure are
reachable; with no
networkConfig, networked commands fail closed (exit 7). - Writes are gated by a host capability tier. Every data mutation (create,
edit, delete across Drive, Gmail labels·trash·drafts, Calendar, Tasks, Sheets,
Contacts) requires a
GogWriteTierthe host grants —.readOnlyby default, so data writes fail closed (exit 3) until the host raises it to.editor.full. The tier is host-only (a task-local, never a flag or env var), so LLM-authored bash can't raise its own access. Sending (gmail send,chat send) andadmindirectory writes keep their own dedicatedGogPolicyswitches, and every write supports--dry-run.
See PLAN.md for the full architecture and decisions, and
.agents/skills/gog/SKILL.md for the
agent-facing command reference.
SwiftGog depends on SwiftBash. Today the manifest pins SwiftBash as a sibling
checkout (Package.swift declares .package(path: "../SwiftBash")), so clone
both repos side by side and depend on SwiftGog by path:
git clone https://github.com/picomlx/SwiftBash.git
git clone https://github.com/picomlx/SwiftGog.git # sits next to ../SwiftBash// your app's Package.swift
dependencies: [
.package(path: "../SwiftGog"),
],(For remote/URL consumption, switch SwiftGog's own SwiftBash dependency from the
path: pin to a url: pin first.)
The package vends three libraries:
| Library | What it provides |
|---|---|
GogCore |
the host seams: GogCredentialProvider, GogPolicy, GogTransport |
GogCommands |
the gog command tree (ArgumentParser commands) |
GogShell |
Shell.registerGogCommands() — one call installs the whole tree |
Build a sandboxed Shell, allow-list the Google API hosts, register gog, then
run commands with a credential provider (and optional policy) bound around the
run:
import BashInterpreter // Shell, NetworkConfig, AllowedURLEntry, MountedFileSystem
import GogCore // GogCredentials, GogPolicy, GogPolicies, GogCredentialProvider
import GogShell // registerGogCommands()
// 1. The host's token source (no OAuth lives in gog).
struct MyProvider: GogCredentialProvider {
let account: String
func accessToken() async throws -> String { try await myTokenStore.token(for: account) }
func refreshedAccessToken() async throws -> String { try await myTokenStore.refresh(for: account) }
var accountHint: String? { account }
}
// 2. A sandboxed shell: a mounted workspace + an allow-list of Google API hosts.
let shell = Shell(fileSystem: myMountedFileSystem) // e.g. mounts "/gog"
shell.networkConfig = NetworkConfig(
allowedURLPrefixes: [
// The full tree needs every service host it can reach; trim to match
// the commands you actually register/allow (see PLAN.md).
AllowedURLEntry("https://www.googleapis.com/"), // Drive, Calendar
AllowedURLEntry("https://gmail.googleapis.com/"), // Gmail
AllowedURLEntry("https://people.googleapis.com/"), // identity + contacts
AllowedURLEntry("https://tasks.googleapis.com/"), // Tasks
AllowedURLEntry("https://sheets.googleapis.com/"), // Sheets
AllowedURLEntry("https://docs.googleapis.com/"), // Docs writes (reads export via www)
AllowedURLEntry("https://slides.googleapis.com/"), // Slides writes (reads export via www)
AllowedURLEntry("https://chat.googleapis.com/"), // Chat
AllowedURLEntry("https://forms.googleapis.com/"), // Forms
AllowedURLEntry("https://youtube.googleapis.com/"), // YouTube
AllowedURLEntry("https://admin.googleapis.com/"), // Admin Directory + Reports
],
allowedMethods: [.GET, .POST, .PATCH, .PUT, .DELETE])
// 3. Install the gog command tree (off-catalog, at /usr/local/bin/gog).
shell.registerGogCommands()
// 4. Run, with the provider (and any policy) bound for this run only.
let run = try await GogCredentials.$current.withValue(MyProvider(account: "alice@corp.com")) {
try await GogPolicies.$current.withValue(GogPolicy(gmailSendDisabled: true)) {
try await shell.runCapturing("gog drive ls --json")
}
}
print(run.stdout) // structured JSON
assert(run.exitStatus == .success)Because the provider and policy are bound as task-local values around the run, LLM-authored bash inside the shell cannot read or change them — they are not command flags or environment variables.
Bind a different GogCredentialProvider per run (or per task) — there is no
global mutable auth state, so concurrent tenants don't interfere:
try await GogCredentials.$current.withValue(tenantA.provider) { … } // tenant A
try await GogCredentials.$current.withValue(tenantB.provider) { … } // tenant BGogPolicy (bound via GogPolicies.$current) is how the host controls
mutations. It has two parts:
-
writeTiergoverns every data mutation (create / edit / delete across Drive, Gmail labels·trash·drafts, Calendar, Tasks, Sheets, Contacts):.readOnly(default) — all data writes fail closed; list / get / search / download / export only..edit— additive, in-place, or reversible writes (create, rename, update, append, mkdir, cp, untrash, label edits)..full— also destructive, irreversible, or sharing writes (delete, trash, move, share, unshare, clear).
The tiers are ordered
.full > .edit > .readOnly, so each includes the ones below it. (Adding theTRASH/SPAMGmail labels needs.full, since that is really a trash, not a label edit.) -
Dedicated switches for the non-data flows, orthogonal to
writeTier:gmailSendDisabled/chatSendDisabled(outbound mail & chat — enabled by default) andadminWriteDisabled(directory writes — disabled by default). A fully locked-down host setswriteTier: .readOnlyand disables sending.
// Allow reversible data edits, but block destructive ops and all sending.
GogPolicy(gmailSendDisabled: true, chatSendDisabled: true, writeTier: .edit)writeTier is host-only — bound as a task-local, never a command flag or
environment variable — so LLM-authored bash inside the shell cannot raise its
own access. Every write also supports --dry-run (build and print the request
without calling Google), and a blocked write fails closed with exit 3 before
any network call.
registerGogCommands() installs the full command tree (selective
installation is not part of the public API today); writeTier is the lever for
what that tree is allowed to mutate.
Upstream gogcli guards destructive
directory operations (suspending a user, changing group membership) with an
interactive confirmation prompt plus a --force flag, and in non-interactive
use it refuses them unless --force is passed. SwiftGog runs LLM-authored bash
with no human at a terminal, so it replaces that with a host-bound policy:
directory writes are disabled by default (GogPolicy.adminWriteDisabled —
the one gate that defaults to off, unlike the send gates), and there is
intentionally no --force flag — a command-line escape hatch would let the
model escalate past the gate. The host, not the model, decides whether to
enable directory writes. The fail-closed intent matches gogcli's non-interactive
behaviour; the control simply moves from argv to host policy.
| Code | Meaning |
|---|---|
| 0 | success |
| 1 | a Google API error (HTTP ≥ 400); the message is echoed to stderr |
| 2 | usage / validation error (bad flag, out-of-range --max, bad input) |
| 3 | refused by host policy (write tier too low, or sending/admin disabled), or --fail-empty with no results |
| 7 | fail-closed: no network configured, or missing / rejected credentials |
| 23 | could not write the requested sandbox destination |
These are enforced and CI-guarded; a consumer can rely on them:
- No host filesystem or networking primitives.
GogCore/GogCommandsnever useFileManager,Data(contentsOf:), orURLSession— a CI lint-guard fails the build if they appear. All file I/O goes throughShell.fileSystem; all HTTPS goes through the allow-listed transport. - Token confinement. The injected token is only ever an
Authorizationheader. It is never written toShell.environment, argv, stdout/stderr, or the mounted FS. - Fail-closed by default. No network config ⇒ exit 7. No credentials ⇒ exit 7. Out-of-mount path ⇒ rejected.
GogTransport is an injectable seam: production uses SecureTransport (over
SwiftBash's allow-listed fetcher), and tests bind a fake via
GogTransportProvider.$current to return canned Google JSON — no real network:
// MockTransport / StubProvider are your own test doubles conforming to
// GogTransport / GogCredentialProvider.
let json = #"{"files":[]}"#
let transport = MockTransport(response: HTTPResponse(status: 200, body: Data(json.utf8)))
try await GogTransportProvider.$current.withValue(transport) {
try await GogCredentials.$current.withValue(StubProvider()) {
try await shell.runCapturing("gog drive ls --json")
}
}See Tests/GogShellTests/GogWiringTests.swift for the full pattern (fakes,
sandbox-deny tests, and per-command behaviour).
The command surface spans identity, Drive, Gmail, Calendar, Contacts, Tasks,
Docs, Sheets, Slides, Chat, Forms, YouTube, and Admin (Directory + Reports),
read-first, with every data write behind a host-set capability tier
(GogWriteTier, read-only by default). See
.agents/skills/gog/SKILL.md for the current
command list and PLAN.md for the roadmap.