Skip to content

fix(channel/telegram): authorize the account that taps an approval button - #22

Open
JordanTheJet wants to merge 314 commits into
masterfrom
fix/telegram-callback-approval-authz
Open

fix(channel/telegram): authorize the account that taps an approval button#22
JordanTheJet wants to merge 314 commits into
masterfrom
fix/telegram-callback-approval-authz

Conversation

@JordanTheJet

Copy link
Copy Markdown
Owner

Summary

  • Base branch: master
  • What changed and why: The Telegram inline-keyboard callback handler resolved an approval verdict from the callback payload alone — it read id and data but never from. Every message path in this channel authorizes the sender against the channel allowlist first; the callback path did not. An inline keyboard is visible to every member of the chat it was posted in, so the tap that answers an approval prompt now runs the same is_any_user_allowed check.
  • The verdict path moves out of listen() into resolve_approval_callback, returning a typed ApprovalCallbackOutcome (NotApproval / Unauthorized / UnknownAction / Resolved). That puts the decision at a real, testable boundary instead of inline in the long-poll loop.
  • A callback carrying no attributable from account fails closed, including under a wildcard allowlist — a new external surface defaults closed.
  • An unlisted tap leaves the approval pending so the authorized operator can still decide it, and gets a localized refusal acknowledgement rather than a silent no-op.
  • Scope boundary: Only the Telegram callback path. No change to how approvals are requested, to the approval broker, to other channels' approval surfaces, or to the allowlist semantics themselves. Binding an approval to the specific operator who triggered the turn (rather than to the channel allowlist) is a separate, larger change and is not attempted here.
  • Blast radius: Telegram channel only. Non-approval callback payloads keep their existing behavior and return before any authorization work.
  • Linked issue(s): N/A
  • Labels: type:fix, risk:low, size:S

Testing (required)

How you can test

  • Reviewer testing requested? Yes
  • Interface(s) exercised: channel = telegram
  • Setup / preconditions: A Telegram channel configured with allowed_users naming one account, and a second account in the same group chat that is not on that list. Trigger any tool call that requests approval so the inline keyboard is posted.
  • Steps to run: From the second (unlisted) account, tap Approve on the prompt.
  • Expected on this branch (after): The tap is refused with "You are not authorized to answer this approval", the approval stays pending, and the listed operator can still decide it.
  • Prior behavior on master (before): The same tap resolves the approval and the tool call proceeds.

How I tested

Seven new tests at the decision boundary, written before the implementation (all failed with "method not found" first):

  • callback_actor_identities_carry_username_and_numeric_id
  • callback_actor_denied_when_from_is_absent — fail-closed under a wildcard allowlist
  • callback_actor_denied_when_not_on_allowlist
  • callback_actor_allowed_by_username_or_numeric_id
  • unauthorized_tap_does_not_resolve_a_pending_approval — asserts the oneshot is unresolved and the approval is still pending
  • authorized_tap_resolves_the_pending_approval
  • unknown_action_from_an_allowed_actor_is_reported_not_resolved
  • non_approval_callback_data_is_ignored_before_any_authz
cargo test -p zeroclaw-channels --lib telegram
# test result: ok. 223 passed; 0 failed; 1 ignored; 0 measured; 1133 filtered out

cargo fmt --all -- --check          # clean
cargo clippy -p zeroclaw-channels --all-targets -- -D warnings   # clean
bash scripts/ci/comment_hygiene_gate.sh   # Comment hygiene gate passed.
  • CI checks relied on and why they cover this change: Rust quality gate covers the changed crate; the telegram test module is the behavior boundary for this path.
  • Known CI coverage gap, if any: The long-poll listen() loop itself is not exercised end-to-end against a live Bot API; the extracted resolve_approval_callback is what the tests drive, and listen() is a thin dispatch over its outcome.
  • Beyond CI, what did you manually verify? I verified the identity-extraction shape matches what the message paths already build (username normalized via normalize_identity, plus the numeric sender id). I did NOT run against a live Telegram bot.

Security & Privacy Impact (required)

  • New permissions, capabilities, or file system access scope? No (it narrows an existing one)
  • New external network calls? No
  • Secrets / tokens / credentials handling changed? No
  • PII, real identities, or personal data in diff, tests, fixtures, or docs? No — fixtures use alice / eve and synthetic numeric ids
  • Prompt injection or untrusted model-visible text introduced/changed? No
  • If any Yes, describe the risk and mitigation: N/A. This PR tightens an approval gate; the residual gap it does not close is that the check is allowlist-wide rather than bound to the specific operator who triggered the turn.

JordanTheJet and others added 30 commits July 18, 2026 20:38
…eroclaw-labs#9014)

- e530dd4 ci(release): notarize + staple the macOS .dmg for offline validation
- da1be7e fix(ci): publish the notarized macOS DMG deterministically
- 649a013 test(ci): keep desktop release guards mergeable
- 40bca67 fix(ci): keep zero-DMG failure portable
- be2fd5c Merge branch 'master' into ci-staple-macos-dmg
- 4c8b791 docs(governance): define accepted RFC dispositions
- 9c9d22b Merge branch 'master' into codex/fnd003-rfc-dispositions
- 22417ab feat(zerocode): add agent rename dashboard flow
- ec964a8 chore(zerocode): refresh agent rename branch
- 35ec476 Merge branch 'master' into codex/issue-7790-zerocode-agent-rename
…zeroclaw-labs#9133)

- b3d021f docs(architecture): record channel plugin and gateway process targets
- c8c7fd6 Merge branch 'master' into codex/adr-006-007-targets
…law-labs#9130)

- cec0fb1 fix(config): restore generated descriptions and code contracts
- e0e0a7e fix(gateway): restore pending reload contract description
- 13ebdba Merge origin/master into codex/restore-8901-contracts
- a39f4ad Merge branch 'master' into codex/restore-8901-contracts
…claw-labs#9026)

- b0a7138 feat(gateway/acp): select session agent via ?agent= query param
- d163820 fix(gateway/acp): reject disabled agents at shared alias boundary
- 28aba77 docs(acp): replace prose em-dashes in ?agent= section
- 306178a fix(gateway/acp): keep ?agent= out of session restore rebind
- bfcde2f Merge branch 'master' into feat/acp-agent-query-param
…9146)

The hardware peripherals design doc linked to a non-existent GitHub repository. Replace it with the canonical kevinmehall/nusb repository verified through crates.io.
…8s (zeroclaw-labs#9147)

Replace the nonexistent zeroclaw-templates repository link with the in-repo deploy-k8s sample manifests and correct the Helm wording.
…labs#9148)

Remove optional angle brackets from two CommonMark link destinations to match the surrounding documentation style.
…9150)

Remove the trailing space inside the Mattermost voice-message code span so markdownlint MD038 passes.
…rt (zeroclaw-labs#9151)

Escape the line-leading zeroclaw-labs#6960 reference so markdownlint does not parse it as an ATX heading.
…zeroclaw-labs#8622)

- 595c61b feat(channels): emit structured login lifecycle events for QR pairing
- 5032f0e fix(log): keep QR pairing credentials off the persisted JSONL trace
- 480dbd7 fix(log): flush writer before reading persisted trace in ephemeral-attrs test
- 2f355cd fix(log,gateway): keep QR pairing credentials off verbose stderr and unauthenticated SSE
- a6a0792 Merge upstream/master into feat/channel-login-events
- 13a6205 fix(gateway,log,runtime): hold the pairing-credential boundary across every broadcast consumer
- 497a22c Merge upstream/master into feat/channel-login-events
- 0a2f53b style(gateway,log,runtime): rustfmt the credential-boundary test helpers
- 9b9fe0d Merge upstream/master into feat/channel-login-events
…9122)

- ee12041 refactor(plugins): establish canonical instance scope
- 1f22dc3 fix(plugins): require adapter opt-in for HTTP
- 150c82a fix(plugins): preserve callable tool attribution
- caed50f docs(plugins): require grant and adapter opt-in
- 03e7153 Merge branch 'master' into feat/plugin-instance-context
- 6b44540 fix(cli): apply config-dir before locale detection
- c06956e fix(cli): preserve clap argument ownership
- 78e4317 Merge origin/master into benchmark-memory-boot-metrics
…zeroclaw-labs#9094)

- 18b4c32 fix(zerocode): separate the literal Ctrl label from the key on darwin
- 576fbc6 Merge branch 'master' into fix/darwin-ctrl-chord-display-assertion
…#9161)

- c7dc0bc docs(ci): correct stale Rust-CodeQL note in codeql.yml
- dfc5088 Merge branch 'master' into chore/codeql-comment-rust-split
…bs#9176)

- 6275e56 docs(reference): correct operator-facing contract claims
- 139610a docs(reference): narrow operator contract claims
…oclaw-labs#9165)

The 'Determine version tag' step in docs-deploy.yml expanded
${{ github.event.inputs.tag }} directly into the run: script, one line
before the regex that validates it. GitHub performs template
interpolation before the shell executes, so a crafted workflow_dispatch
tag input (e.g. '"; <cmd>; "') would run arbitrary commands before the
validation could reject it — an Actions script-injection (CWE-94) in a
job that holds contents: write.

Pass github.event_name / event.inputs.tag / github.ref / github.ref_name
through env vars and reference them as shell variables, so template
values reach the shell as data and can never be interpreted as code. The
validation regex and all downstream behavior are unchanged.

Exploitability is low (workflow_dispatch requires repo write access and
the regex bounds valid values), but this is the canonical Actions
injection anti-pattern and the fix is free.
…eroclaw-labs#9152)

resolve_sops_dir() only handled the None/empty case relative to the
workspace; an explicit relative sops_dir (the shape shown in the
documented <workspace>/sops layout) fell through to PathBuf::from(),
which resolves against whatever directory the process happens to be
running in rather than the workspace. Path::join already replaces the
base when the joined path is absolute, so workspace_dir.join(expanded)
is a strict superset: absolute and ~-prefixed values behave exactly as
before, relative values now resolve against the workspace as documented.
…s#9144)

- f26c269 fix(ci): publish Docker variants before scheduled scans
- ecd5fc8 fix(ci): avoid inheriting release secrets
- ab5f592 test(ci): lock container release invariants
…#9132)

- 17d68d3 docs(architecture): document background work lifecycle
- ddb6ff2 docs(architecture): clarify background wait boundaries
- 2aca605 fix(ci): make comment hygiene gate language-aware
- 4468acb test(ci): cover comment scanner fatal path
- 4367404 fix(i18n): localize agent-scope rejection
- fe7447a Merge origin/master into codex/issue-6548-agent-scope-rejection-locales
…claw-labs#9043)

- 33e3a05 docs(install): align first-run guidance with installer behavior
- 41e27d4 docs(install): qualify supported prebuilt targets
- 60b366 docs(install): qualify Cargo bin destination
@JordanTheJet

Copy link
Copy Markdown
Owner Author

Note on the diff size. This branch is based on upstream/master (e91a7a0c6), but this fork's master is 313 commits behind upstream, so GitHub renders the base gap as ~1000 changed files. The real change is what the commit contains:

crates/zeroclaw-channels/src/telegram.rs   | 292 +++++++-----
crates/zeroclaw-runtime/locales/en/cli.ftl |   1 +
2 files changed, 251 insertions(+), 42 deletions(-)

Review with git diff upstream/master...fix/telegram-callback-approval-authz. Syncing this fork's master needs a decision first: merging upstream conflicts in 5 files, all fork-specific release/desktop CI (release-stable-manual.yml, tests/architecture/desktop_release.rs, tests/architecture/release_workflow.rs, tests/test_architecture.rs, apps/tauri/tests/capability_security.rs).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment