380 Commits
Author SHA1 Message Date
thotam 0ae55991bb feat(mcp): MCP OAuth 2.1 client for tool servers (#1196)
* feat(mcp): MCP OAuth 2.1 client — full implementation with tests

Implements a complete MCP OAuth 2.1 authorization flow for tool servers that
require user-delegated access, covering all layers from DB to UI.

- discovery.go: RFC 9728 protected-resource → RFC 8414 AS metadata → OIDC
  fallback chain with 5-min in-memory cache and InvalidateCache()
- dcr.go: RFC 7591 Dynamic Client Registration with response size guard
- flow.go: PKCE (S256) authorization code flow — StartFlow(), ExchangeCode(),
  ClientCredentials(), auto-cleanup of expired flows; carries AS issuer through
  PendingFlow for status display
- refresher.go: OAuthTokenProvider with in-memory token cache, automatic refresh
  on expiry, per-user vs global slot isolation, InvalidateCache/InvalidateServer

- migrations/000074 + SQLite schema: mcp_oauth_tokens with AES-256-GCM encrypted
  access/refresh tokens, partial unique index for global vs per-user rows,
  ON DELETE CASCADE from mcp_servers

- store.MCPOAuthTokenStore: Upsert, Get/GetUser, Delete/DeleteUser, and
  DeleteServerOAuthTokens (purge all rows for a server)
- PostgreSQL + SQLite implementations

- POST   /v1/mcp/oauth/start      — discovery + optional DCR + PKCE redirect URL;
  client_credentials completes server-side (no redirect) and returns completed=true
- GET    /v1/mcp/oauth/callback   — exchange code, persist token, publish WS event;
  payload built via json.Marshal (no reflected XSS via error_description)
- GET    /v1/mcp/oauth/status/{id}, DELETE /v1/mcp/oauth/token/{id} — admin-gated
- POST   /v1/mcp/oauth/discover/{id} — on-demand discovery probe
- All outbound calls go through the SSRF-safe client with pinned IPs

- pkg/protocol/mcp_events.go: EventMCPOAuthComplete routed only to the initiating
  user (admins in-tenant included); fail-closed across tenants

- getUserMCPTools() injects Authorization: Bearer from OAuthTokenProvider; on a
  401 for OAuth servers it purges the cached token so the next turn re-resolves

- handleUpdateServer purges all OAuth tokens (global + per-user), drops the
  refresher cache, and evicts the pool when a server's URL or OAuth config
  (client_id / endpoints / grant_type / scope / auth_type) changes — so the
  status UI and agent never use a token minted for the old resource/AS

- MCPOAuthDialog (WS-driven), unified user-credentials dialog, OAuth settings
  fields; handles the no-redirect client_credentials completion

- internal/mcp/oauth/*_test.go: discovery cache, PKCE, DCR, refresher
- internal/http/mcp_oauth_test.go + mcp_update_oauth_purge_test.go: routes, auth
  gating, WS event, purge-on-URL/OAuth-config-change
- tests/integration: store + encryption + tenant isolation, E2E start→callback,
  DeleteServerOAuthTokens
- internal/gateway/event_filter_test.go, internal/agent/loop_mcp_user_test.go

* fix(mcp): return 400 on OAuth callback with code but missing state

The callback handler rendered a 200 HTML page whenever code or state was
absent. An auth code WITH a missing state is a malformed / CSRF-risk
callback (state is the CSRF token), so reject that case with HTTP 400.
A bare hit with neither code nor state (user opening the URL directly),
provider errors, and exchange failures keep their 200 HTML popup page.

Adds a status code parameter to writeCallbackHTML. Fixes the
TestOAuthCallbackMissingState integration regression while keeping
TestHandleCallbackMissingCodeAndState (no params -> 200) green.

* fix(mcp): scope-based OAuth auth + honor manual OAuth endpoints

Addresses the two MCP/OAuth security-review findings.

Finding 1 — authorization. mcp_oauth_tokens is tenant-scoped, but
start/status/revoke were gated only by requireAuth(RoleAdmin), an RBAC
role check, not tenant membership, so a RoleAdmin caller could act on a
tenant they don't administer. A blanket requireTenantAdmin would have
broken per-user self-service, which the UI exposes (the per-user
MCPUserCredentialsDialog shows an "Authorize" button to regular users for
their own credentials). Instead mirror the existing per-user MCP
credentials model (resolveTargetUserID in mcp_user_credentials.go):
- start/status/revoke accept any authenticated user; each handler calls
  authorizeOAuthScope.
- a caller may manage their OWN per-user token (self-service); the
  global/server token (user_id="") and other users' tokens require
  tenant-admin (owner bypass), so a RoleAdmin that is not a tenant admin
  is rejected.
- discover stays admin-only (it only previews AS metadata for a server).
Add a TenantStore dependency. Tests cover self-service, on-behalf-of-
another (403), and global-by-non-tenant-admin (403).

Finding 2 — honor manual OAuth config end-to-end. The UI sent use_dcr /
auth_endpoint / token_endpoint and the update path fingerprinted them for
purge, but handleStart always discovered + DCR'd and ignored them. Now:
- use_dcr=false (a *bool, so legacy/absent stays discover+DCR) skips
  discovery/registration and uses the operator endpoints, SSRF-validated.
- token_endpoint is always required; auth_endpoint only for auth-code
  grants — client_credentials needs no authorization URL, matching the UI
  which hides that field for that grant.
- the refresher already refreshes against the stored token_endpoint and
  the callback persists it, so manual-mode tokens refresh correctly.
- oauthFingerprint includes use_dcr (nil normalized to true) so toggling
  DCR mode purges stale tokens.
- the web form only serializes manual endpoints when use_dcr is off.

Audited all MCP dialogs (form, global OAuth, per-user credentials, grants,
tools): OAuth dialogs handle completed/auth_url identically and read
config from stored server settings; runtime connect uses the stored token
via the refresher (no re-discovery).

Tests: manual auth-code + client_credentials endpoints, missing/SSRF
endpoints, and the full self/global/on-behalf authorization matrix.
2026-06-21 22:27:44 +07:00
thotamandGoon 21fb1f18ee feat(webhooks): add webhook management UI with delivery history & test (#1211)
* feat(webhooks): add webhook management UI with delivery history & test

Webhooks admin page on the web dashboard for managing inbound HTTP webhooks
(llm + message kinds), backed by new admin endpoints. No schema changes
(reuses migrations 000059-000061).

Backend (internal/http):
- GET  /v1/webhooks/{id}/calls           - paginated delivery history (trimmed
  DTO; status/limit/offset filters; ownership + tenant scoped)
- GET  /v1/webhooks/{id}/calls/{callId}  - full single-call detail (request
  payload, full response, callback URL, idempotency key, timestamps)
- POST /v1/webhooks/{id}/test            - server-side test invocation using the
  admin session (no secret); dispatched by kind via RunTest() on the llm/message
  handlers; message tester nil-guarded (403 on Lite)
Injects WebhookCallStore + SetTesters() in cmd/gateway_http_wiring.go, adds i18n
key webhook.message_test_requires_standard (en/vi/zh), and unit tests for
list/detail (filter, tenant isolation) and test (success/error/edition gate).

Frontend (ui/web):
- /webhooks admin-only page: list with revoked filter + badge, create/edit form
  (edition-gated message kind, Lite localhost_only lock), show-once secret dialog
  (create + rotate), test dialog, and delivery-history dialog with server-side
  pagination plus a click-through full call-detail dialog.
- use-webhooks hooks, query keys, types, routing, sidebar entry (Cable icon to
  distinguish from the existing event "Hooks" page), webhooks i18n namespace
  (en/vi/zh).

* fix(webhooks): validate admin message tests

---------

Co-authored-by: Goon <duy@wearetopgroup.com>
2026-06-21 18:46:37 +07:00
Duy /zuey/ b5d5fce6e4 feat: update MiniMax and Z.AI provider defaults
Refresh MiniMax to MiniMax-M3, update Z.AI defaults to glm-5.2, and add focused provider catalog/runtime coverage.
2026-06-21 16:39:14 +07:00
Duy /zuey/ fc308a3989 Merge pull request #1245 from digitopvn/codex/fix-mcp-stdio-runtime-validation
fix: harden MCP stdio validation
2026-06-20 23:16:12 +07:00
Duy Nguyen 878c33e964 fix: harden MCP stdio validation 2026-06-20 19:29:07 +07:00
Plateau Nguyen 0e994f959c feat(providers): explicit prompt cache for DashScope/Qwen (#1127)
* feat(providers): explicit prompt cache for DashScope/Qwen

Extend OpenAI-compat path with Anthropic-style cache_control:ephemeral
inline blocks for Alibaba DashScope endpoints (provider_type=bailian or
URL contains "dashscope"). Verified live: qwen3.6-plus / qwen3.5-plus /
qwen3-coder-plus all return 99.7% cache hit rate on 6K-token prefix,
yielding ~89% token cost reduction on cached prefix (90% Alibaba discount).

- isDashScope(): 3-source detection (URL + providerType + name) handles
  reverse-proxied endpoints; covers both "dashscope" and "bailian"
- buildRequestBody wraps system content with SplitSystemPromptForCache
  (exported from anthropic_request.go) using <!-- GOCLAW_CACHE_BOUNDARY -->
- Tool prefix cache: cache_control on last tool definition, with 4-marker
  budget guard
- Parse cache_creation_input_tokens from prompt_tokens_details into
  Usage.CacheCreationTokens; propagates via existing span metadata writers
- Runtime escape hatch: GOCLAW_DISABLE_DASHSCOPE_CACHE=true
- Live integration smoke test (build tag integration, env-gated)

* chore(providers): polish per PR #1127 review

- Use strings.Repeat instead of custom repeat() helper in smoke test
- Clarify BuildRequestBodyForTest is test-only, not public API

* feat(providers): enable thinking for Qwen 3.7/3.6, observe cache in smoke test

Qwen3.7-plus and qwen3.6-plus support deep thinking but were missing from
dashscopeThinkingModels, so enable_thinking/thinking_budget was silently
skipped. Add both to the whitelist and test.

Add qwen3.7-plus to the cache smoke test as cache-optional: Alibaba's
context-cache doc does not yet list 3.7/3.6 for explicit cache, and the
cache wrap is a safe no-op when unsupported, so a no-cache result is logged
rather than failed to avoid a flaky live assertion.

Claude-Session: https://claude.ai/code/session_01X3jkrc7N3ar8ZGzUJWyNS5

* fix(providers): preserve DashScope detection for proxy routes
2026-06-19 10:59:02 +07:00
Goon 591d809779 Merge remote-tracking branch 'upstream/dev' into dev
# Conflicts:
#	internal/cron/service.go
2026-06-15 14:14:16 +07:00
Goon 540dc11301 fix(providers): send Kimi headers when listing models 2026-06-15 10:16:47 +07:00
Raihan Afiandi b844af8d25 feat(providers): add kimi_coding provider with fixed User-Agent + temp lock
Moonshot's Kimi Coding endpoint is OpenAI-compatible on the wire but
has two non-standard rules:

  1. Every request must carry `User-Agent: claude-code/0.1.0` — without
     it the upstream rejects the call outright.
  2. `temperature` is locked to the server default; passing any other
     value returns HTTP 400 `invalid temperature: only 1 is allowed for
     this model`.

Rather than special-case either, this commit generalises both:

  - WithExtraHeaders on OpenAIProvider — static headers attached to
    every outgoing request. Reusable by any future provider that needs
    pinned identity headers; mirrored in adapter_openai.ToRequest so
    callers using the adapter path see the same shape.
  - The existing skipTemp branch in openai_request.go gets a
    provider_type check — kimi_coding joins o1/o3/o4/gpt-5-mini in
    omitting `temperature` from the request body.

Provider wiring:
  - store.ProviderKimiCoding constant + ValidProviderTypes entry +
    KimiCoding{DefaultAPIBase,DefaultModel,RequiredUserAgent}.
  - case store.ProviderKimiCoding in both registration switches
    (cmd/gateway_providers.go and internal/http/providers.go).
  - UI dropdown entry with the API base pre-filled.

5 unit tests cover: real outgoing header injection, adapter-path
header mirroring, empty-map WithExtraHeaders no-op, kimi_coding
strips temperature, and the negative control (other providers still
forward temperature).

Admin flow: Providers → Add → "Kimi Coding (Moonshot)" → paste API
key → save.
2026-06-15 10:16:47 +07:00
Duy /zuey/ c32d6b3279 Merge pull request #176 from digitopvn/codex/issue-107-usage-analytics-plan
feat(usage): add event analytics dashboard
2026-06-12 18:28:35 +07:00
Goon 06877365c4 merge: sync dev into usage analytics branch 2026-06-12 17:58:22 +07:00
Duy /zuey/ 4f87b93dbd feat: add qwen3.7-plus to Bailian Coding catalog
Closes #169
2026-06-12 17:57:48 +07:00
Goon 4002bb4c13 feat(usage): add event analytics dashboard 2026-06-12 17:51:12 +07:00
Duy /zuey/ 515596c44c Merge pull request #167 from digitopvn/codex/issue-161-skill-self-evolution
feat(skills): add skill self-evolution metrics
2026-06-12 15:55:18 +07:00
Goon dd6ee64f23 fix(skills): harden evolution mutations 2026-06-12 15:38:23 +07:00
Goon 1e0ea7615d fix(skills): guard suggestion status updates by skill 2026-06-12 15:04:29 +07:00
Goon 248d591f52 fix: resolve dev merge conflicts 2026-06-12 15:01:38 +07:00
Goon 7692939d09 Merge remote-tracking branch 'origin/dev' into codex/issue-161-skill-self-evolution
# Conflicts:
#	cmd/skills_cmd.go
#	docs/project-changelog.md
#	internal/http/skills.go
#	internal/store/sqlitestore/schema.go
2026-06-12 14:57:09 +07:00
Goon f45bfa860c feat(skills): add skill self-evolution metrics 2026-06-12 14:47:51 +07:00
Goon c320de5a87 feat: grant first setup agent gateway operator access 2026-06-12 10:40:59 +07:00
Goon 0bf7f88054 feat(skills): add lifecycle API and CLI 2026-06-12 10:38:53 +07:00
Duy /zuey/ 167b80e778 feat(traces): add search and advanced filters
Closes #152
2026-06-12 00:14:40 +07:00
Goon 94d7bbc28a fix(vault): enforce team access on document updates 2026-06-09 23:04:59 +07:00
Goon 59a1773a18 Merge remote-tracking branch 'origin/dev' into codex/merge-nextlevelbuilder-goclaw 2026-06-09 22:49:30 +07:00
Goon a63080714a Merge remote-tracking branch 'upstream/dev' into codex/merge-nextlevelbuilder-goclaw
# Conflicts:
#	internal/pipeline/think_stage.go
#	internal/pipeline/tool_stage.go
#	tests/integration/git_adapter_ssh_test.go
2026-06-09 22:38:32 +07:00
43837afca3 fix(security): consolidate & enhance batched security fixes (#1155, #967, #972, #974, #989, #973) (#1185)
* fix(sandbox): avoid shell in FsBridge writes

Replace sh -c with interpolated path by shell-free 'tee -- <path>' argv form,
piping content via stdin. Prevents command injection through filenames
containing shell metacharacters inside the sandbox container.

Co-authored-by: evgyur <evgyur@gmail.com>

* fix(security): fail-closed on pairing DB errors across channels

On IsPaired lookup error, deny instead of granting access. Covers the shared
CheckDMPolicy/CheckGroupPolicy helpers (Slack/Discord/Feishu/WhatsApp/Zalo) and
the four inline Telegram pairing checks.

Co-authored-by: Srini <srinis.k@gmail.com>

* fix(security): harden provider URL validation against SSRF

Enforce scheme check for all provider types; restrict local types (ollama,
claude_cli, acp) to an explicit localhost allowlist instead of skipping checks;
resolve remote hostnames and reject any IP in a private/reserved range via the
shared security.IsBlocked CIDR list (covers loopback, link-local, metadata,
multicast, and unspecified 0.0.0.0/::). Closes the wildcard-DNS bypass and the
local-type escape hatch. Operator opt-in via GOCLAW_ALLOW_PRIVATE_PROVIDER_URLS.

Exports security.IsBlocked as the single source of truth for blocked ranges.

Co-authored-by: Linh Vo Van <linh.vo@e-cq.net>

* feat(pipeline): add fail-closed tool call authorization gate

Gate tool execution against the server-side AllowedTools allowlist built from the
RBAC/tenant-aware filtered tool set. Resolve the tool-call prefix before the
allowlist lookup so prefixed agents are not wrongly blocked, re-check deny on lazy
MCP activation, and expand IsDenied to cover aliased tool names.

Co-authored-by: Huy Doan <tui@pm.me>

* fix(security): expand file-serve deny-list defense-in-depth

Add absolute-path deny prefixes (/home, /Users, /srv, /var/lib, /var/www, /opt)
and an explicit fail-closed log when no file-serving boundary is configured.

Co-authored-by: Linh Vo Van <linh.vo@e-cq.net>

* fix(providers): allow claude cli executable paths

Refs: #1185

---------

Co-authored-by: evgyur <evgyur@gmail.com>
Co-authored-by: Srini <srinis.k@gmail.com>
Co-authored-by: Linh Vo Van <linh.vo@e-cq.net>
Co-authored-by: Huy Doan <tui@pm.me>
2026-06-05 00:48:38 +07:00
Duy /zuey/ 53ef912441 fix(channels): add reasoning delivery modes (#135) 2026-06-03 11:01:14 +07:00
Goon 8fb3149142 fix(release): address official ship blockers 2026-05-31 22:05:59 +07:00
Goon 02d7b6f3de fix: stabilize agent git access 2026-05-31 19:42:05 +07:00
Goon 994acfe3a4 feat(cli-credentials): add agent-scoped git credentials 2026-05-31 16:49:23 +07:00
Goon d78f97b9d7 feat(channel-memory): merge dev for passive extraction 2026-05-31 14:01:29 +07:00
Duy /zuey/ 2cd750ac44 feat(channels): add context capability admin surface
Squash merge PR #115 after resolving changelog and SQLite migration-map conflicts with current dev. Renumbered channel-context PostgreSQL migration to 000075 and bumped PG required schema to 75 plus SQLite schema to 44 so it follows the run timeline migration. Local checks passed: go test ./..., go build ./..., go build -tags sqliteonly ./..., go vet ./..., and pnpm -C ui/web build. PR CI run 26705617311 passed release-versioning, go, and web.
2026-05-31 13:50:31 +07:00
Duy /zuey/ 269e2618ae feat: add archived run timeline
Squash merge PR #113 after resolving the project changelog conflict with current dev. Local checks passed: Go store/http/gateway/agent/pipeline tests, SQLite-tagged tests, both Go builds, web Vitest, and web build. PR CI run 26705098712 passed release-versioning, go, and web.
2026-05-31 13:22:07 +07:00
Duy /zuey/ 990fabf94b fix(config): persist shell deny group disables
Squash merge PR #112 after resolving the project changelog conflict with current dev. Local checks passed: config gateway tests, provider/http/tools deny-pattern tests, go build ./..., and go build -tags sqliteonly ./.... PR CI run 26704832350 passed release-versioning, go, and web.
2026-05-31 13:08:24 +07:00
Duy /zuey/ 80893a1e6c feat(skills): add selected skill downloads
Squash merge PR #110 after resolving dev changelog conflict. Local checks covered Go http/store, full web test/build; PR CI run 26704448786 passed release-versioning, go, and web.
2026-05-31 12:44:29 +07:00
Duy /zuey/ df8a03df48 fix: allow skill_manage companion files
Squash merge PR #104 after validation.
2026-05-31 12:24:22 +07:00
Duy /zuey/ 3569f8d681 fix(channels): load WhatsApp QR channel on create
Squash merge PR #98 in priority order after #97. Existing PR checks were green; branch workflow status will be tracked.
2026-05-31 11:33:41 +07:00
Duy /zuey/ c4e0bd2be5 fix(cli-credentials): stabilize git preset creation
Squash merge PR #96 after PR #92. PR checks were green; dev beta CI will be monitored after merge.
2026-05-31 11:05:47 +07:00
Goon 8d45168b35 feat(channel-memory): run passive extraction pipeline 2026-05-29 20:48:19 +07:00
Goon c7aac69ca0 fix(channels): harden context capability admin 2026-05-29 20:04:33 +07:00
Goon bd34e2917e feat(channels): manage context scoped capabilities 2026-05-29 19:29:31 +07:00
Goon f20d776ebb feat(channels): expose context capability matrix 2026-05-29 19:17:18 +07:00
Goon 29f3eb3874 feat(channels): expose read-only channel contexts 2026-05-29 19:11:17 +07:00
Aaron VuandClaude Opus 4.7 9edcc00dbc fix(vault): surface close errors + clarify slog label + drop test dup
Trivial nits from thieung's review of ba59e7b0:

- writeDocumentContent now closes the file explicitly and propagates
  any Close() error so a delayed FS failure (e.g. disk-full at flush)
  doesn't return success.
- Renamed slog `site=lexical` to `site=path_traversal` — the lexical
  prefix check catches "../" / absolute-path escapes, not symlinks.
  Umbrella key `security.vault_symlink_escape` stays.
- Non-master-tenant test now reuses newJSONRequest + WithContext,
  dropping the duplicate mustJSON helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 20:55:23 +07:00
Aaron VuandClaude Opus 4.7 1fad4e0b57 fix(vault): non-master tenant 500, O_NOFOLLOW, slog, *string content
Addresses second review on #1174 (thieung):

**B1 (blocking)** — first JSON content write into a fresh non-master
tenant returned 500 because workspace/tenants/{slug}/ doesn't exist
yet and EvalSymlinks fails with ErrNotExist. writeDocumentContent now
MkdirAlls the workspace before resolving (mirrors handleUpload). Added
a regression test that drives a non-master tenant context end-to-end
and asserts the file lands under workspace/tenants/{slug}/.

**I1** — replaced the Lstat→WriteFile sequence (TOCTOU window) with
os.OpenFile(O_WRONLY|O_CREATE|O_TRUNC|O_NOFOLLOW). The Lstat short-fail
stays as defense-in-depth + cleaner error logging. Portability handled
via build-tagged constants in vault_handler_documents_nofollow_{unix,
windows}.go — O_NOFOLLOW is no-op on Windows (desktop edition is
single-user with no untrusted tenants).

**I3** — every symlink/path-escape rejection now emits
slog.Warn("security.vault_symlink_escape", ...) with the rejection
site, resolved path and workspace, matching the storage.go pattern
so oncall can alert on attempted escapes.

**I4** — POST `content` field changed from `string` to `*string` to
match PUT. Nil pointer = "no write" (metadata-only stub); empty string
= "write a 0-byte file + fire event"; non-empty = write bytes. Updated
handler godoc to spell out the symmetry. Switched `werr == os.ErrInvalid`
to `errors.Is` per repo conventions.

**Tests added** — non-master tenant first content write (B1), PUT with
nil content (locks "no change" contract), handler-level ".." path
rejection, POST content:"" writes empty file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 20:55:23 +07:00
Aaron VuandClaude Opus 4.7 5d5be0a64f fix(vault): harden symlink containment + mirror upload enrich-progress
Addresses review on #1174:

- writeDocumentContent now resolves symlinks before writing. A symlink
  living inside the workspace but pointing outside (e.g. workspace/link
  -> /etc) previously passed the lexical prefix check and os.WriteFile
  followed it out of bounds. We now EvalSymlinks the workspace root and
  the deepest existing ancestor of the target, re-check containment
  before any mkdir/write, and refuse a symlink planted at the final
  component via Lstat.
- publishDocUpserted starts enrichment progress (enrichProgress.Start)
  before publishing EventVaultDocUpserted, matching handleUpload's
  ordering so the worker pool can't drain the event before the progress
  tracker registers it.
- Add regression tests: symlink dir escape, final-component symlink,
  parent escape, empty content, disallowed extension, event-after-upsert
  ordering, metadata-only no-write, and upsert-failure orphan behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 20:55:23 +07:00
Aaron VuandClaude Opus 4.7 fc105d31a1 fix(vault): accept and persist content on POST/PUT vault/documents
Previously POST /v1/{agents/{id}/}vault/documents and the matching PUT
endpoint declared a body struct without a `content` field, so callers
passing JSON `{"path": ..., "title": ..., "content": "..."}` got back
HTTP 201 with a doc id, but the bytes were silently discarded:

  - bindJSON ignored the `content` key (no matching field)
  - the document row was inserted with content_hash="" and no file on disk
  - the enrichment pipeline (summary, embeddings, links) was never
    triggered because no EventVaultDocUpserted was published

The endpoint was effectively a metadata-only create — there was no client
path that combined "register doc + supply bytes" via JSON. /v1/vault/upload
exists, but it requires multipart, flattens paths to basename, and is
inconvenient for programmatic clients that already have UTF-8 text in
memory (CI jobs, agent skills, MCP tools).

This change makes `content` an optional field on both handlers:

  - POST: when content is non-empty, validate the file extension against
    the same allowedUploadExts whitelist used by /v1/vault/upload, write
    bytes to <tenant-workspace>/<path>, compute SHA-256, store it as
    content_hash on the doc, and publish EventVaultDocUpserted so the
    enrichment worker picks it up — exactly the same downstream
    behaviour as /v1/vault/upload.
  - PUT: same logic; nil content = no change, non-nil content = rewrite
    the file on disk (and an empty string truncates it).
  - Omitting `content` preserves the historical metadata-only behaviour
    (e.g. for callers that materialise files via filesystem rescan).
  - Path validation reuses the existing rules: no `..`, no absolute
    paths; an extra abs-path check rejects symlink escapes from the
    workspace root.

Two small helpers (writeDocumentContent, publishDocUpserted) factor out
the disk write + event publish so both handlers share the same code,
and so they can be unit-tested in isolation later.

The fix is backwards-compatible: existing callers that don't send
`content` continue to work unchanged. Existing docs created without
content remain metadata-only stubs; a subsequent PUT with `content`
will materialise them.

Verified live on a development instance:
  POST → row inserted, file at <workspace>/<path>, SHA-256 matches,
         enrichment event published.
  PUT  → file rewritten, content_hash updated, updated_at refreshed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 20:55:23 +07:00
Duy /zuey/ a591473546 feat(secure-cli): CLI credential adapters framework + git adapter (#82) (#89)
* feat(secure-cli): Phase 1 schema + storage delta (issue #82)

Adds `adapter_name` column to secure_cli_binaries and `credential_type` + `host_scope` columns to secure_cli_user_credentials. LookupByBinary LEFT JOIN now projects AdapterName, UserCredentialType, and UserHostScope. Extends SecureCLIStore with SetUserCredentialsTyped(ctx, binaryID, userID, env, credType, hostScope); legacy SetUserCredentials delegates to typed variant with nil/nil for backward compat.

PG migration 73 + RequiredSchemaVersion bumped to 73. SQLite incremental migrations (versions 39–41) + SchemaVersion 42. 7 SQLite + 6 PG integration tests covering schema, round-trip, NULL legacy, LookupByBinary projection.

Fixes #82

* feat(secure-cli): Phase 2 CredentialAdapter framework + passthrough (issue #82)

Adds CredentialAdapter interface + Injection{ArgvPrefix,Env,Cleanup,ScrubValues} struct. Registry resolves by name; falls back to passthrough for empty/unknown. passthroughAdapter is default no-op — all existing presets (gh/aws/gcloud/kubectl/terraform/gws) behave bit-for-bit identically.

Per-request WithScrubBag(ctx) + AddScrubValuesCtx + ScrubCredentialsCtx for multi-tenant secret isolation (replaces package-global slice). Hook in executeCredentialed between env merge and exec: resolve adapter by bin.AdapterName, reject non-passthrough in sandbox, call Prepare, splice ArgvPrefix, merge Env, defer Cleanup, register ScrubValues.

Audit log security.system_env_injection records adapter name + env key names + argv_prefix_len + sha256(host_scope) — NEVER values. CLIPreset.AdapterName field added; empty default for legacy presets.

12 unit tests covering passthrough no-op, registry fallback + nil safety, Injection shape, hashHostScope determinism, sortedKeys, scrub bag per-request isolation + concurrent + short-value guard.

Fixes #82

* feat(secure-cli): Phase 2b extensibility helpers + psql stub adapter (issue #82)

Proves CredentialAdapter framework generalizes beyond git. Adds materializeEphemeral(ctx, content, prefix) shared helper — 0600 tmpfile + idempotent atomic.Bool cleanup latch; memfd intentionally rejected (resolves "self" against child process).

Adds psqlAdapter consuming framework end-to-end via PGPASSFILE pattern; libpq-spec .pgpass escaping for `:` and `\`. Registers psql preset with AdapterName: "psql" (production UI for typed creds lands in v2).

Interface validation gate passes: Injection shape unchanged, hook is psql-agnostic, no special branch needed.

Tests: ephemeral write/cleanup/concurrent/zero-content; psql routing/content/escaping/error paths/registration.

Fixes #82

* feat(secure-cli): git adapter PAT + SSH implementation (issue #82)

Phase 3 — PAT path
- gitAdapter PAT branch via GIT_CONFIG_COUNT/KEY_0/VALUE_0 env (git 2.31+)
  so the token never lands on argv, .git/config, or remote URL
- Host-scope enforcement with IDN normalization (golang.org/x/net/idna)
  and embedded-userinfo rejection in URL parsing
- CVE-2018-17456 mitigation: resolve remote URLs via `git config --get`
  not `git remote get-url` to dodge ext::sh protocol handler injection
- Case-insensitive DenyArgs blocking `-c http.`, `-c credential.`,
  `-c core.sshcommand`, `config --global/--system`, `credential-helper`,
  bare `daemon`
- New WithExecCwd / ExecCwdFromContext context helpers — fixes a latent
  design gap where the adapter's pre-flight `git config --get` ran in
  goclaw's daemon CWD instead of the agent's repo
- 14 unit tests covering subcommand routing, host normalization,
  scp-form parsing, userinfo rejection, CRLF token rejection,
  CVE-2018-17456 regression, DenyArgs preset coverage
- 3 integration tests against a local TLS git-http-backend server
  proving end-to-end clone + fetch + host-mismatch rejection with
  zero token leakage into the cloned .git/config

Phase 4 — SSH path
- gitAdapter ssh_key branch materializes per-call 0600 tmpfile via
  the Phase 2b materializeEphemeral helper, injects GIT_SSH_COMMAND
  with -o IdentitiesOnly=yes -o BatchMode=yes
  -o StrictHostKeyChecking=accept-new, idempotent cleanup
- ValidateSSHKey using golang.org/x/crypto/ssh rejects
  passphrase-protected keys via ErrSSHKeyPassphraseUnsupported sentinel
- 8 unit tests covering passphrase rejection, env shape, cleanup
  lifecycle, host-mismatch reuse, malformed-blob rejection
- 3 integration tests proving tmpfile 0600 lifecycle, env propagation
  to child process, cleanup-on-exec-failure, no-orphan-on-rejection
- 2 new i18n keys (en/vi/zh) for SSH-passphrase and SSH-key-invalid

Verification: go vet clean, go build ./... clean,
go build -tags sqliteonly ./... clean, go test -race
./internal/tools/ pass, go test -tags integration
./tests/integration/ -run TestGitAdapter pass.

Refs #82

* feat(secure-cli): UI presets + typed credential HTTP path + i18n (issue #82)

Phase 5. Typed PUT envelope with {error:{code,message}, error_key} for
field-level errors. CliCredentialGitFields React component (PAT/SSH picker,
host_scope input, CRLF→LF normalization, masked-edit). 17 i18n keys × 3
locales (backend + frontend). i18n parity test.

* feat(secure-cli): audit log schema + adapter framework docs (issue #82)

Phase 6. emitSystemEnvInjectionAudit helper centralizes
security.system_env_injection slog with host_scope_hash (SHA-256 8 hex,
plaintext hostname omitted for PII safety). Audit shape pinned by
TestEmitSystemEnvInjectionAudit_*. New docs: git-credential-adapter.md
(user guide), credential-adapter-playbook.md (R1 implementer guide w/
kubectl/docker/npm/aws/psql worked mappings). 09-security.md § 14
trust-boundary diagram + SSH TOFU + SIGKILL caveats. 03-tools-system.md
§ 8a. Changelog entry.

* docs(journal): issue #82 CLI credential adapters shipped

Retrospective covering 6 commits across phases 1-6: framework, git PAT/SSH, psql stub, UI/i18n, audit log schema, docs. Notes memfd-drop rationale, TOFU/SIGKILL caveats, sentinel-length lesson from audit-shape tests.

* fix(secure-cli): honor per-request scrub bag on success+failure paths (#82)

Sandbox and host exec paths called the non-Ctx ScrubCredentials, so
adapter ScrubValues registered into the per-request bag during Prepare
(e.g. GitLab glpat-, Bitbucket app-passwords, Azure DevOps PATs, Gitea
tokens, SSH key tmpfile paths) were ignored on stdout/stderr returned
to the agent. Only the package-global regex pass ran — covering ghp_
but nothing else.

Switch all four exec/sandbox call sites to ScrubCredentialsCtx so the
bag is consulted. Also scrub the slog adapter_cleanup_failed line —
os.Remove errors embed the full tmpfile path.

Add 5 regression tests that pin success path, failure path, negative
control (proves the bag is what catches the sentinel), classic-PAT
sanity, and timeout path no-leak.

Locks AC6 against non-GitHub PAT providers.

* fix(secure-cli): address review-pr #89 findings

- ui/web: SSH key Textarea was 'text-xs' on all viewports, triggering
  iOS Safari auto-zoom on focus. Switch to 'text-base md:text-xs' so
  mobile renders 16px (no zoom) while desktop keeps compact mono font.
- psql adapter: add on-disk pgpass tmpfile path to ScrubValues. psql
  echoes 'could not open password file "<path>"' on IO errors, so
  the path needs scrubbing alongside the password. Mirrors the git SSH
  adapter pattern. Update test assertion accordingly.
- psql adapter: replace literal 'nil' context with 'context.TODO()'
  to silence staticcheck SA1012.
2026-05-28 18:17:49 +07:00
Goon 87b9e296b1 Merge remote-tracking branch 'origin/dev' into codex/configurable-exec-timeout
# Conflicts:
#	docs/project-changelog.md
2026-05-24 20:19:40 +07:00
Goon 3a749ff91c Merge remote-tracking branch 'origin/dev' into codex/issue-48-google-workspace-cli
# Conflicts:
#	docs/project-changelog.md
2026-05-24 18:57:47 +07:00