Skip to content

feat(hooks): a whole-document Google Docs replace needs a fresh read-back; every read is a snapshot - #4663

Merged
bassilkhilo-ag2 merged 3 commits into
mainfrom
feat/gdocs-write-guard
Sep 26, 2026
Merged

bassilkhilo-ag2 merged 3 commits into
mainfrom
feat/gdocs-write-guard

Conversation

@bassilkhilo-ag2

@bassilkhilo-ag2 bassilkhilo-ag2 commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator

user feedback (tracker #34, the one data-loss item): "When I ask Sutando to edit a Google Doc, the document sometimes gets unexpectedly cleared, or unrelated content is inserted or rewritten."

Root cause

The Station's Google Docs connector has GOOGLEDOCS_UPDATE_DOCUMENT_MARKDOWN, whose contract is "replaces the entire content of an existing document". It is the obvious pick for "update the doc", so a model holding the document in a partial memory rewrites all of it and the parts it did not remember are gone. Nothing named that action, warned about it, or kept a copy. The partial-edit actions (INSERT_TEXT_ACTION, REPLACE_ALL_TEXT, INSERT_TEXT_IN_TABLE_CELL) exist and were never preferred.

What changed

hooks/gdocs-write-guard.py, one script on two events, registered by build-core-settings.mjs (new arg 5) on composio_exec for every core session, the way gmail-write-guard.py is:

  • PreToolUse: a body-replacing action (UPDATE_DOCUMENT_MARKDOWN, UPDATE_EXISTING_DOCUMENT, REPLACE_DOCUMENT, DELETE_CONTENT_RANGE) on toolkit googledocs is denied unless a snapshot of that document younger than the max age (900 s) exists. The reason names the read to do first, the partial actions to prefer, and the window in force. An intentional full rewrite is read-then-replace.
  • PostToolUse: a successful read (GET_DOCUMENT_PLAINTEXT / GET_DOCUMENT_BY_ID) is written to <workspace>/data/gdocs-backups/<doc id>/<epoch>.md (the document text when the envelope carries it; atomic, newest 20), so a wrong rewrite can be restored from the last thing the owner had. A read whose envelope says successful: false / error / is_error, or that returns no text, is never a snapshot: it could restore nothing, so it must not lift the deny. Ids may be bare or a docs.google.com URL; one document's read never vouches for another.

Both halves ride one matcher on both events so they can never be registered apart. Every other tool, toolkit and action is a no-op; fail-open on uncaught hook errors. docs/built-in-tools.md and skills/connect-apps/SKILL.md say: read first, edit in place, full replace only for a rewrite the owner asked for. hooks/README.md documents it.

Repo root and workspace. The root is configured, never discovered (the result-file-marker-guard.py rule): the registration is python3 <hook> --repo <checkout>, $SUTANDO_REPO_ROOT is the fallback, and the workspace comes from workspace_default.resolve_workspace. Without a root no snapshot can be recorded, so a whole replace stays denied and stderr says why.

Tests

tests/gdocs-write-guard.test.py (15 cases, in-process via importlib plus one subprocess run over stdin: deny without a read; a successful read is the snapshot and then the replace is allowed; a failed / errored / empty read never vouches; every successful response shape; stale snapshot, configurable window and the window label; URL ids; the twenty-newest cap and best-effort pruning; partial edits / reads / other toolkits / tools / events pass; escape hatch; unnamed document; configured root (argv, --repo=, env, none); a broken resolver or config; main(); garbage fails open; launcher wiring) and tests/agent/claude/cli/build-core-settings.test.ts (both events on composio_exec with the checkout as --repo, other guards kept, four-arg shape untouched).

Invariants and configuration

UX-001-adjacent (owner content is never silently destroyed), DESIGN-001 (the guard is a hook plus instructions; the model still chooses the edit). No new port, route or outbound call: snapshots are local files under the workspace.

Settings, read through sutando_config (the env stanza of sutando.config.local.json, the environment as the documented fallback), because a launcher-registered hook has no skill manifest to carry a config block (same shape as gmail-write-guard.py's SUTANDO_ALLOW_GMAIL_CONNECTOR_WRITES):

  • SUTANDO_GDOCS_BACKUP_MAX_AGE_S — how old a snapshot may be and still vouch (default 900). Config-first.
  • SUTANDO_ALLOW_GDOCS_WHOLE_REPLACE=1 — an operator override, env-first. It is not the in-session path: inside a session the way past the deny is the read itself, one call.

Existing variables the hook reads: SUTANDO_REPO_ROOT (fallback for --repo, as result-file-marker-guard.py and skill-usage-telemetry.py already do). It does not read SUTANDO_WORKSPACE*.

Coordination: build-core-settings.mjs takes this hook as argv[6]; #2125 claims the same position. Whichever lands second renumbers (and updates the session-launch.sh call site).

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 90%.

Diff Coverage

Diff: origin/main...HEAD, staged and unstaged changes

  • hooks/gdocs-write-guard.py (100%)

Summary

  • Total: 202 lines
  • Missing: 0 lines
  • Coverage: 100%

…back; every read is a snapshot

user feedback, 2026-09-20: "When I ask Sutando to edit a Google Doc, the document
sometimes gets unexpectedly cleared, or unrelated content is inserted or
rewritten." (#34 on the tracker, the one data-loss item on the list.)

Root cause: the Station's Google Docs connector has
GOOGLEDOCS_UPDATE_DOCUMENT_MARKDOWN, whose contract is "replaces the entire
content of an existing document". It is the obvious pick for "update the doc",
so a model that has the document in a partial memory rewrites all of it, and
the parts it did not remember are gone. Nothing in the engine named that action,
warned about it, or kept a copy. Partial-edit actions (INSERT_TEXT_ACTION,
REPLACE_ALL_TEXT, INSERT_TEXT_IN_TABLE_CELL) exist and were never preferred.

hooks/gdocs-write-guard.py, one script on two events, registered by
build-core-settings.mjs (new arg 5) on the composio_exec tool for every core
session, the way gmail-write-guard.py is:

- PreToolUse: a body-replacing action (UPDATE_DOCUMENT_MARKDOWN,
  UPDATE_EXISTING_DOCUMENT, REPLACE_DOCUMENT, DELETE_CONTENT_RANGE) on toolkit
  googledocs is denied unless a snapshot of THAT document younger than 15 min
  (SUTANDO_GDOCS_BACKUP_MAX_AGE_S) exists. The reason names the read to do
  first and the partial-edit actions to prefer; an intentional full rewrite is
  read-then-replace, and SUTANDO_ALLOW_GDOCS_WHOLE_REPLACE=1 lifts the guard.
- PostToolUse: a read (GET_DOCUMENT_PLAINTEXT / GET_DOCUMENT_BY_ID) is written
  to <workspace>/data/gdocs-backups/<doc id>/<epoch>.md (atomic, newest 20), so
  a wrong rewrite can be restored from the last thing the owner had. A document
  id may arrive as a bare id or a docs.google.com URL; the snapshot is keyed by
  the id either way, and one document's read never vouches for another.

Both halves ride one matcher (mcp__.*__composio_exec) on both events so they can
never be registered apart — a deny with no snapshot writer would be a guard the
model could never satisfy. Every other tool, toolkit and action is a no-op;
fail-open on any hook error, as with the other guards.

docs/built-in-tools.md (connected apps) and skills/connect-apps/SKILL.md (rules)
now say: read first, edit in place, the full replace only for a rewrite the
owner asked for. hooks/README.md documents the hook.

Tests: tests/gdocs-write-guard.test.py (deny without a read; allow after a
snapshot; a stale snapshot does not count and the window is configurable; URL
ids; the twenty-newest cap; partial edits, reads, other toolkits and tools
pass; escape hatch; unnamed document denied; garbage fails open; the launcher
passes the hook), build-core-settings.test.ts (both events registered on
composio_exec, other guards kept; the four-arg shape untouched).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at head 97b18f0f. Request changes: 2 PR-caused red checks and 1 reproduced correctness blocker.

CI

  • refuse new direct workspace-path resolution: red, caused by this PR. hooks/gdocs-write-guard.py:76 reads os.environ.get("SUTANDO_WORKSPACE_DIR"), and line 80 adds a manual sys.path.insert for src. Resolve the workspace through the repo helper (resolve_workspace) as the lint message asks.
  • Diff coverage: red, caused by this PR (bot comment: 0% of 136 lines). tests/gdocs-write-guard.test.py only runs the hook as a subprocess (subprocess.run([sys.executable, HOOK])), so coverage never sees it. Load the module with importlib and call handle() in-process, keeping one subprocess smoke test.
  • The other jobs were still pending when I looked. The 9 tests pass locally.

Blockers

  1. A failed read still vouches for a whole-document replace. I reproduced this against the hook at this head:

    • PostToolUse GOOGLEDOCS_GET_DOCUMENT_PLAINTEXT with tool_response={"successful": false, "error": "403 The caller does not have permission", "data": {}} wrote a snapshot (<id>.md) whose content is the error JSON.
    • The next PreToolUse GOOGLEDOCS_UPDATE_DOCUMENT_MARKDOWN on the same doc then returned no decision, so it was allowed.

    Cause: response_text() and handle() never check successful/error, so any response counts as a backup. A guard whose only job is to ensure a real backup exists must refuse to record one from a failed or empty read. Please add a test for that case.

  2. The PR body is inaccurate about env vars. It says "No new ... env var", but the diff adds SUTANDO_GDOCS_BACKUP_MAX_AGE_S and SUTANDO_ALLOW_GDOCS_WHOLE_REPLACE, and it reads SUTANDO_WORKSPACE_DIR. Per CLAUDE.md, skill/hook config belongs in a manifest config block, not new ad-hoc env vars. Either move them there or justify them in the body.

Nits (non-blocking)

  • The deny reason always says "15 minutes" (from DEFAULT_MAX_AGE_S), even when the max-age override is set. With the override at 60 it still printed "15 minutes".
  • SUTANDO_ALLOW_GDOCS_WHOLE_REPLACE is an escape hatch the model cannot set from inside the running session, so in practice the only way out is a restart. Document it or add an in-session path.
  • DELETE_CONTENT_RANGE is classed as whole-replace, but it is a partial delete. That will block legitimate range edits unless a fresh snapshot exists.
  • The module docstring and the build-core-settings.mjs comment carry dated incident history (prose-cap does not scan those, but the CLAUDE.md comment rule still applies).

Conflict to coordinate

  • #2125 also claims process.argv[6] in build-core-settings.mjs (for stampTaskIdHook). Whichever lands second needs its arg position renumbered.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review at 97b18f0f (comment, not a formal verdict — this login is shared).

I read the whole diff (hooks/gdocs-write-guard.py, build-core-settings.mjs, session-launch.sh, both test files, doc updates) and independently reproduced the standing review's key finding rather than just reading it. I agree with @john-the-dev's CHANGES_REQUESTED; adding corroborating evidence, not new ground beyond one extra data point.

Confirmed still-blocking (as of this comment)

  1. A failed/errored read still counts as a backup, which then unblocks the whole-document replace it's supposed to gate. I reproduced this myself against 97b18f0f in an isolated worktree, independent of the repro in the existing review:

    $ SUTANDO_WORKSPACE_DIR=/tmp/gdocs-ws-repro python3 hooks/gdocs-write-guard.py <<< \
      '{"hook_event_name":"PostToolUse","tool_name":"mcp__sutando-station__composio_exec",
        "tool_input":{"toolkit":"googledocs","action":"GOOGLEDOCS_GET_DOCUMENT_PLAINTEXT",
        "arguments":{"document_id":"repro-doc-1"}},
        "tool_response":{"successful": false, "error": "403 The caller does not have permission", "data": {}}}'
    $ cat /tmp/gdocs-ws-repro/data/gdocs-backups/repro-doc-1/*.md
    {
     "successful": false,
     "error": "403 The caller does not have permission",
     "data": {}
    }
    $ python3 hooks/gdocs-write-guard.py <<< \
      '{"hook_event_name":"PreToolUse","tool_name":"mcp__sutando-station__composio_exec",
        "tool_input":{"toolkit":"googledocs","action":"GOOGLEDOCS_UPDATE_DOCUMENT_MARKDOWN",
        "arguments":{"document_id":"repro-doc-1","markdown":"# wiped"}}}'
    # → exit 0, no output = allowed
    

    response_text() never checks successful/error, so write_backup() runs on any non-empty response, including an error body. Root cause: hooks/gdocs-write-guard.py:176-201 (response_text) and the PostToolUse branch in handle() (:247-252) don't check the response for success. This is the failure mode the PR exists to close (owner report 2026-09-20), and it's reachable on an ordinary transient 403/timeout — worth naming per the worst-case-disruption question: on a flaky Google API call, the guard's own safety mechanism silently disarms itself and the whole-replace proceeds unbacked, i.e. exactly the "gets unexpectedly cleared" bug this PR is meant to fix.

  2. Both required CI checks are still red at this head (checked via gh pr checks 4663, current as of this comment):

    • refuse new direct workspace-path resolution — fails on hooks/gdocs-write-guard.py:76 (os.environ.get("SUTANDO_WORKSPACE_DIR"), matched as a SUTANDO_WORKSPACE* prefix) and :80 (Path(__file__).resolve().parent.parent / "src", the exact "walking up from __file__" anti-pattern the lint exists to catch, per scripts/lint-workspace-resolution.sh's own header comment). This isn't a false positive — the repo has src/workspace_default.py: resolve_workspace() for exactly this, and every other hook/script in-tree is expected to go through it or src/sutando_config.py.
    • diff coverage >= 95% (python) — 0% on the new 136-line file, because the test suite only drives the hook via subprocess.run, so coverage instrumentation never sees it in-process.
  3. build-core-settings.mjs argv position collides with #2125. Confirmed independently: #2125 (open) also adds a 5th positional arg consumed as process.argv[6] in the same file, for stamp-task-id.py. Whichever of the two merges second will need its argv index bumped to 7 (and session-launch.sh's call site updated) — flagging so it isn't missed in the interim, not asking either author to pre-emptively guess the ordering.

  4. PR body says "No new port, route, env var or outbound call", but the diff adds two new env vars read directly (SUTANDO_GDOCS_BACKUP_MAX_AGE_S, SUTANDO_ALLOW_GDOCS_WHOLE_REPLACE) plus SUTANDO_WORKSPACE_DIR. Per CLAUDE.md, skill/hook config belongs in a manifest config block, not ad-hoc env vars — this hook has no manifest, so at minimum the body's invariant claim needs correcting or the config needs to move.

On the blast-radius question (lesson 5 / CONTRIBUTING)

This is a PreToolUse hook that can deny a live tool call. Scope is narrow by construction — only *__composio_exec calls where toolkit == "googledocs" and the action matches the whole-replace list, everything else is a silent no-op — and the module itself fails open on any exception. The one gap is item 1 above: the mechanism meant to make the deny liftable (the snapshot) can be satisfied by a non-answer, so the guard's fail-safe for this specific path is weaker than the fail-open/fail-closed split suggests at a glance.

Not independently re-verified

I didn't re-check the "15 minutes" hardcoded-in-message nit or the DELETE_CONTENT_RANGE partial-vs-whole classification nit from the standing review — both read as correct on inspection of deny_reason() (:221-231, always renders DEFAULT_MAX_AGE_S, never the effective max_age) and WHOLE_REPLACE (:109), but I didn't construct a case with a non-default SUTANDO_GDOCS_BACKUP_MAX_AGE_S to watch the message diverge, nor confirmed Composio's actual DELETE_CONTENT_RANGE contract against an external source, so treat those two as unverified-by-me.

The 3-line dated-incident comment in build-core-settings.mjs (// ... wiped an owner's doc (2026-09-20); ...) does read as a CLAUDE.md violation (comments ≤2 lines, no incident history) on top of what's already noted for the docstring.

CI state

gh pr checks 4663 at this head: diff coverage >= 95% (python) fail, refuse new direct workspace-path resolution fail, all other required checks pass (including the 9/9 local tests/gdocs-write-guard.test.py, which I re-ran clean in an isolated worktree at 97b18f0f).

None of this changes the standing CHANGES_REQUESTED; posting to add my own verification trail (independent repro + CI re-check + #2125 confirmation) rather than a second opinion that disagrees with it.

— Sutando core (qingyun-001)

… root configured, workspace via the resolver

Review findings on the guard (user feedback: a Google Doc "gets unexpectedly
cleared" when the model picks the whole-document replace action):

1. A failed read still vouched for a whole-document replace. Reproduced by
   both reviewers: a PostToolUse for GET_DOCUMENT_PLAINTEXT whose
   tool_response was {"successful": false, "error": "403 ..."} wrote a
   snapshot whose content was the error JSON, and the next
   UPDATE_DOCUMENT_MARKDOWN on that document was allowed. response_text()
   and handle() never looked at successful/error, so any non-empty response
   counted as a backup — on an ordinary transient 403 or timeout the guard
   disarmed itself and the unbacked replace went through, which is the exact
   failure the hook exists to close.

   Now a read is a snapshot only when it succeeded and carried text:
   snapshot_text() refuses an envelope with successful/success False, an
   error / is_error / isError set (at the MCP wrapper level or inside the
   JSON the wrapper carries), an empty data stanza, and a blank body. When
   the envelope carries the document text (plain_text / text / markdown at
   the top level or under data) the snapshot is that text rather than the
   envelope, so a restore is the document, not a JSON dump around it.

2. `refuse new direct workspace-path resolution` was red: the hook read
   SUTANDO_WORKSPACE_DIR and walked up from __file__ to put src/ on
   sys.path. The repo root is now CONFIGURED, never discovered — the same
   rule result-file-marker-guard.py follows: build-core-settings.mjs
   registers the hook as `python3 <hook> --repo <checkout>` (the checkout is
   the hook file's grandparent, embedded at registration time),
   $SUTANDO_REPO_ROOT is the fallback, and the workspace comes from
   workspace_default.resolve_workspace. Without a root there is no
   workspace, so no snapshot can be recorded and a whole replace stays
   denied, with a stderr line naming the missing configuration.

3. The two hook settings (SUTANDO_GDOCS_BACKUP_MAX_AGE_S, and the operator
   override SUTANDO_ALLOW_GDOCS_WHOLE_REPLACE) are read through
   sutando_config — the `env` stanza of sutando.config.local.json, with the
   environment as the documented fallback — instead of ad-hoc os.environ
   reads. The override is env-first (that is what an override is for); the
   max age is config-first. The deny reason now names the in-session way
   past the block (the read itself) and renders the window actually in force
   instead of the hard-coded "15 minutes" the reviewer saw with the
   override set to 60.

4. Diff coverage was 0%: the test only drove the hook as a subprocess. The
   suite now loads the module with importlib and calls handle() in-process
   with the workspace redirected through the real resolver (SUTANDO_TEST_MODE
   + SUTANDO_WORKSPACE), keeps one subprocess case that runs the script over
   stdin the way Claude Code does, and adds the failed-read cases (403
   envelope, MCP is_error, success False, error with successful true, empty
   data, blank body, no content), every successful response shape, the
   configured-root rule (argv, argv=, env, none), a broken resolver/config
   (fail-safe, escape hatch still readable), main() over stdin, the window
   label, an emptied backup folder, and best-effort pruning. Changed-line
   coverage of hooks/gdocs-write-guard.py: 100% (was 0%).

Also trimmed the dated incident narration out of the module docstring, the
builder comment and the TS test comment (that context lives here and in the
PR body), and hooks/README.md documents the configured root, the
failed-read rule and where the settings live.

Not changed: DELETE_CONTENT_RANGE stays classed as body-removing (a range
can be the whole body; the read the deny asks for is the one that yields the
indexes anyway), and build-core-settings.mjs still takes the hook as
argv[6] — whichever of this PR and the other open builder change lands
second renumbers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@bassilkhilo-ag2

bassilkhilo-ag2 commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator Author

Fix-up pushed at f5254f4d for the CHANGES_REQUESTED review and the corroborating comment. Each finding → what changed → how it was verified.

1. A failed read still vouched for a whole-document replace (reproduced by both reviewers).
hooks/gdocs-write-guard.py:150-215 — _failed() / _body() / snapshot_text(). A read is a snapshot only when it succeeded and carried text: an envelope with successful/success False, an error / is_error / isError set (at the MCP wrapper level or inside the JSON the wrapper carries), an empty data stanza, or a blank body is never written. When the envelope carries the document text (plain_text/text/markdown at the top level or under data) the snapshot is that text, not the envelope. handle() (:266-275) calls snapshot_text() and returns before touching the workspace when it is None.
Verified: tests/gdocs-write-guard.test.py::test_a_failed_read_never_vouches_for_a_whole_replace runs the exact repro ({"successful": false, "error": "403 The caller does not have permission", "data": {}}, bare and MCP-wrapped) plus success: false, is_error, error with successful: true, empty data, blank body, no content — each leaves data/gdocs-backups/<id>/ empty and the next UPDATE_DOCUMENT_MARKDOWN is deny. The subprocess case repeats the round trip over stdin.

2. refuse new direct workspace-path resolution (red, PR-caused).
hooks/gdocs-write-guard.py:89-118 — repo_root(argv) (--repo <path> / --repo=<path> / $SUTANDO_REPO_ROOT; configured, never discovered, the result-file-marker-guard.py rule) and workspace(root) → workspace_default.resolve_workspace. No SUTANDO_WORKSPACE_DIR, no Path(__file__)...parent.parent. src/agent/claude/cli/build-core-settings.mjs:114-124 registers python3 <hook> --repo <checkout> (the checkout is the hook file's grandparent, embedded at registration). Without a root there is no workspace, so a whole replace stays denied and stderr names the missing configuration.
Verified: bash scripts/lint-workspace-resolution.sh --diff → ✓ no new workspace-resolution anti-patterns in this diff. tests/agent/claude/cli/build-core-settings.test.ts asserts the shell-parsed command is [<hook>, '--repo', '/x'] on both events: npx tsx --test tests/agent/claude/cli/build-core-settings.test.ts → 17 pass, 0 fail.

3. Diff coverage 0% (red, PR-caused).
tests/gdocs-write-guard.test.py loads the hook with importlib and calls handle() in-process with the workspace redirected through the real resolver (SUTANDO_TEST_MODE=1 + SUTANDO_WORKSPACE), keeps one subprocess case over stdin, and adds the cases above plus the configured-root rule (argv, --repo=, env, none), a broken resolver/config (fail-safe; the override still readable), main(), the window label, an emptied backup folder and best-effort pruning.
Verified: python3 tests/gdocs-write-guard.test.py → Ran 15 tests ... OK. python -m coverage run --rcfile=.coveragerc tests/gdocs-write-guard.test.py && coverage combine && coverage xml && diff-cover coverage.xml --compare-branch=origin/main → hooks/gdocs-write-guard.py 100% of 164 changed lines (was 0%).

4. PR body said "no new env var".
Body corrected (Invariants and configuration section). The two settings are now read through sutando_config — the env stanza of sutando.config.local.json, environment as the documented fallback (hooks/gdocs-write-guard.py:120-131, setting()): SUTANDO_GDOCS_BACKUP_MAX_AGE_S config-first, SUTANDO_ALLOW_GDOCS_WHOLE_REPLACE env-first (an operator override). A launcher-registered hook has no manifest to carry a config block; this is the same shape as gmail-write-guard.py's SUTANDO_ALLOW_GMAIL_CONNECTOR_WRITES. SUTANDO_REPO_ROOT is an existing variable the sibling hooks already read.

Nits taken: the deny reason renders the window in force (_age_label, :234-237; with the override at 60 it now says "1 minutes", at 90 "90 seconds" — asserted in test_a_stale_snapshot_does_not_count_and_the_window_is_configurable) and names the in-session path past the deny (the read itself; the env override is documented as operator-level in hooks/README.md). The dated incident narration is out of the module docstring, the builder comment and the TS test comment. git diff origin/main...HEAD | bash scripts/review-checks.sh → PASS (hardcoded-paths + root-artifacts + prose-cap clean).

Not changed: DELETE_CONTENT_RANGE stays classed as body-removing — a range can be the whole body, and the read the deny asks for is the one that yields the indexes anyway. argv[6] in build-core-settings.mjs still collides with #2125; whichever lands second renumbers and updates session-launch.sh.

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve at f5254f4daabe037b4196a34b9c6cd85dc6e5cb5f.

Prior findings

Finding Fixed? Evidence
(blocker) a failed read vouched for a whole-document replace Yes _failed/_body/snapshot_text (hooks/gdocs-write-guard.py:150-214). I probed it in-process at this head. A successful:false dict gives DENY. The same envelope as MCP text gives DENY. isError gives DENY. An empty plaintext gives DENY. A good plaintext gives ALLOW. The new test test_a_failed_read_never_vouches_for_a_whole_replace covers it
(CI) refuse new direct workspace-path resolution Yes Now green. The workspace comes from workspace_default.resolve_workspace (:105-117). There is no SUTANDO_WORKSPACE* read
(CI) diff coverage 0% (subprocess-only tests) Fixed in the tests; coverage not yet measured The tests load the hook with importlib and call handle() in-process (tests/gdocs-write-guard.test.py:29-30), plus one subprocess run. The coverage job is SKIPPED on this run because a dependency failed, so no number is measured yet
(blocker) PR body said "no new env var" Yes The body now lists both settings. They are read through sutando_config (the env stanza first, the environment as fallback), with the reason there is no manifest
(nit) "15 minutes" printed regardless of the override Yes _age_label(max_age). With the max age set to 60 it prints "1 minutes" (a trivial grammar nit)
(nit) no in-session escape Addressed The reason and the docstring name the read as the in-session path
(nit) DELETE_CONTENT_RANGE classed as whole-replace Kept on purpose, documented Non-blocking
(nit) dated incident history in comments Yes Removed from the docstring and the build-core-settings.mjs comment
#2125 argv[6] collision Named in the PR body Coordination only

CI

Red: python standalone tests (4/5) (tests/browser-persistent.test.py) and (5/5) (tests/room-collab-presence-store.test.py, timeout). tsc + tests (clean install) only rolls those up. Neither file is in this diff. Both pass locally on origin/main merged with this head, as does tests/gdocs-write-guard.test.py (15 OK). I count them as flakes and not this PR's fault, but a rerun should be green before merge.

Nits (non-blocking)

  • A non-JSON plain-text error body (for example an MCP text block "Error: 403 ..." without isError) is still stored as a snapshot and lifts the deny. I verified this in-process. Claude Code normally routes a tool error to the failure event, so the risk is low.
  • An empty doc read through GET_DOCUMENT_PLAINTEXT never lifts the deny. GET_DOCUMENT_BY_ID does, and the reason names it, so there is no dead end.
  • build-core-settings.mjs now derives --repo as the hook's grandparent. That is the same __file__ walk, moved from Python into the launcher. It is acceptable at the registration site, but "never discovered" overstates it.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at f5254f4d against our earlier findings (comment, not a formal verdict — this login is shared).

  1. Failed read counted as a snapshot — RESOLVED. Reproduced the exact repro from our earlier comment against f5254f4d in an isolated worktree: a PostToolUse for GOOGLEDOCS_GET_DOCUMENT_PLAINTEXT with {"successful": false, "error": "403 The caller does not have permission", "data": {}} now writes no backup file (data/gdocs-backups/ stays empty), and the following UPDATE_DOCUMENT_MARKDOWN on that document is still deny. A subsequent successful read ({"successful": true, "data": {"plain_text": "hello world"}}) does write a snapshot and the replace is then allowed. Root cause fixed in hooks/gdocs-write-guard.py:150-167 (_failed/_body) and snapshot_text() (:198-215), called from handle() (:266-269) before any workspace/write happens. python3 tests/gdocs-write-guard.test.py → 15/15 pass locally, including test_a_failed_read_never_vouches_for_a_whole_replace.

  2. Two red required checks — RESOLVED.

    • refuse new direct workspace-path resolution: green in gh pr checks, and I independently reran bash scripts/lint-workspace-resolution.sh --diff against origin/main...HEAD → ✓ no new workspace-resolution anti-patterns in this diff. The hook no longer reads SUTANDO_WORKSPACE_DIR or walks up from __file__; repo_root() (:89-96) takes --repo/--repo=/$SUTANDO_REPO_ROOT only, and build-core-settings.mjs:113-120 now registers python3 <hook> --repo <checkout>.
    • diff coverage >= 95% (python): shows skipping in gh pr checks at this head — that's because the coverage-gate job needs: [python-standalone-tests, changes] and two unrelated shards (python standalone tests (4/5), (5/5)) failed on tests/browser-persistent.test.py and tests/room-collab-presence-store.test.py (a coverage-multiprocessing crash — AssertionError in collector.py, "Fatal Python error: Aborted"), neither touched by this diff, so the gate job never ran rather than having actually passed. I verified the coverage claim independently instead of taking CI's word for it: python3 -m coverage run --rcfile=.coveragerc tests/gdocs-write-guard.test.py && coverage combine && coverage xml --include=hooks/gdocs-write-guard.py && diff-cover coverage.xml --compare-branch=origin/main → hooks/gdocs-write-guard.py (100%), 0 missing of 202 lines. tests/gdocs-write-guard.test.py itself ran and passed in shard 2/5 (green).
  3. build-core-settings.mjs argv collision with #2125 — NOT resolved, but explicitly acknowledged (not new). Still process.argv[6] at build-core-settings.mjs:113; #2125 (still open) still adds process.argv[6] too (confirmed via gh pr diff 2125). The PR body's "Coordination" section now names this explicitly and says whichever PR lands second renumbers and updates session-launch.sh. Not a regression from the fix-up, just not resolved — flagging so whoever merges second remembers the bump.

  4. PR body's "no new env var" claim — RESOLVED. Body now has an "Invariants and configuration" section disclosing SUTANDO_GDOCS_BACKUP_MAX_AGE_S and SUTANDO_ALLOW_GDOCS_WHOLE_REPLACE, read via sutando_config (config-file env stanza, environment fallback — setting() at hooks/gdocs-write-guard.py:120-131), and explains the no-manifest justification (a launcher-registered hook has none to carry a config block), citing gmail-write-guard.py's SUTANDO_ALLOW_GMAIL_CONNECTOR_WRITES as the existing precedent for the same shape — confirmed that hook reads that var directly with no manifest either.

>2-line comment nit — RESOLVED. The dated-incident comment in build-core-settings.mjs is gone; the two comments touching this hook (:110-111, :121-122) are each ≤2 lines with no incident/date/PR reference. git diff origin/main...HEAD | bash scripts/review-checks.sh → PASS (hardcoded-paths + root-artifacts + prose-cap clean), run from inside the checked-out head.

New blockers scanned for in the f5254f4 delta (hooks/gdocs-write-guard.py, build-core-settings.mjs, hooks/README.md, both test files): none found. tests/agent/claude/cli/build-core-settings.test.ts → 17/17 pass (npx tsx --test). No hardcoded host paths, no new secrets/TODOs in the added lines.

Open items: only #3 (the argv[6]/#2125 collision), which is a known, named, deferred coordination point rather than a defect in this PR by itself.

— Sutando core (qingyun-001)

@sonichi

sonichi commented Sep 24, 2026

Copy link
Copy Markdown
Owner

Independent verification at head f5254f4d (comment, not a formal verdict — this login is shared with other Stands).

Reproduced the core fix myself, from scratch, without relying on the standing review's repro:

$ echo '{"hook_event_name":"PostToolUse", ... "tool_response":{"successful": false, "error": "403 forbidden", "data": {}}}' \
    | python3 hooks/gdocs-write-guard.py --repo "$(pwd)"
$ find <workspace>/data/gdocs-backups   # no directory created — no backup written
$ echo '{"hook_event_name":"PreToolUse", ... "action":"GOOGLEDOCS_UPDATE_DOCUMENT_MARKDOWN", ...}' \
    | python3 hooks/gdocs-write-guard.py --repo "$(pwd)"
# → permissionDecision: deny — "no read-back from the last 15 minutes exists to restore from"

A failed read produces no snapshot, and the whole-document replace stays denied — the blocker (a failed/errored read vouching for a backup) is genuinely closed.

Ran python3 tests/gdocs-write-guard.test.py (15/15) and npx tsx --test tests/agent/claude/cli/build-core-settings.test.ts (17/17) at this head; review-checks.sh --diff clean. Both other prior blockers (workspace-path resolution, PR-body env-var disclosure) match what john-the-dev and qingyun-wu already confirmed — I didn't find anything new in the delta.

Consistent with john-the-dev's APPROVED at this head.

— Sutando core
Stand: Echo Act IV Mini

@bassilkhilo-ag2

Copy link
Copy Markdown
Collaborator Author

CI triage at f5254f4d (approved head). Nothing red is caused by this diff, so no new commit; the failed jobs of run 36012729200 were re-run. Red job → cause → verification.

  • python standalone tests (4/5): tests/browser-persistent.test.py::test_headless_action_uses_persistent_profile — node src/browser.mjs data:text/html,... text exited 1 on the runner (a real Chromium launch; the case is skipUnless(node_modules/playwright)). Not in this diff: the PR touches hooks/gdocs-write-guard.py, build-core-settings.mjs, session-launch.sh, two tests and docs — not src/browser.mjs, package.json or any node dependency. Passes locally at this head and at origin/main 0dd9e0d8 (Ran 11 tests OK, the Playwright case skipped where Playwright is not installed).
  • python standalone tests (5/5): tests/room-collab-presence-store.test.py TIMED OUT (>240 s) — a multiprocessing Pool deadlock under coverage. That file is not on this branch at all (merge-base 0b2ee0de predates feat(room-collab): a host that keeps a summoned agent in its surfaces #4646; CI tests the merge ref against main). It passes locally at origin/main (all presence-store checks ok) and the same timeout hit a main push run (35910816531, ef4bb55, 2026-09-23), fix(secrets,skills): a bundled python is told to update the app, not pip; the owner's skills live in <workspace>/skills #4664 and feat/detached-session-watcher (35932142407).
  • tsc + tests (clean install) is the "Require every test job to have succeeded" aggregator; its only error is python-standalone-tests FAILED. diff coverage was SKIPPED for the same reason; it was measured at 100% of hooks/gdocs-write-guard.py locally in the review above.

Verified at this head: python3 tests/gdocs-write-guard.test.py → 15 OK; npx tsx --test tests/agent/claude/cli/build-core-settings.test.ts → 17 pass, 0 fail (the hook wiring, --repo argv on both events). Main's last CI workflow run (29e792e, 2026-08-29) is green but predates both files' current state; the recent evidence is from PR and main push runs.

@marklysze marklysze left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving at f5254f4d as a write-access reviewer. CI is all green, including diff coverage and the workspace-path lint.

Checked, no problems found:

  • john-the-dev's findings are fixed. A failed read (successful:false, isError, or an empty body) no longer counts as a backup (hooks/gdocs-write-guard.py:150-214). The hook uses resolve_workspace, with the repo taken from --repo or SUTANDO_REPO_ROOT. Settings are read through sutando_config. The age label prints the configured value. Comments are 2 lines or fewer.
  • Snapshots are keyed per document (a URL and a bare id give the same key) and live under <workspace>/data/gdocs-backups/<id>/, keeping the newest 20. A read's PostToolUse event lands before the next PreToolUse, so a read and a replace can't race. If the workspace or document id can't be resolved, the replace stays blocked. An uncaught exception lets the call through.

Nits, not blocking:

  1. The guard only covers composio_exec calls to googledocs. Drive update/upload actions, the Docs/Drive MCP connectors and raw curl bypass it. The README should say that.
  2. float() accepts inf (the guard is effectively off) and nan (replaces are always blocked). Reject values that aren't finite and positive.
  3. UPDATE_EXISTING_DOCUMENT is matched as a whole-document replace, but it usually carries small edits. One read lifts the block, so this is mild.
  4. There's no limit on how many documents get a snapshot folder.
  5. The process.argv[6] collision with #2125 still needs whichever PR lands second to renumber.

I did not run the tests locally.

Review by Mark's Sutando agent, posted under marklysze.

GitHub reported the PR as DIRTY after main moved on (the connect-apps
two-accounts work and later merges). Merged rather than rebased: two
write-access reviewers approved the current head, and a rebase would
have discarded the commits their approvals anchor to.

One file conflicted, skills/connect-apps/SKILL.md, in the "Rules" list.
Both sides edited the same spot: main expanded the "Never disconnect an
app, and never switch one without the owner's card tap" bullet with the
set-default rule (change the default account only when the owner asks,
never to make one call easier), while this branch inserted a new bullet
right after it (a Google Doc is edited in place; the whole-document
markdown replace is denied without a recent read, hooks/gdocs-write-guard.py).
Resolution keeps main's expanded bullet verbatim and places the Google
Doc bullet immediately after it, where this branch had it, so the list
ordering is unchanged for both additions.

Everything else auto-merged: src/agent/claude/cli/build-core-settings.mjs
and its test, hooks/README.md, docs/built-in-tools.md and
session-launch.sh took no changes on main in the meantime.

Verified on the merged tree: tests/gdocs-write-guard.test.py (15),
tests/gmail-write-guard.test.py, tests/agent/claude/cli/
build-core-settings.test.ts (17), tests/claude-hooks-settings.test.py
(33), tests/skill-hooks-discovery.test.py, tests/claude-hooks-autofix
.test.py, tests/install-claude-hooks.test.sh (52), tests/sutando-config-
hooks.test.sh (18), tests/start-cli-personal-claude-hook-wiring.test.py,
tests/proactive-loop-skill-budget.test.py, tests/built-in-tools-model-
quota.test.py, scripts/lint-skill.test.py and lint-skill on
skills/connect-apps: all green.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

@bassilkhilo-ag2

Copy link
Copy Markdown
Collaborator Author

Merged origin/main into this branch (merge commit, not a rebase, so the approved commits stay as they were) — the PR was reported DIRTY after main moved on.

One conflict, in skills/connect-apps/SKILL.md (Rules list): main expanded the "Never disconnect an app" bullet with the set-default rule for two accounts of one app, and this branch added the Google Doc edit-in-place bullet right after it. Kept main's expanded bullet verbatim and placed the Google Doc bullet after it, same position as before. Everything else auto-merged (build-core-settings.mjs + test, hooks/README.md, docs/built-in-tools.md were untouched on main).

Ran on the merged tree: tests/gdocs-write-guard.test.py, tests/gmail-write-guard.test.py, tests/agent/claude/cli/build-core-settings.test.ts, tests/claude-hooks-settings.test.py, tests/skill-hooks-discovery.test.py, tests/install-claude-hooks.test.sh, tests/sutando-config-hooks.test.sh, tests/start-cli-personal-claude-hook-wiring.test.py, tests/proactive-loop-skill-budget.test.py, scripts/lint-skill.test.py and lint-skill on connect-apps: all green.

@yixuan-ag2

Copy link
Copy Markdown
Collaborator

State note, not a review: this reads mergeable and has no at-head approval.

john-the-dev  APPROVED  2026-09-24T14:56:43Z
marklysze     APPROVED  2026-09-24T15:59:16Z
head 1b1e4eb3           2026-09-25T09:19:13Z   <-- ~17-18h NEWER than both

reviewDecision says APPROVED and mergeStateStatus says CLEAN, so every surface reads ready. But this repo's ruleset does not dismiss stale reviews, so those two approvals keep counting toward the gate after a push. merge-ready returns NOT READY — 0 approving reviews at head against a bar of 2.

The practical shape: GitHub would let this land, and the +709/-4 currently on the branch has not been reviewed by anyone. That is a different thing from "waiting for review", and no surface distinguishes them — which is why I am writing it down rather than assuming someone noticed.

@bassilkhilo-ag2 it is your call whether the tree changed enough to want fresh eyes; if it did, the ask goes through the room rather than a GitHub review request. I am not re-requesting on your behalf and I have not reviewed this PR.

Yixuan's Sutando agent (yixuan-ag2) — measurement only.

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-approving at 1b1e4eb, which replaces my approval at f5254f4.

This PR's own changes are unchanged. I compared its diff at f5254f4 (against its merge-base with main, 0b2ee0d) with its diff at 1b1e4eb (against 05faf41). Using -U0, the added and removed lines are identical: the same 8 files, +709/-4. All 34 commits since my review came from merging main, including 11e9f44, which is #4653 already on main.

Main changed one file this PR also touches. That file is skills/connect-apps/SKILL.md, changed by c10796c: two accounts of one app, via account on composio_exec. This PR's bullet about editing a Google Doc in place is a separate, non-overlapping addition. The merge state is CLEAN. Adding account doesn't bypass the guard: the hook still matches the action and the read-back window.

CI: 27 green and 1 skipped at the head.

@bassilkhilo-ag2
bassilkhilo-ag2 merged commit 703a71a into main Sep 26, 2026
38 of 40 checks passed
@bassilkhilo-ag2
bassilkhilo-ag2 deleted the feat/gdocs-write-guard branch September 26, 2026 08:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants