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.

#BlockerOwnerTracked
1n8n bms-4-root-ssh credential rejects auth — the key it stores is no longer in root@bms-4’s authorized_keyssecret-managerp24-infra#4785
2Gmail OAuth refresh token expired (invalid_grant); the Google client is in Testing mode so grants die every 7 daysresolved 2026-08-03: OAuth client moved to In production publishing status (Google Auth Platform → Odbiorcy/Audience), then re-authenticated once via node 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
3Injection blast radius not boundedOption A shipped (see below). Filesystem-level residual (Option B) tracked as a follow-up, not a blockerrepo ownerdocs/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.

OptionEffectStatus
A — restrict the agent’s tools. --allowedTools instead of --dangerously-skip-permissionsRemoves the arbitrary-execution path at its sourceShipped
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 accountShrinks what any compromise of claude-runner reaches — not just this agent’s own tool calls, which A cannot coverTracked as a follow-up issue, not a blocker for this workflow’s activation
C — accept the risk explicitlySuperseded by ANot 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 .active

The n8n API rejects read-only keys (id, active, createdAt, …) in a PUT body. If the import 400s, strip everything except name, 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

#NodePurpose
1Daily 07:00 Europe/WarsawscheduleTriggerfield: days, triggerAtHour: 7, triggerAtMinute: 0
2Build SSH CommandCode node — base64-wraps the remote runner script so no quoting survives the ssh → su → bash layers
3Run Gmail Agent on bms-4ssh node, credential bms-4-root-ssh (W8fOGECM0UwCagjd)
4Check ResultCode 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>):

MarkerMeaningFix
rc=10run log not writable — /var/log/p24-infra-workers missing, or not writable by claude-runnermkdir -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 missingRe-run the p24-infra#4721 clone step
rc=12client_id, client_secret or refresh_token missing/unreadable under $HOME/.gmail-mcpRe-run the #4721 credential placement script
rc=13Google rejected the OAuth refresh token — the error name Google returned is quoted in the markerRe-authenticate — see below
rc=14Claude 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=15the token endpoint was unreachable (DNS, egress, timeout) — not an expired grantCheck bms-4 egress to oauth2.googleapis.com; curl rc is quoted in the marker
rc=124agent exceeded the 45-minute budgetInspect the run log, consider raising timeout
rc=1claude itself exited non-zero for anything else — claude’s real exit code is quoted in the markerRead the tail in the alert, then the full run log

rc=10rc=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):

ControlImplementation
Not world-readablethe 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-4the 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 n8nthe 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 18765 on 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.js on bms-4 was permanently repointed to port 18765 — both the redirect_uri constructor arg and server.listen(...) — via sed, 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 (installed credentials shape), and Google’s loopback flow accepts any localhost port for that client type without explicit redirect-URI registration. Use port 18765, not 4242, for the tunnel and in the printed consent URL on any future re-auth — check grep -n listen /opt/gmail-tools/auth.js on 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 RESP

token.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.

RecordValue
dev_r_ai_systemssystem_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_statuscompliant
dev_r_servicesn8n-bms4/gmail-tools-daily-inbox-agent (child element of the gmail-tools service row)
Reproducible from the reposupabase/migrations/20260731120000_gmail_daily_agent_ai_system_4724.sql — idempotent guarded INSERTs for both rows, so the registration survives a rebuild
Narrativedocs/eu-ai-act-compliance.md §4 n8n AI workflows
Risk + data governancedocs/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.labels and nothing else — no gmail.settings.*, so it cannot create server-side filters or forwarding rules, the classic injection payload. It holds no gmail.*.delete scope, but gmail.modify does permit users.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-permissions means a successful injection gets arbitrary command and file execution as claude-runner on bms-4. That account’s home holds vps_root_key (root SSH to the whole fleet) and an owner-level GitHub PAT under the active gh auth login — an open P1 item in priorities.md §P1. Do not read “runs as unprivileged claude-runner” as a control: the runner shares that UID, so unsetting ANTHROPIC_API_KEY and SSH_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 to curl on stdin rather than as command-line arguments — /proc/<pid>/cmdline is 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 -p pattern this follows
  • docs/playbooks/n8n/n8n-workflow-creation.md — creation/activation reference
  • .claude/commands/n8n.md §Known Workflows — registry entry
  • docs/gmail-tools-operations.md — the parent gmail-tools service (clone, SOPS, credentials)
  • docs/eu-ai-act-compliance.md · docs/ai-risk-data-governance.md Part C — AI-system registration