gmail-tools — Daily Inbox Agent (operations)
Workflow: gmail-tools — Daily Inbox Agent (07:00 Europe/Warsaw)
n8n ID: gli6YpO4t9e2koMc (bms-4 — https://n8n.bms-4.infra.zintegrowana.online)
Host: bms-4 (54.36.123.110), runs as claude-runner
Repo under management: radieu/gmail-tools cloned at /opt/gmail-tools
Created: 2026-07-31 · p24-infra#4724 (Return-To: radieu/gmail-tools#2)
Status: ✅ Fully working, verified end-to-end 2026-08-03 (execution 281935,
22:24–22:33 UTC): 70 emails categorized, 66 archived via gmail-mutate.js, sheets-sync run,
digest sent. p24-infra#4724 is closed. OAuth re-authenticated 2026-08-03 (fresh production-mode
grant). The radieu/gmail-tools#25 “missing scripts” blocker turned out to be a stale bms-4
checkout (5 weeks behind main), not a missing implementation — fixed by syncing to main. See
§Known issue — n8n execution-tracking lag below for a still-open reliability gap that doesn’t
block daily operation but can make a genuinely successful run show as crashed for hours.
AI system: registered — dev_r_ai_systems.gmail-tools-daily-inbox-agent (minimal EU AI Act tier; injection blast radius separately assessed elevated — see §Compliance and C-R6(b))
Activation checklist
The workflow is complete and its cron path is proven (a temporary-schedule test fired the trigger at exactly the requested minute). It is deactivated for two different reasons, and both kinds must clear: blockers 1–2 are things that make the run fail today, and an active daily failure would page Discord every morning about known issues; blocker 3 is a thing that makes it unsafe to succeed unattended. Fixing only 1–2 removes the alerts, not the risk.
| # | Blocker | Owner | Tracked |
|---|---|---|---|
| 1 | n8n bms-4-root-ssh credential rejects auth — the key it stores is no longer in root@bms-4’s authorized_keys | secret-manager | p24-infra#4785 |
| 2 | invalid_grant); the Google client is in Testing mode so grants die every 7 daysnode auth.js (loopback callback tunneled through an SSH local-forward from the operator’s workstation to bms-4:18765 — not 4242, see callout below). New token confirmed to lack the refresh_token_expires_in field entirely — no more 7-day timer. See §Known blocker below for the historical detail this row used to summarize. | repo owner (needs a browser) | radieu/gmail-tools#2 |
| 3 | repo owner | docs/ai-risk-data-governance.md C-R6(b) · priorities.md §P1 |
Blocker 3 — resolved via Option A; Option B tracked as a follow-up
The runner now invokes claude --allowedTools ... instead of --dangerously-skip-permissions:
Read(docs/gmail-filtering-playbook.md), Read(docs/priorities.md), Edit(docs/priorities.md),
and five Bash(node scripts/<specific-script>.js ...) patterns — no bare Bash, no Write, no
arbitrary node -e. This was previously blocked on radieu/gmail-tools access to read playbook
§14 and build the allowlist against real scripts; that access exists now (AI-Dev-BMS4-1/2 are
collaborators, p24-infra#4721). Krok 1’s arbitrary Gmail-API scripting was replaced with two
purpose-built scripts (gmail-query.js read-only, gmail-mutate.js label-only) so the allowlist
has something narrow to point at. Full rationale + verification:
radieu/gmail-tools docs/daily-agent-security-model.md.
| Option | Effect | Status |
|---|---|---|
A — restrict the agent’s tools. --allowedTools instead of --dangerously-skip-permissions | Removes the arbitrary-execution path at its source | Shipped |
B — separate the credentials. Move vps_root_key to a distinct OS user, and/or switch gh auth to the scoped AI-Dev-BMS4-1 bot account | Shrinks what any compromise of claude-runner reaches — not just this agent’s own tool calls, which A cannot cover | Tracked as a follow-up issue, not a blocker for this workflow’s activation |
| C — accept the risk explicitly | Superseded by A | Not needed — A is a real mitigation, not an acceptance |
A does not require B: A closes this agent’s own tool surface; B would close the filesystem
surface shared by every claude-runner process on the host, a broader host-hardening question
independent of this workflow. Activation is no longer gated on blocker 3.
Once all three clear:
export SOPS_AGE_KEY_FILE="$HOME/.age/p24-infra-keys.txt"
N8N_HOST="https://n8n.bms-4.infra.zintegrowana.online"
K=$(sops --decrypt --input-type dotenv --output-type dotenv \
/opt/p24-infra/secrets/n8n-bms4.env.sops | grep "^BMS4_N8N_API_KEY=" | cut -d= -f2-)
# 1. Re-import first — the repo JSON is canonical and is AHEAD of the live workflow.
# The live copy predates every runner fix landed after the first upload: the run-log
# preflight (rc=10), the client_secret check (rc=12), moving the OAuth credentials off
# curl's command line, the 0600 transcript + 30-day prune, rc=15/rc=1, and the
# nonce-tagged stdout-only control channel. Activating without this step ships the older
# runner, which both leaks credentials via argv and can be paged by inbound email.
curl -s -X PUT -H "X-N8N-API-KEY: $K" -H "Content-Type: application/json" \
"$N8N_HOST/api/v1/workflows/gli6YpO4t9e2koMc" \
--data-binary @infra-src/n8n-workflows/gmail-tools-daily-inbox-agent.json | jq -r '.name'
# 2. Then activate.
curl -s -X POST -H "X-N8N-API-KEY: $K" \
"$N8N_HOST/api/v1/workflows/gli6YpO4t9e2koMc/activate" | jq .activeThe n8n API rejects read-only keys (
id,active,createdAt, …) in aPUTbody. If the import 400s, strip everything exceptname,nodes,connections,settings— the committed JSON already contains only those four keys.
✅ Fixed 2026-08-02 — under-escaped quotes crashed every run
The workflow was activated 2026-08-01 once all three blockers above cleared, but the very first
scheduled fire (execution 275882, 2026-08-02 07:00 Europe/Warsaw) crashed: the
Build SSH Command Code node built the --allowedTools argument list with shell double-quotes
escaped only one level instead of the two needed to survive JSON → JS-string-literal nesting, so
the JS string literal terminated early and n8n threw SyntaxError: Unexpected identifier 'Read'
building the SSH command — deterministic, not data-dependent.
Fixed and verified in p24-infra#5202 (node --check on the extracted jsCode, plus running it
under node and confirming the decoded shell script carries all eight --allowedTools entries
correctly quoted). The corrected JSON was re-imported into the live workflow on 2026-08-02
(gli6YpO4t9e2koMc, still active, schedule unchanged at 07:00 Europe/Warsaw). Full detail:
p24-infra#4724.
Verification status: confirmed — several controlled test-fires on 2026-08-02/03 (temporarily
reschedule → deactivate/activate to reload → confirm → restore 07:00) show the Build SSH Command
node succeeding every time post-fix; the earlier SyntaxError crash is gone for good. Check recent
executions via:
curl -s -H "X-N8N-API-KEY: $K" \
"$N8N_HOST/api/v1/executions?workflowId=gli6YpO4t9e2koMc&limit=5" | jq -r '.data[] | "\(.id) \(.status) \(.startedAt)"'✅ Resolved 2026-08-03 — allowedTools scripts were never actually missing
Even with the crash fixed and OAuth working, the agent initially still couldn’t read or act on
Gmail. --allowedTools names scripts/gmail-query.js, scripts/gmail-mutate.js, and
scripts/send-digest.js, and these were absent from the bms-4 deployment. First diagnosed (wrongly)
as scripts that were never implemented and filed as radieu/gmail-tools#25 — the real cause was that
bms-4’s /opt/gmail-tools checkout was 5 weeks and ~30 files behind main, including a
credentials-path refactor, an OAuth-scope trim, and the commits that actually added these scripts
weeks earlier. Fixed by syncing the checkout to main (git stash the local journal edits →
git merge --ff-only origin/main → resolve the resulting docs/priorities.md /
docs/session-audit.md conflicts — two independent session-numbering sequences had collided —
gmail-tools PR #27). radieu/gmail-tools#25 closed with the corrected root cause on file.
⚠️ Known issue — n8n execution-tracking lag (unresolved, not blocking daily operation)
Across several test-fires (both through n8n and via direct SSH reproduction bypassing n8n
entirely), the Run Gmail Agent on bms-4 SSH node sometimes returned exit code 0 with completely
empty stdout/stderr even though the script demonstrably ran to completion and wrote a full log
file. Initial hypothesis was an SSH channel/output-buffering issue tied to long periods of stdout
silence (all real logging goes to the log file via >> "$LOG" 2>&1, not inherited stdout, until
the final marker line) — a heartbeat loop was added to the runner script to test this
(p24-infra#5304: backgrounds claude, polls with kill -0 every 20s, echoes a
GMAIL_AGENT_HEARTBEAT marker while waiting).
The real pattern turned out to be worse. Execution 279394 (the regular 2026-08-03 07:00 run)
wrote its full log file within ~13 minutes of starting, but n8n’s own execution record stayed
running for almost 6 hours before finally resolving as crashed. This lines up with n8n’s
container restarts on bms-4, independently observed happening every 15–60 minutes — the leading
theory now is that a restart mid-execution orphans the in-flight SSH exec channel, and n8n only
reconciles the stale running record much later (possibly on its own next restart cycle, not
immediately). This is a platform-level n8n reliability question, broader than this one workflow —
any long-running SSH-based cron on bms-4 could be affected. Not filed as a separate issue yet.
Practical consequence: an n8n execution showing status: running for an unusually long time,
or later resolving to crashed/error, does not reliably mean the underlying run actually
failed. Before treating a failure alert as real, check the log file directly — its timestamp and
size tell you what actually happened, independent of what n8n’s execution record says:
ssh root@54.36.123.110 "ls -la /var/log/p24-infra-workers/gmail-daily-agent-*.log | tail -3"
ssh root@54.36.123.110 "ls -t /var/log/p24-infra-workers/gmail-daily-agent-*.log | head -1 | xargs tail -c 2000"What it does
Once per day at 07:00 Europe/Warsaw the workflow SSHes into bms-4 and runs the
gmail-tools autonomous inbox agent against radieu@gmail.com:
claude --dangerously-skip-permissions -p \
"Wykonaj codzienny przegląd skrzynki Gmail zgodnie z playbook docs/gmail-filtering-playbook.md sekcja 14"
The agent’s own specification lives in the gmail-tools repo, not here:
docs/gmail-filtering-playbook.md§14 — Agent dzienny — specyfikacja (the four mandatory steps: categorise → reconcile invoices → check deadlines → send digest)docs/priorities.md— read every run, updated per §14 Aktualizacja priorities.md po zakończeniu
The daily digest is emailed to radieu@gmail.com by the agent itself via the Gmail API.
Node chain
| # | Node | Purpose |
|---|---|---|
| 1 | Daily 07:00 Europe/Warsaw | scheduleTrigger — field: days, triggerAtHour: 7, triggerAtMinute: 0 |
| 2 | Build SSH Command | Code node — base64-wraps the remote runner script so no quoting survives the ssh → su → bash layers |
| 3 | Run Gmail Agent on bms-4 | ssh node, credential bms-4-root-ssh (W8fOGECM0UwCagjd) |
| 4 | Check Result | Code node — throws on any failure marker so n8n’s errorWorkflow fires |
Workflow-level settings.timezone = "Europe/Warsaw" is set explicitly rather than relying on
the container’s GENERIC_TIMEZONE, so the 07:00 slot survives an env change.
Failure alerting
settings.errorWorkflow = "gG3SsUPtcqgtsbje" (error_flow) — the same handler used by
gitBackup, interrcars-payments-3h-update, W4 Direct API Ingestion and 8 other bms-4
workflows. On failure it posts a Discord embed and writes an error-log row to Supabase
proc_log.
The SSH node deliberately keeps n8n’s default error behaviour (it throws) rather than
continueOnFail / onError: continueRegularOutput. Swallowing the error hides the real
cause: during testing a genuine credential failure surfaced only as a generic “no success
marker returned”, because with continueRegularOutput the SSH failure is attached to the
node’s run metadata and never reaches the item JSON a downstream Code node can read.
Letting it throw puts n8n’s own message — e.g. All configured authentication methods failed — straight into the Discord alert. Check Result then covers the other class:
SSH connected fine, but the remote script reported a failure.
The control channel is nonce-tagged, and stdout-only
Build SSH Command generates a random runId per execution and injects it into the runner.
Every status line the runner writes carries it — GMAIL_AGENT_OK:<runId> /
GMAIL_AGENT_FAIL:<runId> rc=NN :: … — and Check Result accepts a marker only when the nonce
matches this execution’s.
More importantly, the two streams are separated: stdout carries runner-generated markers and
nothing else, while the agent transcript — which quotes inbound email verbatim — goes to stderr
and the run log. Check Result parses stdout only, and treats stderr purely as diagnostic text to
quote into the alert.
That separation is what makes the channel unforgeable. Before it, an email whose body contained a
line starting GMAIL_AGENT_FAIL was echoed into the transcript, printed by the success-path
tail, and read by Check Result as a genuine failure — a stranger could page Discord by sending
mail (#4724, PR #4788 review). The nonce alone would not have fixed that; keeping the transcript
off stdout does.
The one remaining text heuristic is the rc=14 usage-limit check, which greps the transcript
because claude -p exposes no structured signal for it. It is scoped to the last 5 lines, on
the grounds that a usage-limit notice is what ends a run, so quoted mail earlier in the
transcript no longer trips it. A false positive there is fail-safe: it raises an alert and takes
no action on the mailbox.
Failure markers the runner emits (each prefixed GMAIL_AGENT_FAIL:<runId>):
| Marker | Meaning | Fix |
|---|---|---|
rc=10 | run log not writable — /var/log/p24-infra-workers missing, or not writable by claude-runner | mkdir -p /var/log/p24-infra-workers && chown claude-runner /var/log/p24-infra-workers on bms-4. The runner tries mkdir -p itself first, so this only fires when the parent path is not writable either |
rc=11 | /opt/gmail-tools missing | Re-run the p24-infra#4721 clone step |
rc=12 | client_id, client_secret or refresh_token missing/unreadable under $HOME/.gmail-mcp | Re-run the #4721 credential placement script |
rc=13 | Google rejected the OAuth refresh token — the error name Google returned is quoted in the marker | Re-authenticate — see below |
rc=14 | Claude usage limit hit mid-run (matched in the transcript’s last 5 lines — see above) | Wait for quota reset; check other bms-4 workers |
rc=15 | the token endpoint was unreachable (DNS, egress, timeout) — not an expired grant | Check bms-4 egress to oauth2.googleapis.com; curl rc is quoted in the marker |
rc=124 | agent exceeded the 45-minute budget | Inspect the run log, consider raising timeout |
rc=1 | claude itself exited non-zero for anything else — claude’s real exit code is quoted in the marker | Read the tail in the alert, then the full run log |
rc=10–rc=15 are the runner’s own marker codes. A non-zero exit from claude is deliberately
remapped to rc=1 (with claude’s real code in the message text) so it can never collide with one
of them and send the operator to the wrong row of this table. rc=14 is checked before any exit-code
branch, because a usage-limit abort can exit either zero or non-zero.
Run transcript and its retention
Full agent output for every run is kept on bms-4 at
/var/log/p24-infra-workers/gmail-daily-agent-<UTC-timestamp>.log; n8n only receives the last
60 lines plus a status marker.
The transcript can quote third-party senders and subjects, so it is treated as personal data
(docs/ai-risk-data-governance.md Part C, C.4):
| Control | Implementation |
|---|---|
| Not world-readable | the runner sets umask 077 and chmod 600 on the file it creates. The shared /var/log/p24-infra-workers is mode 2755 and sibling worker logs are chmod 0666, so this is set per-file by the runner rather than inherited |
| Retention — 30 days on bms-4 | the runner prunes gmail-daily-agent-*.log older than RETAIN_DAYS=30 at the start of each run. The glob is scoped to this workflow’s own filenames, so it can never remove another worker’s log. There is no logrotate unit or repo-wide rotation for that directory — this prune is the only retention mechanism covering these files |
| Retention — 7 days in n8n | the 60-line tail that reaches the n8n execution record is purged by n8n itself: EXECUTIONS_DATA_PRUNE=true, EXECUTIONS_DATA_MAX_AGE=168 in bms-4/docker-compose.yml |
If the workflow is ever deleted, remove the leftover transcripts too — nothing else prunes them once the runner stops running.
✅ Resolved 2026-08-03 — Gmail OAuth grant no longer expires every 7 days
Historical symptom (2026-07-31 through 2026-08-02): the Google Cloud OAuth client backing
gmail-tools was in “Testing” publishing status. Google issues 7-day refresh tokens to
Testing-mode clients — the stored token carried refresh_token_expires_in: 604799 (exactly 7
days), which was the fingerprint of that mode. Every Google call from /opt/gmail-tools failed
with invalid_grant once that timer ran out — the daily agent, node scripts/*.js, and
calendar-cli.js alike. The workflow’s preflight caught this and failed fast with rc=13
instead of burning a full agent run.
Fix applied 2026-08-03: the OAuth client’s publishing status was moved from Testing to
In production in the new Google Auth Platform console (Google Cloud Console → APIs &
Services → OAuth consent screen now redirects here) → Odbiorcy / Audience tab → Stan
publikacji / Publication status. Note this is a project-number-scoped setting, not tied to a
single OAuth client — find the project via the client’s client_id prefix (the numeric part
before the first -, e.g. 588063977139-....apps.googleusercontent.com → project number
588063977139), then open
https://console.cloud.google.com/apis/credentials/consent?project=<project-number>. A follow-up
“needs verification” banner may appear (Google wants the sensitive-scope app reviewed) — this
does not affect token lifetime, only the consent-screen warning and a 100-user cap; safe to
ignore for a single-account internal tool.
After the status flip, one final re-authentication was still required to issue a new token under the new status (an already-issued Testing-mode token doesn’t retroactively become long-lived):
ssh root@54.36.123.110
su -s /bin/bash claude-runner -c 'cd /opt/gmail-tools && USERPROFILE=$HOME node auth.js'Correction to the old instructions: auth.js does not use a manual copy-paste
authorization code — it starts a local HTTP callback server (originally port 4242,
redirect_uri=http://localhost:4242/callback) and waits for Google to redirect there after
consent. Since auth.js runs on bms-4, localhost:4242 means bms-4’s own loopback — a browser
on any other machine can’t complete the redirect directly. To finish the flow from an operator’s
own workstation: open an SSH local port-forward first (ssh -L 4242:localhost:4242 root@54.36.123.110, kept open for the duration), then open the printed consent URL in a browser
on that same workstation — the tunnel delivers Google’s callback to the waiting process on bms-4
automatically, no code to copy anywhere. Confirm success via the process’s own log (or a
screen/nohup capture if run detached) — it prints Token zapisany: ...token.json and exits
cleanly on success.
⚠️ Port changed to
18765on 2026-08-03. The 2026-08-03 re-auth run hit an unresolvable local port-4242 conflict on the operator’s Windows workstation (a stuck listener that survived a WinNAT service restart, root cause never identified). Rather than keep fighting a workstation-local port,/opt/gmail-tools/auth.json bms-4 was permanently repointed to port18765— both theredirect_uriconstructor arg andserver.listen(...)— viased, with a backup kept at/opt/gmail-tools/auth.js.bak-4242. No Google Cloud Console change was needed: this OAuth client is “Desktop app” type (installedcredentials shape), and Google’s loopback flow accepts anylocalhostport for that client type without explicit redirect-URI registration. Use port18765, not4242, for the tunnel and in the printed consent URL on any future re-auth — checkgrep -n listen /opt/gmail-tools/auth.json bms-4 first to confirm the live port before starting, in case it’s changed again since this was written.
Verify afterwards:
su -s /bin/bash claude-runner -c 'cd /opt/gmail-tools && USERPROFILE=$HOME node scripts/inbox-status.js'A token that no longer carries refresh_token_expires_in at all (check with
python3 -c "import json;print('refresh_token_expires_in' in json.load(open('/home/claude-runner/.gmail-mcp/token.json')))"
→ should print False) confirms the fix held — no more weekly re-auth needed.
$USERPROFILE shim
All nine scripts under gmail-tools/scripts/ resolve credentials via process.env.USERPROFILE
— a Windows-only variable — with no $HOME fallback (only auth.js has one). On Linux they
crash with ERR_INVALID_ARG_TYPE before making any API call. The runner script therefore exports
USERPROFILE="$HOME" so the agent’s shell-outs work unchanged. The durable fix belongs in the
gmail-tools repo (process.env.USERPROFILE || process.env.HOME) — raised on radieu/gmail-tools#2.
Granted scopes
The live grant carries only gmail.modify, gmail.readonly, gmail.labels — enough for
labelling, archiving and sending the digest (messages.send accepts gmail.modify).
auth.js requests calendar/sheets/drive scopes but they were never granted, so Calendar and
Sheets tooling stays unavailable until the next consent round. Playbook §14 does not need them.
Operations
export SOPS_AGE_KEY_FILE="$HOME/.age/p24-infra-keys.txt"
N8N_HOST="https://n8n.bms-4.infra.zintegrowana.online"
K=$(sops --decrypt --input-type dotenv --output-type dotenv \
/opt/p24-infra/secrets/n8n-bms4.env.sops | grep "^BMS4_N8N_API_KEY=" | cut -d= -f2-)
# recent runs
curl -s -H "X-N8N-API-KEY: $K" \
"$N8N_HOST/api/v1/executions?workflowId=gli6YpO4t9e2koMc&limit=10" \
| jq -r '.data[] | "\(.id) \(.status) \(.startedAt)"'
# pause / resume
curl -s -X POST -H "X-N8N-API-KEY: $K" "$N8N_HOST/api/v1/workflows/gli6YpO4t9e2koMc/deactivate"
curl -s -X POST -H "X-N8N-API-KEY: $K" "$N8N_HOST/api/v1/workflows/gli6YpO4t9e2koMc/activate"Run the agent by hand (same command the workflow issues):
ssh root@54.36.123.110
su -s /bin/bash claude-runner -c 'export USERPROFILE=$HOME; cd /opt/gmail-tools && \
claude --dangerously-skip-permissions -p "Wykonaj codzienny przegląd skrzynki Gmail zgodnie z playbook docs/gmail-filtering-playbook.md sekcja 14"'Capacity
bms-4 allows 4 parallel claude sessions. This job takes one slot for the length of a run
(minutes, capped at 45) once per day at 07:00 Europe/Warsaw — no contention with the
infra-task / dev-issue workers, which are dispatched on their own cadence.
No new secrets
The workflow adds nothing to SOPS. It reuses the credential files placed by p24-infra#4721
(~/.gmail-mcp/{token.json,gcp-oauth.keys.json} plus /opt/gmail-tools/credentials/) and the
existing n8n bms-4-root-ssh credential.
~/.gmail-mcp/gcp-oauth.keys.json is a SEPARATE copy — sync it on every client-secret rotation (#5213)
/opt/gmail-tools/credentials/gcp-oauth.keys.json (rebuilt from SOPS by
scripts/deploy-gmail-tools-credentials.sh) and $HOME/.gmail-mcp/gcp-oauth.keys.json (read by
this workflow’s own preflight, and by auth.js) are two independent files — the deploy script
only writes the former. Rotating GMAIL_TOOLS_GCP_OAUTH_CLIENT_SECRET in SOPS and redeploying does
not update ~/.gmail-mcp/gcp-oauth.keys.json; it silently keeps the old (now-invalid) client
secret, so the token-refresh preflight starts failing rc=13 with invalid_client even though the
SOPS-side deployment reports fully healthy. Confirmed during the #5213 client-secret rotation:
/opt/gmail-tools/credentials/gcp-oauth.keys.json verified OK against SOPS via --check, but the
~/.gmail-mcp copy still held the disabled secret until manually synced.
After any GMAIL_TOOLS_GCP_OAUTH_CLIENT_SECRET (or ..._CLIENT_ID) rotation, on bms-4 as
claude-runner:
cp /opt/gmail-tools/credentials/gcp-oauth.keys.json "$HOME/.gmail-mcp/gcp-oauth.keys.json"
chmod 600 "$HOME/.gmail-mcp/gcp-oauth.keys.json"Both files share the same {"installed": {...}} shape, so a straight copy is valid — no transform
needed. Verify with the live refresh check (never prints the token/secret, only access_token
presence or the Google error name):
cd "$HOME" && \
CID=$(jq -r '.installed.client_id' .gmail-mcp/gcp-oauth.keys.json) && \
CSEC=$(jq -r '.installed.client_secret' .gmail-mcp/gcp-oauth.keys.json) && \
RTOK=$(jq -r '.refresh_token' .gmail-mcp/token.json) && \
RESP=$(curl -sS -X POST https://oauth2.googleapis.com/token \
-d client_id="$CID" -d client_secret="$CSEC" -d refresh_token="$RTOK" -d grant_type=refresh_token) && \
unset CID CSEC RTOK && \
(echo "$RESP" | jq -e '.access_token' >/dev/null 2>&1 && echo OK || echo "ERROR: $(echo "$RESP" | jq -r '.error // "unknown"')") && \
unset RESPtoken.json (the refresh token itself) is not affected by a client-secret-only rotation and does
not need re-copying — only gcp-oauth.keys.json does.
Compliance — EU AI Act
This workflow runs an autonomous AI agent, so it is a registered AI system. Registration was done
at creation, not at activation — et-chatbot-produkcja is in the same registry while disabled, and
CLAUDE.md §Compliance makes it mandatory for any new AI-powered element.
| Record | Value |
|---|---|
dev_r_ai_systems | system_name = gmail-tools-daily-inbox-agent, service_name = n8n-bms4, status = planned |
| Risk tier (EU AI Act) | minimal — no Annex III category |
| Injection blast radius (security, assessed separately) | elevated — C-R6(b); gates activation, see §Activation checklist blocker 3 |
compliance_status | compliant |
dev_r_services | n8n-bms4/gmail-tools-daily-inbox-agent (child element of the gmail-tools service row) |
| Reproducible from the repo | supabase/migrations/20260731120000_gmail_daily_agent_ai_system_4724.sql — idempotent guarded INSERTs for both rows, so the registration survives a rebuild |
| Narrative | docs/eu-ai-act-compliance.md §4 n8n AI workflows |
| Risk + data governance | docs/ai-risk-data-governance.md Part C — C.1, C-R6, C.3, C.4 |
Why minimal. The agent scores, ranks and evaluates no one; it allocates no work to any person; it makes no decision about a natural person. The only person whose affairs it touches is the account owner, who is also its operator. Article 52 transparency is not engaged either — the sole recipient of AI-generated text (the daily digest) is that same person.
Minimal is a classification, not a security rating. The tier answers whether the system
evaluates, ranks, allocates or decides about a natural person. It does not, so minimal is
correct. It says nothing about what a compromised run could reach — that is assessed separately
below and is elevated.
What is genuinely different about it. It is the first system in Part C that is agentic, and
it is agentic in two directions at once — it reads attacker-controllable input (inbound email)
while holding both Gmail write tools and, via --dangerously-skip-permissions, unrestricted host
tools. Tracked as C-R6:
- (a) The Gmail dimension is capped by scope. The live grant carries
gmail.modify,gmail.readonly,gmail.labelsand nothing else — nogmail.settings.*, so it cannot create server-side filters or forwarding rules, the classic injection payload. It holds nogmail.*.deletescope, butgmail.modifydoes permitusers.messages.trash; trashed mail is recoverable for 30 days and then auto-purged by Google, so the worst case here is “mislabelled, wrongly archived, or trashed mail”, recoverable inside the 24 h digest loop. Archiving is fully reversible. - (b) The host dimension is the real ceiling, and it is NOT mitigated.
claude --dangerously-skip-permissionsmeans a successful injection gets arbitrary command and file execution asclaude-runneron bms-4. That account’s home holdsvps_root_key(root SSH to the whole fleet) and an owner-level GitHub PAT under the activegh authlogin — an open P1 item inpriorities.md§P1. Do not read “runs as unprivilegedclaude-runner” as a control: the runner shares that UID, so unsettingANTHROPIC_API_KEYandSSH_AUTH_SOCK(which it does) closes inherited paths only and cannot make an on-disk key unreadable. This is why activation is gated — §Activation checklist blocker 3. - Bounds come from the repo, not the inbox. The task is fixed by
gmail-filtering-playbook.md§14, a committed file; inbound email is data the agent reads, never instructions it is asked to follow. This is a design intention, not an enforced boundary — it is exactly what a successful injection defeats, which is why (b) matters. - Credential hygiene inside the run.
nice -n 5, hard 45-minute cap, and the Gmail refresh token and client secret handed tocurlon stdin rather than as command-line arguments —/proc/<pid>/cmdlineis world-readable and bms-4 runs up to four concurrent agent sessions (§Capacity). - Visible within 24 h. Every run leaves a full transcript on bms-4, and the owner reads the digest each morning. This is detection, not prevention.
If the granted scopes are ever widened — particularly to gmail.settings.* or a hard-delete
scope — re-assess C-R6 before the next run; that changes (a)‘s worst case from “recoverable mail”
to “persistent mailbox rule” or “irrecoverable loss”.
Personal data. Inbox content is personal data and includes third-party correspondents. It is
read in place via the Gmail API and never copied to Supabase or any other store. The per-run
transcript under /var/log/p24-infra-workers/ is the only durable artefact on bms-4 that can
quote subjects and senders; its 0600 mode and 30-day prune are implemented in the runner and
described in §Run transcript and its retention above, alongside the 7-day n8n execution TTL.
See also
docs/playbooks/n8n/n8n-ssh-worker-pattern.md— the SSH →claude -ppattern this followsdocs/playbooks/n8n/n8n-workflow-creation.md— creation/activation reference.claude/commands/n8n.md§Known Workflows — registry entrydocs/gmail-tools-operations.md— the parentgmail-toolsservice (clone, SOPS, credentials)docs/eu-ai-act-compliance.md·docs/ai-risk-data-governance.mdPart C — AI-system registration