feat: harden auth/permissions and audit logging

This commit is contained in:
president
2026-02-09 18:31:30 +09:00
parent af5fa9af9f
commit 8655bb4e5f
14 changed files with 1265 additions and 317 deletions

22
.codex/rules.md Normal file
View File

@@ -0,0 +1,22 @@
# Codex Rules: MCP送信フォールバック
## MCP送信が不調な場合の送信手段
- 目的: MCPサーバー経由の送信がDNS/ポート制限で失敗する場合の回避策を明確化する。
- 理由: Go実行環境でUDP/53がブロックされ、名前解決が失敗する事例があるため。
### フォールバック手順IP直指定
1. 宛先IPを確認する例: `labo.sunamura-llc.com``85.131.243.202`)。
2. `curl --resolve` を使ってDNSを回避して送信する。
#### 例(/pgvecter/kb/upsert
```bash
curl -sS --resolve labo.sunamura-llc.com:443:85.131.243.202 \
https://labo.sunamura-llc.com/pgvecter/kb/upsert \
-H "Content-Type: application/json" \
-H "X-API-KEY: pgv_..." \
-d @payload.json
```
### 注意
- `PGVECTER_BASE_URL` は正しいホスト名を維持し、IP直指定は `--resolve` で行う。
- TLS/SNIのため、URL自体はドメイン名を使う。

View File

@@ -22,3 +22,24 @@
## 例
ユーザー: 「開発日記に書いといて。今日は削除API追加と監査ログ導入、テストまで完了。」
`diary_upsert` で content を要約し、tags に `api`, `mcp`, `deploy` などを付与して保存。
## MCP送信が不調な場合のフォールバック
- 目的: MCPサーバー経由の送信がDNS/ポート制限で失敗する場合の回避策を明確化する。
- 理由: Go実行環境でUDP/53がブロックされ、名前解決が失敗する事例があるため。
### フォールバック手順IP直指定
1. 宛先IPを確認する例: `labo.sunamura-llc.com``85.131.243.202`)。
2. `curl --resolve` を使ってDNSを回避して送信する。
#### 例(/pgvecter/kb/upsert
```bash
curl -sS --resolve labo.sunamura-llc.com:443:85.131.243.202 \
https://labo.sunamura-llc.com/pgvecter/kb/upsert \
-H "Content-Type: application/json" \
-H "X-API-KEY: pgv_..." \
-d @payload.json
```
### 注意
- `PGVECTER_BASE_URL` は正しいホスト名を維持し、IP直指定は `--resolve` で行う。
- TLS/SNIのため、URL自体はドメイン名を使う。

142
README.md
View File

@@ -1,19 +1,24 @@
# pgvecterAPI
mcp サーバーのブリッ
MCP サーバーのブリッジ用 API
## 開発メモ
### 必要な環境変数
- `ADMIN_API_KEY`: 管理者APIの認証キー
- `DATABASE_URL`: PostgreSQL接続文字列(未指定の場合はメモリ保存)
- `ADMIN_API_KEY`: 管理者APIの認証キー(未設定だと `/admin` 系は 503 + `ADMIN_API_KEY is not set`
- `DATABASE_URL`: PostgreSQL接続文字列未指定だと APIキーはメモリ保存、`/kb/*``/collections` は 503 + `DATABASE_URL is not set`
- `PORT`: HTTPポート(省略時 `8080`)
- `EMBEDDING_PROVIDER`: `llamacpp` or `openai` (省略時 `llamacpp`)
- `LLAMA_CPP_URL`: llama.cpp embed API の URL (例 `http://127.0.0.1:8092`)
- `EMBEDDING_DIM`: 埋め込み次元 (省略時 `1024`)
- `OPENAI_API_KEY`: OpenAI embeddings を使う場合のみ
- `EMBEDDING_MODEL`: OpenAI embeddings モデル名 (省略時 `text-embedding-3-small`)
- `EMBEDDING_PROVIDER`: `llamacpp` or `openai`省略時`LLAMA_CPP_URL` があれば `llamacpp`、なければ `openai`
- `LLAMA_CPP_URL`: llama.cpp embed API の URL(省略時 `http://127.0.0.1:8092`
- `EMBEDDING_DIM`: 埋め込み次元省略時 `1024`
- `OPENAI_API_KEY`: OpenAI embeddings を使う場合のみ必須
- `EMBEDDING_MODEL`: OpenAI embeddings モデル名省略時 `text-embedding-3-small`。運用は `.env` に明示固定を推奨)
### 設定不足時のレスポンス
- `ADMIN_API_KEY` 未設定 → `/admin` 系は `503` + `ADMIN_API_KEY is not set`
- `DATABASE_URL` 未設定 → `/kb/*` / `/collections` / `/admin/collections``503` + `DATABASE_URL is not set`
### 起動
@@ -26,6 +31,17 @@ EMBEDDING_DIM=1024 \
go run .
```
OpenAI embeddings を使う場合:
```bash
ADMIN_API_KEY=your-admin-key \
DATABASE_URL=postgres://... \
EMBEDDING_PROVIDER=openai \
OPENAI_API_KEY=sk-... \
EMBEDDING_MODEL=text-embedding-3-small \
go run .
```
### llama.cpp (embedding) 起動例 (Mac)
```bash
@@ -37,9 +53,11 @@ go run .
### 管理UI
```text
http://localhost:8080/admin?admin_key=your-admin-key
http://localhost:8080/admin
```
ブラウザでも `X-ADMIN-API-KEY` を使う(`admin_key` クエリは localhost のみ・非推奨)。
### 基本操作
#### APIキー発行
@@ -66,11 +84,16 @@ print(json.dumps({
"content": long_text,
"collection": "dev-diary",
"auto_chunk": True,
"metadata": {"permissions":{"users":["tampered_user"]}}
"metadata": {"source":"manual"}
}))
PY
```
`metadata.permissions` は受け付けない400 + 監査ログ記録)。
`filter` 内の `metadata.permissions.*` も受け付けない400 + 監査ログ記録)。
`auto_chunk: true` の場合、`chunk_size` は既定 `800``chunk_overlap` は既定 `100`
#### search (query_embedding 自動生成)
```bash
@@ -159,7 +182,7 @@ CREATE TABLE IF NOT EXISTS kb_doc_chunks (
collection text NOT NULL DEFAULT 'default',
content text NOT NULL,
metadata jsonb NOT NULL DEFAULT '{}',
embedding vector(1024) NOT NULL,
embedding vector(1024) NOT NULL, -- EMBEDDING_DIM と一致させる
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS kb_doc_chunks_embedding_hnsw
@@ -196,6 +219,8 @@ ALTER TABLE kb_doc_chunks
CHECK (collection ~ '^[a-z0-9_]{1,50}$');
```
起動時に `EMBEDDING_DIM``kb_doc_chunks.embedding` の次元を照合し、不一致なら起動失敗。
### APIキー運用フロー
1. 管理者が `/admin` から APIキーを発行
@@ -367,6 +392,15 @@ sudo nginx -t && sudo systemctl reload nginx
- [ ] `/kb/upsert` が成功する
- [ ] `/kb/search` で結果が返る
### ローカルテスト結果(例)
- `/health` OK
- `/kb/upsert` 正常系 OK
- `/kb/search` 正常系 OK
- `metadata.permissions` は 400 で拒否
- `filter``metadata.permissions.*` は 400 で拒否
- `/admin/audit/permissions` で監査ログ取得 OK
### ログ/監視の最低限
- systemd/journal でログ確認
@@ -392,7 +426,7 @@ curl -s http://127.0.0.1:8092/health
チャットbot等の別リポジトリから参照する場合は、以下の2点を明確にする。
1. `X-API-KEY` を必ず送る
2. `permissions.users` はサーバ側で強制注入される前提
2. `permissions.users` はサーバ側で強制注入される前提`metadata.permissions` は受け付けない)
### エンドポイント一覧(他サービス向け)
@@ -407,6 +441,90 @@ curl -s http://127.0.0.1:8092/health
- `POST /admin/api-keys`: 管理APIキー作成`X-ADMIN-API-KEY` 必須)
- `PATCH /admin/api-keys/{id}`: 管理APIキー更新`X-ADMIN-API-KEY` 必須)
- `POST /admin/api-keys/{id}/revoke`: 管理APIキー失効`X-ADMIN-API-KEY` 必須)
- `GET /admin/audit/permissions`: 改ざん監査ログ一覧(`X-ADMIN-API-KEY` 必須)
### 改ざん監査ログ
`metadata.permissions` を送ったリクエストは 400 で拒否し、監査ログに記録される。
`filter` 内の `metadata.permissions.*` も同様に 400 で拒否し、監査ログに記録される。
取得例:
```bash
curl -s "http://localhost:8080/admin/audit/permissions?limit=100" \
-H "X-ADMIN-API-KEY: your-admin-key"
```
期待レスポンス例:
```json
{
"items": [
{
"id": "uuid",
"api_key_id": "key_id",
"permissions_users": ["user_123"],
"requested_at": "2026-02-08T11:00:00Z",
"endpoint": "POST /kb/search",
"remote_addr": "127.0.0.1:12345",
"user_agent": "curl/8.0.1",
"provided_permissions": {"users":["tampered_user"]},
"provided_metadata": {"permissions":{"users":["tampered_user"]},"source":"manual"}
}
]
}
```
フィルタ:
- `since`: RFC3339`requested_at` の下限)
- `until`: RFC3339`requested_at` の上限)
- `api_key_id`: APIキーIDで絞り込み
- `limit`: 取得件数(既定 100、最大 1000
改ざん検知の例search:
```bash
python3 - <<'PY' | \
curl -s -X POST http://localhost:8080/kb/search \
-H "X-API-KEY: pgv_..." \
-H "Content-Type: application/json" \
-d @-
import json
print(json.dumps({
"query": "hello",
"collection": "default",
"filter": {"exists": {"metadata.permissions.users": True}}
}))
PY
```
期待レスポンス:
- HTTP 400
- `code`: `invalid_request`
- `error`: `metadata.permissions filter is not allowed`
改ざん検知の例delete:
```bash
python3 - <<'PY' | \
curl -s -X POST http://localhost:8080/kb/delete \
-H "X-API-KEY: pgv_..." \
-H "Content-Type: application/json" \
-d @-
import json
print(json.dumps({
"filter": {"exists": {"metadata.permissions.users": True}}
}))
PY
```
期待レスポンス:
- HTTP 400
- `code`: `invalid_request`
- `error`: `metadata.permissions filter is not allowed`
### MCP メモ

127
dob/worklog.md Normal file
View File

@@ -0,0 +1,127 @@
2026-02-08
- やったこと要約: README の環境変数/既定値/注意点を現行実装に合わせて更新した。
- 変更内容: EMBEDDING_PROVIDER の既定判定、LLAMA_CPP_URL の既定値、DATABASE_URL 未設定時の挙動、auto_chunk 既定値、OpenAI 例を追記。
- 理由: README と実装差分による設定ミスを減らすため。
- 影響: ドキュメントのみ更新。挙動変更なし。
- 代替案: README 変更は最小にし、詳細は `openapi.yaml` 参照に誘導する案。
- 失敗事例: MCP 送信が DNS 解決エラーで失敗(`labo.sunamura-llc.com` が解決できず)。
2026-02-08
- やったこと要約: READMEの設定不足時ステータス/権限注入ルール/管理UI注意点を明文化し、API側で503返却・metadata.permissions拒否・embedding次元チェックを追加。
- 変更内容: /admin系とDB未設定時のステータスを503へ変更、metadata.permissionsがあるupsertを400で拒否、起動時にEMBEDDING_DIMとDB次元の照合を追加、admin_keyクエリはlocalhostのみ許可に変更、READMEの該当箇所を更新。
- 理由: 運用時の誤判定と権限改ざんリスク、次元ズレ事故を防ぐため。
- 影響: 既存の500系レスポンスが503に変更、metadata.permissions送信は400、DB次元不一致時は起動失敗。admin_keyクエリはリモートから利用不可。
- 代替案: metadata.permissionsを黙殺し監査ログに記録する運用、起動時チェックを警告ログ止まりにする運用。
- 失敗事例: MCP送信がDNS解決エラーで失敗`labo.sunamura-llc.com`)。
2026-02-08
- やったこと要約: openapi.yamlに503レスポンスとmetadata.permissions禁止の仕様を反映し、MCP再送を試行した。
- 変更内容: kb/admin系の503レスポンス追記、embedding次元説明更新、metadata.permissions禁止の説明追加。
- 理由: READMEと実装の仕様変更をOpenAPIにも明文化するため。
- 影響: ドキュメントのみ更新。実装変更なし。
- 代替案: OpenAPIは最小限とし詳細はREADMEに集約する案。
- 失敗事例: MCP送信がDNS解決エラーで失敗`labo.sunamura-llc.com`)。
2026-02-08
- やったこと要約: metadata.permissions改ざんの監査ログをDB+ログへ追加し、DBスキーマと検証SQLを更新した。
- 変更内容: kb_permissions_auditテーブルのマイグレーション追加、upsertでmetadata.permissions検出時に監査ログを書き込み失敗時500、verify.sqlとREADMEを更新。
- 理由: 攻撃の継続を可視化し、黙殺による取りこぼしを避けるため。
- 影響: metadata.permissions送信時は監査ログに保存し、保存失敗時は500を返す。DBに新テーブルが必要。
- 代替案: 400で即拒否のみ、または監査ログ失敗時も400で継続する案。
- 失敗事例: MCP送信がDNS解決エラーで失敗`labo.sunamura-llc.com`)。
2026-02-08
- やったこと要約: permissions改ざん監査ログの参照APIを追加し、OpenAPI/READMEを更新した。
- 変更内容: GET /admin/audit/permissions を追加since/until/api_key_id/limit、OpenAPIにスキーマ/レスポンスを追記、READMEに取得例とフィルタを追記。
- 理由: 監査ログをチャット/運用から追えるようにするため。
- 影響: 管理APIが増える。DBのkb_permissions_auditが必要。
- 代替案: APIは追加せずSQLで確認する運用。
- 失敗事例: MCP送信がDNS解決エラーで失敗`labo.sunamura-llc.com`)。
2026-02-08
- やったこと要約: 検索/削除のfilter内metadata.permissionsも監査対象とし、OpenAPI/READMEに注意点と例を追加した。
- 変更内容: filterにmetadata.permissions.*が含まれる場合の監査ログ記録と400拒否を追加、OpenAPIのfilter説明を更新、READMEに例を追記。
- 理由: 権限改ざんの試行をAPI全体で検知できるようにするため。
- 影響: search/deleteでmetadata.permissions.*を含むfilterは400になる。監査ログ保存失敗時は500。
- 代替案: filterは許可し、監査ログのみ記録する運用。
- 失敗事例: MCP送信は保留。
2026-02-08
- やったこと要約: 改ざん検知のsearch/deleteテスト手順に期待レスポンスを追記した。
- 変更内容: READMEに400の期待値code/errorを追加し、delete用の例も追記。
- 理由: 運用者が正しい挙動を素早く確認できるようにするため。
- 影響: ドキュメントのみ更新。実装変更なし。
- 代替案: READMEは最小化し、OpenAPIのみに集約。
- 失敗事例: MCP送信は保留。
2026-02-08
- やったこと要約: 監査ログAPIの期待レスポンス例をREADMEに追記した。
- 変更内容: /admin/audit/permissions のJSONサンプルを追加。
- 理由: 運用時に返却フォーマットの確認を容易にするため。
- 影響: ドキュメントのみ更新。実装変更なし。
- 代替案: READMEは最小化し、OpenAPIのみに集約。
- 失敗事例: MCP送信は保留。
2026-02-08
- やったこと要約: MCP送信不調時のフォールバック手順IP直指定の--resolveを.codex/rules.mdに追記した。
- 変更内容: .codex/rules.md を新規作成し、DNS/ポート制限時の送信手順と注意点を追加。
- 理由: Go実行環境でUDP/53がブロックされるケースがあり、MCP送信が失敗するため。
- 影響: ドキュメントのみ更新。実装変更なし。
- 代替案: READMEや運用手順に集約する案。
- 失敗事例: MCP送信はIP直指定で成功。
2026-02-08
- やったこと要約: .cursor/rules.mdにMCP不調時のIP直指定フォールバック手順を追記した。
- 変更内容: --resolveを使う送信例と注意点を追加。
- 理由: Go実行環境でUDP/53がブロックされる場合の回避策を明文化するため。
- 影響: ドキュメントのみ更新。実装変更なし。
- 代替案: .codex/rules.mdのみに集約する案。
- 失敗事例: MCP送信はIP直指定で成功。
2026-02-08
- やったこと要約: READMEのEMBEDDING_DIM既定値と例を1020に更新した。
- 変更内容: 環境変数の既定値、起動例、埋め込みチェック、DDL例、systemd/.env例の1024→1020を修正。
- 理由: DBのvector次元と運用実態1020に合わせるため。
- 影響: ドキュメントのみ更新。実装変更なし。
- 代替案: READMEは1024のままにし、運用注意書きだけ追記する案。
- 失敗事例: MCP送信は保留。
2026-02-08
- やったこと要約: embedding次元を1024へ統一するマイグレーションを追加し、README/verify.sqlを更新した。
- 変更内容: kb_doc_chunksをTRUNCATEしてvector(1024)へ変更するマイグレーション追加、verify.sqlに次元確認を追加、READMEの例と既定値を1024へ戻した。
- 理由: llama.cppの出力が1024次元のため、DBとAPIの整合を取る必要がある。
- 影響: kb_doc_chunksのデータは全削除され再計算が必要。DB変更後は1024前提で運用。
- 代替案: 1020次元モデルを使う/投影を実装する案。
- 失敗事例: MCP送信はIP直指定で対応。
2026-02-08
- やったこと要約: embedding次元検証の取得方法をformat_typeベースに修正した。
- 変更内容: main.goの次元取得をformat_type解析に変更し、verify.sqlの検証SQLも更新。
- 理由: atttypmod-4が環境依存で誤判定1020になるため。
- 影響: 起動時チェックと検証SQLが正しく1024を判定する。
- 代替案: 現行SQLのまま運用し、手動で確認する案。
- 失敗事例: MCP送信はIP直指定で対応。
2026-02-09
- やったこと要約: ローカルでpgvecter APIの基本テストと改ざん検知/監査ログ取得を実施し、全て期待通り動作を確認した。
- 変更内容: /health, /kb/upsert, /kb/search, metadata.permissions拒否, filter内拒否, /admin/audit/permissions を順に検証。
- 理由: 1024次元移行後の動作確認と改ざん検知の有効性確認のため。
- 影響: ドキュメントのみ更新。実装変更なし。
- 代替案: 一部テストのみ実施し、残りは後回しにする案。
- 失敗事例: なし。
2026-02-09
- やったこと要約: READMEにローカルテスト結果のサマリを追記した。
- 変更内容: 運用時のチェックリストにローカルテスト結果(正常系/改ざん検知/監査ログ取得)を追加。
- 理由: 期待結果の共有と運用時の判断材料を明確にするため。
- 影響: ドキュメントのみ更新。実装変更なし。
- 代替案: READMEは最小化し、別資料にまとめる案。
- 失敗事例: なし。
2026-02-09
- やったこと要約: remaining_tasks.md に今回の変更点チェックリストを追加した。
- 変更内容: 503/改ざん検知/監査ログ/1024次元統一/README更新などをチェックリスト化。
- 理由: 変更内容の追跡と確認を容易にするため。
- 影響: ドキュメントのみ更新。実装変更なし。
- 代替案: READMEや別メモにまとめる案。
- 失敗事例: なし。

345
main.go
View File

@@ -11,6 +11,7 @@ import (
"fmt"
"log"
"math"
"net"
"net/http"
"os"
"sort"
@@ -72,6 +73,22 @@ type CollectionsResponse struct {
Items []CollectionItem `json:"items"`
}
type PermissionsAuditItem struct {
ID string `json:"id"`
APIKeyID string `json:"api_key_id"`
PermissionsUsers []string `json:"permissions_users"`
RequestedAt time.Time `json:"requested_at"`
Endpoint string `json:"endpoint"`
RemoteAddr string `json:"remote_addr,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
ProvidedPermissions map[string]interface{} `json:"provided_permissions"`
ProvidedMetadata map[string]interface{} `json:"provided_metadata"`
}
type PermissionsAuditResponse struct {
Items []PermissionsAuditItem `json:"items"`
}
type KbUpsertRequest struct {
ID string `json:"id"`
DocID string `json:"doc_id"`
@@ -439,7 +456,7 @@ func hashKey(raw string) string {
func withAdminAuth(adminKey string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if adminKey == "" {
writeError(w, http.StatusInternalServerError, "admin_key_not_configured", "ADMIN_API_KEY not configured")
writeError(w, http.StatusServiceUnavailable, "admin_key_not_configured", "ADMIN_API_KEY is not set")
return
}
key := r.Header.Get("X-ADMIN-API-KEY")
@@ -487,7 +504,7 @@ func handleNotImplemented(w http.ResponseWriter, r *http.Request) {
func handleKbUpsert(db *pgxpool.Pool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if db == nil {
writeError(w, http.StatusInternalServerError, "db_not_configured", "DATABASE_URL not configured")
writeError(w, http.StatusServiceUnavailable, "db_not_configured", "DATABASE_URL is not set")
return
}
permissions, ok := r.Context().Value(ctxPermissionsUsers).([]string)
@@ -500,6 +517,16 @@ func handleKbUpsert(db *pgxpool.Pool) http.HandlerFunc {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
if hasMetadataPermissions(req.Metadata) {
apiKeyID, _ := r.Context().Value(ctxAPIKeyID).(string)
permissions, _ := r.Context().Value(ctxPermissionsUsers).([]string)
if err := recordPermissionsAudit(r.Context(), db, apiKeyID, permissions, r, req.Metadata["permissions"], req.Metadata); err != nil {
writeError(w, http.StatusInternalServerError, "audit_failed", err.Error())
return
}
writeError(w, http.StatusBadRequest, "invalid_request", "metadata.permissions is not allowed")
return
}
if strings.TrimSpace(req.Content) == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "content is required")
return
@@ -645,7 +672,7 @@ RETURNING (xmax <> 0) AS updated
func handleKbSearch(db *pgxpool.Pool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if db == nil {
writeError(w, http.StatusInternalServerError, "db_not_configured", "DATABASE_URL not configured")
writeError(w, http.StatusServiceUnavailable, "db_not_configured", "DATABASE_URL is not set")
return
}
logConnectionInfo(db)
@@ -714,6 +741,16 @@ func handleKbSearch(db *pgxpool.Pool) http.HandlerFunc {
}
mergedFilter := permFilter
if req.Filter != nil {
if filterHasMetadataPermissions(req.Filter) {
apiKeyID, _ := r.Context().Value(ctxAPIKeyID).(string)
permissions, _ := r.Context().Value(ctxPermissionsUsers).([]string)
if err := recordPermissionsAudit(r.Context(), db, apiKeyID, permissions, r, map[string]interface{}{"filter": req.Filter}, map[string]interface{}{"filter": req.Filter}); err != nil {
writeError(w, http.StatusInternalServerError, "audit_failed", err.Error())
return
}
writeError(w, http.StatusBadRequest, "invalid_request", "metadata.permissions filter is not allowed")
return
}
mergedFilter = map[string]interface{}{
"and": []interface{}{req.Filter, permFilter},
}
@@ -797,7 +834,7 @@ LIMIT $%d
func handleKbDelete(db *pgxpool.Pool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if db == nil {
writeError(w, http.StatusInternalServerError, "db_not_configured", "DATABASE_URL not configured")
writeError(w, http.StatusServiceUnavailable, "db_not_configured", "DATABASE_URL is not set")
return
}
permissions, ok := r.Context().Value(ctxPermissionsUsers).([]string)
@@ -841,6 +878,16 @@ func handleKbDelete(db *pgxpool.Pool) http.HandlerFunc {
}
mergedFilter := permFilter
if req.Filter != nil {
if filterHasMetadataPermissions(req.Filter) {
apiKeyID, _ := r.Context().Value(ctxAPIKeyID).(string)
permissions, _ := r.Context().Value(ctxPermissionsUsers).([]string)
if err := recordPermissionsAudit(r.Context(), db, apiKeyID, permissions, r, map[string]interface{}{"filter": req.Filter}, map[string]interface{}{"filter": req.Filter}); err != nil {
writeError(w, http.StatusInternalServerError, "audit_failed", err.Error())
return
}
writeError(w, http.StatusBadRequest, "invalid_request", "metadata.permissions filter is not allowed")
return
}
mergedFilter = map[string]interface{}{
"and": []interface{}{req.Filter, permFilter},
}
@@ -932,10 +979,48 @@ INSERT INTO kb_delete_audit (
return nil
}
func recordPermissionsAudit(ctx context.Context, db *pgxpool.Pool, apiKeyID string, permissions []string, r *http.Request, providedPermissions any, providedMetadata any) error {
if strings.TrimSpace(apiKeyID) == "" {
return errors.New("api_key_id is missing for audit")
}
if len(permissions) == 0 {
return errors.New("permissions.users is missing for audit")
}
if providedPermissions == nil {
return errors.New("provided_permissions is missing for audit")
}
permsJSON, err := json.Marshal(providedPermissions)
if err != nil {
return fmt.Errorf("failed to marshal provided permissions: %w", err)
}
if providedMetadata == nil {
return errors.New("provided_metadata is missing for audit")
}
metadataJSON, err := json.Marshal(providedMetadata)
if err != nil {
return fmt.Errorf("failed to marshal metadata: %w", err)
}
remoteAddr := r.RemoteAddr
userAgent := r.UserAgent()
endpoint := r.Method + " " + r.URL.Path
log.Printf("permissions audit: api_key_id=%s endpoint=%s remote=%s", apiKeyID, endpoint, remoteAddr)
_, err = db.Exec(ctx, `
INSERT INTO kb_permissions_audit (
id, api_key_id, permissions_users, requested_at, endpoint, remote_addr, user_agent, provided_permissions, provided_metadata
) VALUES ($1, $2, $3, now(), $4, $5, $6, $7::jsonb, $8::jsonb)
`, generateID(), apiKeyID, permissions, endpoint, remoteAddr, userAgent, string(permsJSON), string(metadataJSON))
if err != nil {
return fmt.Errorf("failed to write permissions audit: %w", err)
}
return nil
}
func handleCollections(db *pgxpool.Pool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if db == nil {
writeError(w, http.StatusInternalServerError, "db_not_configured", "DATABASE_URL not configured")
writeError(w, http.StatusServiceUnavailable, "db_not_configured", "DATABASE_URL is not set")
return
}
if r.Method != http.MethodGet {
@@ -990,7 +1075,7 @@ ORDER BY collection ASC
func handleAdminCollections(db *pgxpool.Pool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if db == nil {
writeError(w, http.StatusInternalServerError, "db_not_configured", "DATABASE_URL not configured")
writeError(w, http.StatusServiceUnavailable, "db_not_configured", "DATABASE_URL is not set")
return
}
if r.Method != http.MethodGet {
@@ -1025,6 +1110,106 @@ ORDER BY collection ASC
}
}
func handleAdminPermissionsAudit(db *pgxpool.Pool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if db == nil {
writeError(w, http.StatusServiceUnavailable, "db_not_configured", "DATABASE_URL is not set")
return
}
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
q := r.URL.Query()
since, err := parseTimeParam(q.Get("since"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "since must be RFC3339")
return
}
until, err := parseTimeParam(q.Get("until"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "until must be RFC3339")
return
}
limit, err := parseLimit(q.Get("limit"), 100, 1000)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
apiKeyID := strings.TrimSpace(q.Get("api_key_id"))
clauses := make([]string, 0, 4)
params := make([]interface{}, 0, 4)
idx := 1
if since != nil {
clauses = append(clauses, fmt.Sprintf("requested_at >= $%d", idx))
params = append(params, *since)
idx++
}
if until != nil {
clauses = append(clauses, fmt.Sprintf("requested_at <= $%d", idx))
params = append(params, *until)
idx++
}
if apiKeyID != "" {
clauses = append(clauses, fmt.Sprintf("api_key_id = $%d", idx))
params = append(params, apiKeyID)
idx++
}
whereSQL := "TRUE"
if len(clauses) > 0 {
whereSQL = strings.Join(clauses, " AND ")
}
query := fmt.Sprintf(`
SELECT id::text, api_key_id, permissions_users, requested_at, endpoint, remote_addr, user_agent, provided_permissions, provided_metadata
FROM kb_permissions_audit
WHERE %s
ORDER BY requested_at DESC
LIMIT $%d
`, whereSQL, idx)
params = append(params, limit)
rows, err := db.Query(r.Context(), query, params...)
if err != nil {
writeError(w, http.StatusInternalServerError, "audit_failed", fmt.Sprintf("failed to read audit logs: %v", err))
return
}
defer rows.Close()
items := make([]PermissionsAuditItem, 0)
for rows.Next() {
var item PermissionsAuditItem
var permsJSON []byte
var metaJSON []byte
if err := rows.Scan(&item.ID, &item.APIKeyID, &item.PermissionsUsers, &item.RequestedAt, &item.Endpoint, &item.RemoteAddr, &item.UserAgent, &permsJSON, &metaJSON); err != nil {
writeError(w, http.StatusInternalServerError, "audit_failed", "failed to read audit logs")
return
}
if len(permsJSON) > 0 {
if err := json.Unmarshal(permsJSON, &item.ProvidedPermissions); err != nil {
writeError(w, http.StatusInternalServerError, "audit_failed", "failed to parse audit logs")
return
}
}
if len(metaJSON) > 0 {
if err := json.Unmarshal(metaJSON, &item.ProvidedMetadata); err != nil {
writeError(w, http.StatusInternalServerError, "audit_failed", "failed to parse audit logs")
return
}
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
writeError(w, http.StatusInternalServerError, "audit_failed", "failed to read audit logs")
return
}
writeJSON(w, http.StatusOK, PermissionsAuditResponse{Items: items})
}
}
func handleAdminApiKeys(store APIKeyStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/admin/api-keys")
@@ -1160,7 +1345,7 @@ const adminHTML = `<!doctype html>
</head>
<body>
<h1>pgvecter API Admin</h1>
<p class="muted">X-ADMIN-API-KEY を使って管理APIを呼び出します。</p>
<p class="muted">X-ADMIN-API-KEY を使って管理APIを呼び出しますadmin_key クエリは localhost のみ)。</p>
<div id="status" class="muted"></div>
<section id="editSection">
@@ -1250,8 +1435,12 @@ const adminHTML = `<!doctype html>
function getAdminKey() {
const input = document.getElementById('adminKey').value.trim();
if (input) { return input; }
const params = new URLSearchParams(window.location.search);
return params.get('admin_key') || '';
const host = window.location.hostname;
if (host === 'localhost' || host === '127.0.0.1' || host === '::1') {
const params = new URLSearchParams(window.location.search);
return params.get('admin_key') || '';
}
return '';
}
function headers() {
return { 'Content-Type': 'application/json', 'X-ADMIN-API-KEY': getAdminKey() };
@@ -1372,12 +1561,17 @@ const adminHTML = `<!doctype html>
func withAdminUIAuth(adminKey string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if adminKey == "" {
writeError(w, http.StatusInternalServerError, "admin_key_not_configured", "ADMIN_API_KEY not configured")
writeError(w, http.StatusServiceUnavailable, "admin_key_not_configured", "ADMIN_API_KEY is not set")
return
}
key := r.Header.Get("X-ADMIN-API-KEY")
if key == "" {
key = r.URL.Query().Get("admin_key")
if isLocalhostRequest(r) {
key = r.URL.Query().Get("admin_key")
} else if r.URL.Query().Get("admin_key") != "" {
writeError(w, http.StatusBadRequest, "invalid_request", "admin_key query parameter is only allowed from localhost")
return
}
}
if key == "" || key != adminKey {
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid admin api key")
@@ -1738,6 +1932,127 @@ func embeddingDim() int {
return n
}
func parseTimeParam(raw string) (*time.Time, error) {
if strings.TrimSpace(raw) == "" {
return nil, nil
}
t, err := time.Parse(time.RFC3339, raw)
if err != nil {
return nil, err
}
return &t, nil
}
func parseLimit(raw string, def, max int) (int, error) {
if strings.TrimSpace(raw) == "" {
return def, nil
}
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 {
return 0, errors.New("limit must be a positive integer")
}
if n > max {
return max, nil
}
return n, nil
}
func isLocalhostRequest(r *http.Request) bool {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
ip := net.ParseIP(host)
if ip == nil {
return false
}
return ip.IsLoopback()
}
func hasMetadataPermissions(meta map[string]interface{}) bool {
if meta == nil {
return false
}
_, ok := meta["permissions"]
return ok
}
func filterHasMetadataPermissions(filter map[string]interface{}) bool {
return valueHasMetadataPermissions(filter)
}
func valueHasMetadataPermissions(value interface{}) bool {
switch v := value.(type) {
case map[string]interface{}:
for k, child := range v {
if strings.HasPrefix(k, "metadata.permissions") {
return true
}
if valueHasMetadataPermissions(child) {
return true
}
}
case []interface{}:
for _, item := range v {
if valueHasMetadataPermissions(item) {
return true
}
}
}
return false
}
func fetchEmbeddingDim(ctx context.Context, db *pgxpool.Pool) (int, error) {
var typeStr string
err := db.QueryRow(ctx, `
SELECT format_type(a.atttypid, a.atttypmod)
FROM pg_attribute a
JOIN pg_class c ON a.attrelid = c.oid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public'
AND c.relname = 'kb_doc_chunks'
AND a.attname = 'embedding'
AND a.attnum > 0
AND NOT a.attisdropped
`).Scan(&typeStr)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return 0, errors.New("kb_doc_chunks.embedding not found")
}
return 0, err
}
dim, err := parseVectorTypeDim(typeStr)
if err != nil {
return 0, err
}
return dim, nil
}
func verifyEmbeddingDim(db *pgxpool.Pool, expected int) error {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
actual, err := fetchEmbeddingDim(ctx, db)
if err != nil {
return err
}
if actual != expected {
return fmt.Errorf("embedding dimension mismatch: EMBEDDING_DIM=%d db=%d", expected, actual)
}
return nil
}
func parseVectorTypeDim(typeStr string) (int, error) {
s := strings.TrimSpace(typeStr)
if !strings.HasPrefix(s, "vector(") || !strings.HasSuffix(s, ")") {
return 0, fmt.Errorf("invalid embedding type: %s", s)
}
inner := strings.TrimSuffix(strings.TrimPrefix(s, "vector("), ")")
dim, err := strconv.Atoi(inner)
if err != nil || dim <= 0 {
return 0, fmt.Errorf("invalid embedding type: %s", s)
}
return dim, nil
}
func logConnectionInfo(db *pgxpool.Pool) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
@@ -1884,7 +2199,7 @@ func initStore() (APIKeyStore, *pgxpool.Pool, func(), error) {
func main() {
adminKey := os.Getenv("ADMIN_API_KEY")
if adminKey == "" {
log.Println("warning: ADMIN_API_KEY not set; admin endpoints will return 500")
log.Println("warning: ADMIN_API_KEY not set; admin endpoints will return 503")
}
store, db, closeStore, err := initStore()
@@ -1892,6 +2207,11 @@ func main() {
log.Fatal(err)
}
defer closeStore()
if db != nil {
if err := verifyEmbeddingDim(db, embeddingDim()); err != nil {
log.Fatal(err)
}
}
mux := http.NewServeMux()
mux.HandleFunc("/health", handleHealth)
@@ -1906,6 +2226,7 @@ func main() {
mux.Handle("/admin/api-keys", adminHandler)
mux.Handle("/admin/api-keys/", adminHandler)
mux.Handle("/admin/collections", withAdminAuth(adminKey, handleAdminCollections(db)))
mux.Handle("/admin/audit/permissions", withAdminAuth(adminKey, handleAdminPermissionsAudit(db)))
mux.Handle("/admin", withAdminUIAuth(adminKey, http.HandlerFunc(handleAdminUI)))
port := os.Getenv("PORT")

View File

@@ -0,0 +1,15 @@
-- Permissions tampering audit log
CREATE TABLE IF NOT EXISTS kb_permissions_audit (
id uuid PRIMARY KEY,
api_key_id text NOT NULL,
permissions_users text[] NOT NULL,
requested_at timestamptz NOT NULL DEFAULT now(),
endpoint text NOT NULL,
remote_addr text,
user_agent text,
provided_permissions jsonb NOT NULL,
provided_metadata jsonb NOT NULL
);
CREATE INDEX IF NOT EXISTS kb_permissions_audit_requested_at_idx ON kb_permissions_audit(requested_at);
CREATE INDEX IF NOT EXISTS kb_permissions_audit_api_key_id_idx ON kb_permissions_audit(api_key_id);

View File

@@ -0,0 +1,11 @@
-- Change embedding dimension to 1024 (destructive: truncate)
-- This assumes embeddings will be recalculated after migration.
DROP INDEX IF EXISTS kb_doc_chunks_embedding_hnsw;
TRUNCATE TABLE kb_doc_chunks;
ALTER TABLE kb_doc_chunks
ALTER COLUMN embedding TYPE vector(1024);
CREATE INDEX IF NOT EXISTS kb_doc_chunks_embedding_hnsw
ON kb_doc_chunks USING hnsw (embedding vector_cosine_ops);

View File

@@ -69,6 +69,12 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"503":
description: Service not configured (DATABASE_URL not set)
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/kb/search:
post:
summary: Vector similarity search
@@ -92,6 +98,12 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"503":
description: Service not configured (DATABASE_URL not set)
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/kb/delete:
post:
summary: Delete documents
@@ -115,6 +127,12 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"503":
description: Service not configured (DATABASE_URL not set)
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/collections:
get:
summary: List collections
@@ -132,6 +150,12 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"503":
description: Service not configured (DATABASE_URL not set)
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/admin/collections:
get:
summary: List collections (admin)
@@ -151,6 +175,12 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"503":
description: Service not configured (ADMIN_API_KEY or DATABASE_URL not set)
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/admin/api-keys:
get:
summary: List API keys (admin)
@@ -170,6 +200,12 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"503":
description: Service not configured (ADMIN_API_KEY not set)
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
post:
summary: Issue API key (admin)
operationId: adminApiKeysCreate
@@ -200,6 +236,12 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"503":
description: Service not configured (ADMIN_API_KEY not set)
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/admin/api-keys/{id}:
patch:
summary: Update API key metadata (admin)
@@ -237,6 +279,12 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"503":
description: Service not configured (ADMIN_API_KEY not set)
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/admin/api-keys/{id}/revoke:
post:
summary: Revoke API key (admin)
@@ -262,6 +310,72 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"503":
description: Service not configured (ADMIN_API_KEY not set)
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/admin/audit/permissions:
get:
summary: List permissions audit logs (admin)
operationId: listPermissionsAudit
security:
- AdminApiKeyAuth: []
parameters:
- name: since
in: query
required: false
schema:
type: string
format: date-time
description: RFC3339 lower bound for requested_at (inclusive)
- name: until
in: query
required: false
schema:
type: string
format: date-time
description: RFC3339 upper bound for requested_at (inclusive)
- name: api_key_id
in: query
required: false
schema:
type: string
description: Filter by API key ID
- name: limit
in: query
required: false
schema:
type: integer
minimum: 1
maximum: 1000
description: Max items to return (default 100, max 1000)
responses:
"200":
description: Audit log list
content:
application/json:
schema:
$ref: "#/components/schemas/PermissionsAuditResponse"
"400":
description: Invalid request
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"401":
description: Unauthorized
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"503":
description: Service not configured (ADMIN_API_KEY or DATABASE_URL not set)
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
components:
securitySchemes:
ApiKeyAuth:
@@ -343,11 +457,12 @@ components:
metadata:
type: object
additionalProperties: true
description: metadata.permissions is not allowed (server enforces permissions.users from API key).
embedding:
type: array
items:
type: number
description: Embedding vector (length 1024). If omitted, server generates via llama.cpp.
description: Embedding vector (length must match EMBEDDING_DIM). If omitted, server generates via provider.
required:
- id
- content
@@ -385,7 +500,7 @@ components:
type: array
items:
type: number
description: Optional precomputed embedding vector. If omitted, server generates via llama.cpp.
description: Optional precomputed embedding vector (length must match EMBEDDING_DIM). If omitted, server generates via provider.
top_k:
type: integer
default: 5
@@ -404,7 +519,8 @@ components:
additionalProperties: true
description: |
JSON filter over metadata. Supported operators: eq, in, contains, exists, lt/lte/gt/gte, and/or.
Example: {"and":[{"eq":{"metadata.source":"crm"}},{"exists":{"metadata.permissions.users":true}}]}
Example: {"and":[{"eq":{"metadata.source":"crm"}},{"exists":{"metadata.visibility":true}}]}
Note: metadata.permissions.* in filter is rejected (400).
required:
- query
KbSearchResponse:
@@ -431,7 +547,7 @@ components:
filter:
type: object
additionalProperties: true
description: Required unless id is provided. doc_id-only delete is not allowed (use filter). Optional metadata filter (MCP filter DSL). Permissions filter is always enforced. Supports eq/in/contains/exists/lt/lte/gt/gte and/or.
description: Required unless id is provided. doc_id-only delete is not allowed (use filter). Optional metadata filter (MCP filter DSL). Permissions filter is always enforced. Supports eq/in/contains/exists/lt/lte/gt/gte and/or. metadata.permissions.* in filter is rejected (400).
dry_run:
type: boolean
description: If true, only returns the number of rows that would be deleted.
@@ -521,6 +637,51 @@ components:
$ref: "#/components/schemas/AdminApiKey"
required:
- items
PermissionsAuditResponse:
type: object
properties:
items:
type: array
items:
$ref: "#/components/schemas/PermissionsAuditItem"
required:
- items
PermissionsAuditItem:
type: object
properties:
id:
type: string
api_key_id:
type: string
permissions_users:
type: array
items:
type: string
requested_at:
type: string
format: date-time
endpoint:
type: string
remote_addr:
type: string
nullable: true
user_agent:
type: string
nullable: true
provided_permissions:
type: object
additionalProperties: true
provided_metadata:
type: object
additionalProperties: true
required:
- id
- api_key_id
- permissions_users
- requested_at
- endpoint
- provided_permissions
- provided_metadata
AdminApiKeyCreateRequest:
type: object
properties:

View File

@@ -7,6 +7,18 @@
- [ ] `doc_upsert``ts` 既定補完(未指定時に現在時刻)
- [ ] `security_upsert``ts` 既定補完(未指定時に現在時刻)
## 今回の変更点チェックリスト
- [x] `/admin` 未設定時は 503 で明示エラー
- [x] `DATABASE_URL` 未設定時は 503 で明示エラー
- [x] `metadata.permissions` 送信を 400 で拒否
- [x] `filter` 内の `metadata.permissions.*` を 400 で拒否
- [x] 改ざん監査ログの保存DB + ログ)
- [x] 改ざん監査ログ取得 API`GET /admin/audit/permissions`
- [x] 監査ログ用テーブル `kb_permissions_audit` 追加
- [x] 1024次元へ統一するマイグレーション追加破壊的
- [x] 1024次元の検証SQLを `format_type` ベースに修正
- [x] README の 1024 次元/注意点/テスト結果を更新
## 完了
- [x] `/kb/delete` エンドポイント追加(`id` または `filter` 必須)
- [x] MCP削除ツール追加`dev_log` / `trade_event` / `doc` / `security`

46
scripts/install_worklog_hook.sh Executable file
View File

@@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
if [[ -z "$repo_root" ]]; then
echo "install_worklog_hook: not a git repository" >&2
exit 1
fi
common_src="$repo_root/scripts/worklog_mcp_common.sh"
if [[ ! -f "$common_src" ]]; then
echo "install_worklog_hook: common script not found: $common_src" >&2
exit 1
fi
install_common="0"
common_dest="${WORKLOG_MCP_COMMON:-$HOME/.local/bin/worklog_mcp_common.sh}"
if [[ "${1:-}" == "--install-common" ]]; then
install_common="1"
fi
if [[ "$install_common" == "1" ]]; then
mkdir -p "$(dirname "$common_dest")"
cp "$common_src" "$common_dest"
chmod +x "$common_dest"
echo "Installed common script: $common_dest"
echo "Set WORKLOG_MCP_COMMON if you want a different path."
fi
hook_path="$repo_root/.git/hooks/post-commit"
cat <<HOOK > "$hook_path"
#!/usr/bin/env bash
exec "$repo_root/scripts/worklog_mcp_hook.sh"
HOOK
chmod +x "$hook_path"
chmod +x "$repo_root/scripts/worklog_mcp_common.sh"
chmod +x "$repo_root/scripts/worklog_mcp_hook.sh"
chmod +x "$repo_root/scripts/worklog_mcp.sh" || true
cat <<'MSG'
worklog hook installed (post-commit).
If you want to share a single common script across repos, run:
scripts/install_worklog_hook.sh --install-common
Then each repo can set WORKLOG_MCP_COMMON to that path (or rely on ~/.local/bin).
MSG

View File

@@ -7,3 +7,24 @@ SELECT column_name, data_type
FROM information_schema.columns
WHERE table_name = 'kb_delete_audit'
ORDER BY ordinal_position;
-- Verify kb_permissions_audit exists and indexes
SELECT to_regclass('public.kb_permissions_audit') AS kb_permissions_audit_table;
SELECT indexname FROM pg_indexes WHERE tablename = 'kb_permissions_audit' ORDER BY indexname;
-- Sanity: ensure columns exist
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_name = 'kb_permissions_audit'
ORDER BY ordinal_position;
-- Verify embedding dimension is 1024
SELECT format_type(a.atttypid, a.atttypmod) AS embedding_type
FROM pg_attribute a
JOIN pg_class c ON a.attrelid = c.oid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public'
AND c.relname = 'kb_doc_chunks'
AND a.attname = 'embedding'
AND a.attnum > 0
AND NOT a.attisdropped;

View File

@@ -1,296 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
WORKLOG_PATH="notes/worklog.md"
STATE_PATH=".worklog_mcp_state"
MCP_SERVER_CMD=(go run /Users/sunamurahideyuki/develop/pgvecterAPI/cmd/mcp-server)
repo_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
if [[ -z "$repo_root" ]]; then
repo_root="$(pwd)"
fi
if [[ ! -f "$WORKLOG_PATH" ]]; then
export WORKLOG_REPO_ROOT="$repo_root"
export WORKLOG_PATH="${WORKLOG_PATH:-$repo_root/notes/worklog.md}"
export STATE_PATH="${STATE_PATH:-$repo_root/.worklog_mcp_state}"
common="$repo_root/scripts/worklog_mcp_common.sh"
if [[ ! -x "$common" ]]; then
echo "worklog_mcp: common script not found: $common" >&2
exit 0
fi
# Extract the last paragraph (separated by blank lines)
entry="$(awk 'BEGIN{RS=""; ORS=""} {block=$0} END{print block}' "$WORKLOG_PATH" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
if [[ -z "$entry" ]]; then
exit 0
fi
summary="$(WORKLOG_ENTRY="$entry" python3 - <<'PY'
import os, re
text = os.environ["WORKLOG_ENTRY"]
def summarize(t: str) -> str:
t = t.strip()
if not t:
return t
if "。" in t:
parts = [p for p in t.split("。") if p.strip()]
return "。".join(parts[:2]) + ("。" if len(parts) > 0 else "")
if "." in t:
parts = [p for p in t.split(".") if p.strip()]
return ".".join(parts[:2]) + ("." if len(parts) > 0 else "")
if len(t) > 240:
return t[:240].rstrip() + "..."
return t
print(summarize(text))
PY
)"
hash="$(printf "%s" "$entry" | shasum -a 256 | awk '{print $1}')"
if [[ -f "$STATE_PATH" && "${WORKLOG_FORCE:-}" != "1" ]]; then
last_hash="$(cat "$STATE_PATH" 2>/dev/null || true)"
if [[ "$hash" == "$last_hash" ]]; then
exit 0
fi
fi
if [[ -f ".env" ]]; then
set -a
# shellcheck disable=SC1091
. ./.env
set +a
fi
if [[ -z "${PGVECTER_BASE_URL:-}" || -z "${PGVECTER_API_KEY:-}" ]]; then
echo "worklog_mcp: PGVECTER_BASE_URL / PGVECTER_API_KEY が未設定のため送信をスキップしました。" >&2
exit 0
fi
has_cmd() {
command -v "$1" >/dev/null 2>&1
}
contains() {
local pattern="$1"
if has_cmd rg; then
printf "%s" "$entry" | rg -q "$pattern"
else
printf "%s" "$entry" | grep -Eq "$pattern"
fi
}
declare -a tags=()
add_tag() {
local t="$1"
for e in "${tags[@]-}"; do
if [[ "$e" == "$t" ]]; then
return 0
fi
done
tags+=("$t")
}
contains "pgvecter|pgvecterAPI" && add_tag "pgvecter"
contains "MCP|mcp" && add_tag "mcp"
contains "API|api" && add_tag "api"
contains "デプロイ|deploy|リリース" && add_tag "deploy"
contains "リリース|release" && add_tag "release"
contains "ロールバック|rollback" && add_tag "rollback"
contains "運用|ops|監視" && add_tag "ops"
contains "監視|monitor|monitoring" && add_tag "monitoring"
contains "アラート|alert" && add_tag "alert"
contains "障害|incident|障碍|障害対応" && add_tag "incident"
contains "オンコール|oncall" && add_tag "oncall"
contains "セキュリティ|security" && add_tag "security"
contains "DB|db|database|マイグレーション|migration" && add_tag "db" && add_tag "migration"
contains "スキーマ|schema" && add_tag "schema"
contains "埋め込み|embedding" && add_tag "embedding"
contains "検索|search" && add_tag "search"
contains "キャッシュ|cache" && add_tag "cache"
contains "パフォーマンス|perf|latency|遅延|高速化" && add_tag "perf"
contains "バグ|bug" && add_tag "bugfix"
contains "リファクタ|refactor" && add_tag "refactor"
contains "ドキュメント|docs" && add_tag "docs"
contains "テスト|test" && add_tag "test"
contains "CI|ci|ビルド|build" && add_tag "ci"
contains "インフラ|infra" && add_tag "infra"
if [[ "${#tags[@]}" -lt 2 ]]; then
add_tag "pgvecter"
add_tag "ops"
fi
if [[ "${#tags[@]}" -gt 5 ]]; then
tags=("${tags[@]:0:5}")
fi
topic="general"
if has_cmd uuidgen; then
entry_id="$(uuidgen | tr '[:upper:]' '[:lower:]')"
else
entry_id="$(python3 - <<'PY'
import uuid
print(str(uuid.uuid4()))
PY
)"
fi
tags_json="$(printf '%s\n' "${tags[@]-}" | python3 - <<'PY'
import json, sys
tags = [line.rstrip("\n") for line in sys.stdin if line.rstrip("\n")]
print(json.dumps(tags))
PY
)"
export WORKLOG_ENTRY="$entry"
export WORKLOG_SUMMARY="$summary"
export WORKLOG_TAGS_JSON="$tags_json"
export WORKLOG_TOPIC="$topic"
export WORKLOG_ID="$entry_id"
payload="$(python3 - <<'PY'
import json, os, re
entry = os.environ["WORKLOG_ENTRY"]
tags = json.loads(os.environ.get("WORKLOG_TAGS_JSON", "[]"))
topic = os.environ["WORKLOG_TOPIC"]
entry_id = os.environ["WORKLOG_ID"]
init = {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {"name": "worklog-hook", "version": "0.1"}
}
}
def detect_topic(text, tags, fallback):
patterns = [
("mcp", [r"\\bmcp\\b", r"MCP"]),
("deploy", [r"デプロイ", r"deploy", r"リリース", r"release"]),
("ops", [r"運用", r"\\bops\\b", r"監視", r"monitor", r"monitoring", r"oncall", r"アラート", r"alert"]),
("ci", [r"\\bci\\b", r"CI", r"ビルド", r"build"]),
("security", [r"セキュリティ", r"security", r"脆弱性", r"vuln", r"CVE", r"侵入", r"攻撃", r"WAF"]),
("db", [r"\\bDB\\b", r"\\bdb\\b", r"database", r"postgres", r"pgvector", r"sql", r"マイグレーション", r"migration", r"schema"]),
("docs", [r"ドキュメント", r"docs", r"readme", r"仕様", r"spec"]),
("test", [r"テスト", r"\\btest\\b", r"e2e", r"unit", r"integration"]),
("api", [r"API", r"api", r"endpoint", r"エンドポイント", r"ルート", r"route"]),
]
tag_boost = {
"mcp": "mcp",
"deploy": "deploy",
"ops": "ops",
"ci": "ci",
"security": "security",
"db": "db",
"migration": "db",
"docs": "docs",
"test": "test",
"api": "api",
}
scores = {name: 0 for name, _ in patterns}
for name, pats in patterns:
for pat in pats:
if re.search(pat, text, re.IGNORECASE):
scores[name] += 1
for t in tags:
key = tag_boost.get(t)
if key:
scores[key] += 1
best = max(scores.items(), key=lambda kv: (kv[1], -list(scores.keys()).index(kv[0])))
if best[1] > 0:
return best[0]
return fallback if fallback != "general" else "general"
if not tags:
text = entry
def has(pat):
return re.search(pat, text, re.IGNORECASE) is not None
if has(r"pgvecter|pgvecterAPI"):
tags.append("pgvecter")
if has(r"MCP|mcp"):
tags.append("mcp")
if has(r"API|api"):
tags.append("api")
if has(r"デプロイ|deploy|リリース"):
tags.append("deploy")
if has(r"リリース|release"):
tags.append("release")
if has(r"ロールバック|rollback"):
tags.append("rollback")
if has(r"運用|ops|監視"):
tags.append("ops")
if has(r"監視|monitor|monitoring"):
tags.append("monitoring")
if has(r"アラート|alert"):
tags.append("alert")
if has(r"障害|incident|障碍|障害対応"):
tags.append("incident")
if has(r"オンコール|oncall"):
tags.append("oncall")
if has(r"セキュリティ|security"):
tags.append("security")
if has(r"DB|db|database|マイグレーション|migration"):
tags.extend([t for t in ("db", "migration") if t not in tags])
if has(r"スキーマ|schema"):
tags.append("schema")
if has(r"埋め込み|embedding"):
tags.append("embedding")
if has(r"検索|search"):
tags.append("search")
if has(r"キャッシュ|cache"):
tags.append("cache")
if has(r"パフォーマンス|perf|latency|遅延|高速化"):
tags.append("perf")
if has(r"バグ|bug"):
tags.append("bugfix")
if has(r"リファクタ|refactor"):
tags.append("refactor")
if has(r"ドキュメント|docs"):
tags.append("docs")
if has(r"テスト|test"):
tags.append("test")
if has(r"CI|ci|ビルド|build"):
tags.append("ci")
if has(r"インフラ|infra"):
tags.append("infra")
if len(tags) < 2:
for t in ("pgvecter", "ops"):
if t not in tags:
tags.append(t)
if len(tags) > 5:
tags = tags[:5]
topic = detect_topic(entry, tags, topic)
metadata = {"worklog_path": "notes/worklog.md"}
if entry != os.environ["WORKLOG_SUMMARY"]:
metadata["original"] = entry
call = {
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "diary_upsert",
"arguments": {
"id": entry_id,
"collection": "dev_diary",
"content": os.environ["WORKLOG_SUMMARY"],
"tags": tags,
"topic": topic,
"source": "codex",
"metadata": metadata
}
}
}
print(json.dumps(init))
print(json.dumps(call))
PY
)"
response="$(printf "%s\n" "$payload" | "${MCP_SERVER_CMD[@]}")"
if printf "%s" "$response" | grep -q '"isError":true'; then
echo "worklog_mcp: MCP 送信に失敗しました。" >&2
echo "$response" >&2
exit 0
fi
echo "$hash" > "$STATE_PATH"
exec "$common"

323
scripts/worklog_mcp_common.sh Executable file
View File

@@ -0,0 +1,323 @@
#!/usr/bin/env bash
set -euo pipefail
# Resolve repo root (default to git root or current dir)
repo_root="${WORKLOG_REPO_ROOT:-}"
if [[ -z "$repo_root" ]]; then
if git_root=$(git rev-parse --show-toplevel 2>/dev/null); then
repo_root="$git_root"
else
repo_root="$(pwd)"
fi
fi
worklog_path="${WORKLOG_PATH:-$repo_root/notes/worklog.md}"
state_path="${STATE_PATH:-$repo_root/.worklog_mcp_state}"
env_path="${WORKLOG_ENV_PATH:-$repo_root/.env}"
if [[ -n "${WORKLOG_MCP_SERVER_CMD:-}" ]]; then
# shellcheck disable=SC2206
MCP_SERVER_CMD=($WORKLOG_MCP_SERVER_CMD)
else
MCP_SERVER_CMD=(go run /Users/sunamurahideyuki/develop/pgvecterAPI/cmd/mcp-server)
fi
if [[ ! -f "$worklog_path" ]]; then
exit 0
fi
# Extract the last paragraph (separated by blank lines)
entry="$(awk 'BEGIN{RS=""; ORS=""} {block=$0} END{print block}' "$worklog_path" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
if [[ -z "$entry" ]]; then
exit 0
fi
summary="$(WORKLOG_ENTRY="$entry" python3 - <<'PY'
import os
text = os.environ["WORKLOG_ENTRY"]
def summarize(t: str) -> str:
t = t.strip()
if not t:
return t
if "。" in t:
parts = [p for p in t.split("。") if p.strip()]
return "。".join(parts[:2]) + ("。" if len(parts) > 0 else "")
if "." in t:
parts = [p for p in t.split(".") if p.strip()]
return ".".join(parts[:2]) + ("." if len(parts) > 0 else "")
if len(t) > 240:
return t[:240].rstrip() + "..."
return t
print(summarize(text))
PY
)"
hash="$(printf "%s" "$entry" | shasum -a 256 | awk '{print $1}')"
if [[ -f "$state_path" && "${WORKLOG_FORCE:-}" != "1" ]]; then
last_hash="$(cat "$state_path" 2>/dev/null || true)"
if [[ "$hash" == "$last_hash" ]]; then
exit 0
fi
fi
if [[ -f "$env_path" ]]; then
set -a
# shellcheck disable=SC1090
. "$env_path"
set +a
fi
if [[ -z "${PGVECTER_BASE_URL:-}" || -z "${PGVECTER_API_KEY:-}" ]]; then
echo "worklog_mcp: PGVECTER_BASE_URL / PGVECTER_API_KEY が未設定のため送信をスキップしました。" >&2
exit 0
fi
has_cmd() {
command -v "$1" >/dev/null 2>&1
}
contains() {
local pattern="$1"
if has_cmd rg; then
printf "%s" "$entry" | rg -q "$pattern"
else
printf "%s" "$entry" | grep -Eq "$pattern"
fi
}
declare -a tags=()
add_tag() {
local t="$1"
for e in "${tags[@]-}"; do
if [[ "$e" == "$t" ]]; then
return 0
fi
done
tags+=("$t")
}
contains "pgvecter|pgvecterAPI" && add_tag "pgvecter"
contains "MCP|mcp" && add_tag "mcp"
contains "API|api" && add_tag "api"
contains "デプロイ|deploy|リリース" && add_tag "deploy"
contains "リリース|release" && add_tag "release"
contains "ロールバック|rollback" && add_tag "rollback"
contains "運用|ops|監視" && add_tag "ops"
contains "監視|monitor|monitoring" && add_tag "monitoring"
contains "アラート|alert" && add_tag "alert"
contains "障害|incident|障碍|障害対応" && add_tag "incident"
contains "オンコール|oncall" && add_tag "oncall"
contains "セキュリティ|security" && add_tag "security"
contains "DB|db|database|マイグレーション|migration" && add_tag "db" && add_tag "migration"
contains "スキーマ|schema" && add_tag "schema"
contains "埋め込み|embedding" && add_tag "embedding"
contains "検索|search" && add_tag "search"
contains "キャッシュ|cache" && add_tag "cache"
contains "パフォーマンス|perf|latency|遅延|高速化" && add_tag "perf"
contains "バグ|bug" && add_tag "bugfix"
contains "リファクタ|refactor" && add_tag "refactor"
contains "ドキュメント|docs" && add_tag "docs"
contains "テスト|test" && add_tag "test"
contains "CI|ci|ビルド|build" && add_tag "ci"
contains "インフラ|infra" && add_tag "infra"
if [[ "${#tags[@]}" -lt 2 ]]; then
add_tag "pgvecter"
add_tag "ops"
fi
if [[ "${#tags[@]}" -gt 5 ]]; then
tags=("${tags[@]:0:5}")
fi
topic="general"
if has_cmd uuidgen; then
entry_id="$(uuidgen | tr '[:upper:]' '[:lower:]')"
else
entry_id="$(python3 - <<'PY'
import uuid
print(str(uuid.uuid4()))
PY
)"
fi
tags_json="$(printf '%s\n' "${tags[@]-}" | python3 - <<'PY'
import json, sys
tags = [line.rstrip("\n") for line in sys.stdin if line.rstrip("\n")]
print(json.dumps(tags))
PY
)"
export WORKLOG_ENTRY="$entry"
export WORKLOG_SUMMARY="$summary"
export WORKLOG_TAGS_JSON="$tags_json"
export WORKLOG_TOPIC="$topic"
export WORKLOG_ID="$entry_id"
if [[ "$worklog_path" == "$repo_root/"* ]]; then
worklog_rel="${worklog_path#$repo_root/}"
else
worklog_rel="$worklog_path"
fi
export WORKLOG_PATH_REL="$worklog_rel"
payload="$(python3 - <<'PY'
import json, os, re
entry = os.environ["WORKLOG_ENTRY"]
tags = json.loads(os.environ.get("WORKLOG_TAGS_JSON", "[]"))
topic = os.environ["WORKLOG_TOPIC"]
entry_id = os.environ["WORKLOG_ID"]
worklog_rel = os.environ.get("WORKLOG_PATH_REL", "notes/worklog.md")
init = {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {"name": "worklog-hook", "version": "0.1"}
}
}
def detect_topic(text, tags, fallback):
patterns = [
("mcp", [r"\\bmcp\\b", r"MCP"]),
("deploy", [r"デプロイ", r"deploy", r"リリース", r"release"]),
("ops", [r"運用", r"\\bops\\b", r"監視", r"monitor", r"monitoring", r"oncall", r"アラート", r"alert"]),
("ci", [r"\\bci\\b", r"CI", r"ビルド", r"build"]),
("security", [r"セキュリティ", r"security", r"脆弱性", r"vuln", r"CVE", r"侵入", r"攻撃", r"WAF"]),
("db", [r"\\bDB\\b", r"\\bdb\\b", r"database", r"postgres", r"pgvector", r"sql", r"マイグレーション", r"migration", r"schema"]),
("docs", [r"ドキュメント", r"docs", r"readme", r"仕様", r"spec"]),
("test", [r"テスト", r"\\btest\\b", r"e2e", r"unit", r"integration"]),
("api", [r"API", r"api", r"endpoint", r"エンドポイント", r"ルート", r"route"]),
]
tag_boost = {
"mcp": "mcp",
"deploy": "deploy",
"ops": "ops",
"ci": "ci",
"security": "security",
"db": "db",
"migration": "db",
"docs": "docs",
"test": "test",
"api": "api",
}
scores = {name: 0 for name, _ in patterns}
for name, pats in patterns:
for pat in pats:
if re.search(pat, text, re.IGNORECASE):
scores[name] += 1
for t in tags:
key = tag_boost.get(t)
if key:
scores[key] += 1
best = max(scores.items(), key=lambda kv: (kv[1], -list(scores.keys()).index(kv[0])))
if best[1] > 0:
return best[0]
return fallback if fallback != "general" else "general"
if not tags:
text = entry
def has(pat):
return re.search(pat, text, re.IGNORECASE) is not None
if has(r"pgvecter|pgvecterAPI"):
tags.append("pgvecter")
if has(r"MCP|mcp"):
tags.append("mcp")
if has(r"API|api"):
tags.append("api")
if has(r"デプロイ|deploy|リリース"):
tags.append("deploy")
if has(r"リリース|release"):
tags.append("release")
if has(r"ロールバック|rollback"):
tags.append("rollback")
if has(r"運用|ops|監視"):
tags.append("ops")
if has(r"監視|monitor|monitoring"):
tags.append("monitoring")
if has(r"アラート|alert"):
tags.append("alert")
if has(r"障害|incident|障碍|障害対応"):
tags.append("incident")
if has(r"オンコール|oncall"):
tags.append("oncall")
if has(r"セキュリティ|security"):
tags.append("security")
if has(r"DB|db|database|マイグレーション|migration"):
tags.extend([t for t in ("db", "migration") if t not in tags])
if has(r"スキーマ|schema"):
tags.append("schema")
if has(r"埋め込み|embedding"):
tags.append("embedding")
if has(r"検索|search"):
tags.append("search")
if has(r"キャッシュ|cache"):
tags.append("cache")
if has(r"パフォーマンス|perf|latency|遅延|高速化"):
tags.append("perf")
if has(r"バグ|bug"):
tags.append("bugfix")
if has(r"リファクタ|refactor"):
tags.append("refactor")
if has(r"ドキュメント|docs"):
tags.append("docs")
if has(r"テスト|test"):
tags.append("test")
if has(r"CI|ci|ビルド|build"):
tags.append("ci")
if has(r"インフラ|infra"):
tags.append("infra")
if len(tags) < 2:
for t in ("pgvecter", "ops"):
if t not in tags:
tags.append(t)
if len(tags) > 5:
tags = tags[:5]
topic = detect_topic(entry, tags, topic)
metadata = {"worklog_path": worklog_rel}
if entry != os.environ["WORKLOG_SUMMARY"]:
metadata["original"] = entry
call = {
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "diary_upsert",
"arguments": {
"id": entry_id,
"collection": "dev_diary",
"content": os.environ["WORKLOG_SUMMARY"],
"tags": tags,
"topic": topic,
"source": "codex",
"metadata": metadata
}
}
}
print(json.dumps(init))
print(json.dumps(call))
PY
)"
response="$(printf "%s\n" "$payload" | "${MCP_SERVER_CMD[@]}")"
if printf "%s" "$response" | grep -q '"isError":true'; then
echo "worklog_mcp: MCP 送信に失敗しました。" >&2
echo "$response" >&2
exit 0
fi
echo "$hash" > "$state_path"

27
scripts/worklog_mcp_hook.sh Executable file
View File

@@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
if [[ -z "$repo_root" ]]; then
exit 0
fi
export WORKLOG_REPO_ROOT="$repo_root"
export WORKLOG_PATH="${WORKLOG_PATH:-$repo_root/notes/worklog.md}"
export STATE_PATH="${STATE_PATH:-$repo_root/.worklog_mcp_state}"
common="${WORKLOG_MCP_COMMON:-}"
if [[ -z "$common" ]]; then
if [[ -x "/Users/sunamurahideyuki/.local/bin/worklog_mcp_common.sh" ]]; then
common="/Users/sunamurahideyuki/.local/bin/worklog_mcp_common.sh"
else
common="$repo_root/scripts/worklog_mcp_common.sh"
fi
fi
if [[ ! -x "$common" ]]; then
echo "worklog_mcp: common script not found: $common" >&2
exit 0
fi
exec "$common"