@cocorograph/hub-agent 0.8.64 → 0.8.66

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -77,6 +77,23 @@ hub-agent plugins uninstall <name> # ~/.hub/plugins/<prio>-<name>/ を削除
77
77
 
78
78
  ## 構成
79
79
 
80
+ ### チャット表示のマスク
81
+
82
+ Claude / Codex のチャットは、既定で表示・WebSocket 配信用コピーをマスクします。
83
+ `get_skill_prompt`、`skill/`・`エージェント/` の内部本文取得、デプロイ接続情報取得の
84
+ 結果を非表示にし、通常の本文・引数・エラー・差分に含まれる既知の秘密値も伏せます。
85
+ 一般のナレッジ取得や通常のツール結果は表示します。
86
+
87
+ - 分割された秘密値の漏出を防ぐため、文字単位の途中出力ではなく完成済み本文を表示します。
88
+ - コード実行ラッパーに対象ツール名が明記されている場合、その実行結果全体を非表示にします。
89
+ 動的に組み立てたツール名や、任意の文章に埋め込まれた未知形式の秘密を完全検出する機能ではありません。
90
+ - 許可確認には伏せた入力を渡し、変更されなかった伏せ字を元の入力へ戻して実行します。
91
+ 一意に復元できない編集は拒否します。マスク失敗時に生の本文へ戻すことはしません。
92
+ - Codex のモデル入力・再開用 rollout 原本は変更しません。Claude の既存の終了後 JSONL
93
+ マスクは継続します。ターミナル表示や過去に配信済みのデータを遡って消すものではありません。
94
+ - 緊急時の切り戻し設定 `HUB_AGENT_TOOL_RESULT_MASK=0` は、秘密値を含め無加工で配信します。
95
+ 通常運用では無効化しないでください。
96
+
80
97
  ```
81
98
  hub-agent/
82
99
  ├── bin/
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.8.64",
3
+ "version": "0.8.66",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -45,6 +45,13 @@ NODE_DEFAULT_BREW_FORMULA="node@24"
45
45
  PACKAGE_NAME="@cocorograph/hub-agent"
46
46
  CLAUDE_CODE_PACKAGE="@anthropic-ai/claude-code"
47
47
  CODEX_PACKAGE="@openai/codex"
48
+ UV_VERSION="0.12.8"
49
+ HUB_PYTHON_MINOR="3.13"
50
+ HUB_VENV_DIR="$HOME/.local/share/hub-venv"
51
+ # Hub Python の依存バージョンを更新するときは、この配列を変更する。
52
+ HUB_PYTHON_PACKAGES=("PyYAML==6.0.3" "Pillow==12.3.0" "openpyxl==3.1.5")
53
+ # HUB_PYTHON_PACKAGES と同じ 3 件を指す。依存を増やすときは両方を更新する。
54
+ HUB_PYTHON_IMPORTS=("yaml" "PIL" "openpyxl")
48
55
 
49
56
  color_step() { printf "\033[1;34m==> %s\033[0m\n" "$1"; }
50
57
  color_ok() { printf "\033[1;32m✓ %s\033[0m\n" "$1"; }
@@ -598,6 +605,7 @@ ensure_build_tools() {
598
605
  ensure_python3() {
599
606
  if python3 --version >/dev/null 2>&1; then
600
607
  color_ok "$(python3 --version 2>&1)"
608
+ _check_python3_version
601
609
  return 0
602
610
  fi
603
611
 
@@ -615,12 +623,355 @@ ensure_python3() {
615
623
 
616
624
  if python3 --version >/dev/null 2>&1; then
617
625
  color_ok "$(python3 --version 2>&1)"
626
+ _check_python3_version
618
627
  else
619
628
  color_err "python3 の導入後も実行できません"
620
629
  exit 1
621
630
  fi
622
631
  }
623
632
 
633
+ _check_python3_version() {
634
+ local version
635
+ version="$(python3 -c 'import platform; print(platform.python_version())')" || {
636
+ color_err "python3 のバージョンを確認できません"
637
+ exit 1
638
+ }
639
+ if python3 -c 'import sys; raise SystemExit(0 if sys.version_info < (3, 9) else 1)'; then
640
+ color_err "hub の配布物は Python 3.9 以上が必要です(現在: $version)"
641
+ exit 1
642
+ fi
643
+ if python3 -c 'import sys; raise SystemExit(0 if sys.version_info < (3, 11) else 1)'; then
644
+ color_warn "Python 3.11 未満では一部機能が無効になります(Codex の MCP 承認チェック)。現在: $version"
645
+ fi
646
+ }
647
+
648
+ _hub_uv_triple() {
649
+ local os arch libc="gnu"
650
+ os="$(uname -s)"
651
+ arch="$(uname -m)"
652
+ if [[ "$os" == "Linux" ]] && ldd --version 2>&1 | grep -qi musl; then
653
+ libc="musl"
654
+ fi
655
+ case "$os:$arch" in
656
+ Darwin:arm64) printf '%s\n' "aarch64-apple-darwin" ;;
657
+ Darwin:x86_64) printf '%s\n' "x86_64-apple-darwin" ;;
658
+ Linux:x86_64) printf '%s\n' "x86_64-unknown-linux-$libc" ;;
659
+ Linux:aarch64|Linux:arm64) printf '%s\n' "aarch64-unknown-linux-$libc" ;;
660
+ *) color_err "uv が対応していないプラットフォームです: $os $arch"; return 1 ;;
661
+ esac
662
+ }
663
+
664
+ _install_hub_uv() (
665
+ set -e
666
+ local triple url tmp expected actual uv_src local_bin destination staged=""
667
+ triple="$(_hub_uv_triple)" || exit 1
668
+ local_bin="$HOME/.local/bin"
669
+ destination="$local_bin/uv"
670
+ mkdir -p "$local_bin"
671
+ if [[ -e "$destination" || -L "$destination" ]]; then
672
+ color_err "$destination は uv と名前衝突しています。既存ファイルを退避するか PATH を修正してください"
673
+ exit 1
674
+ fi
675
+ url="https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-${triple}.tar.gz"
676
+ tmp="$(mktemp -d)"
677
+ trap 'rm -rf "$tmp"' EXIT
678
+ staged="$(mktemp "$local_bin/.uv.tmp.XXXXXX")"
679
+ trap 'rm -rf "$tmp"; if [[ -n "$staged" ]]; then rm -f "$staged"; fi' EXIT
680
+ trap 'exit 130' INT
681
+ trap 'exit 143' TERM
682
+ retry 3 curl -fsSL "$url" -o "$tmp/uv.tar.gz" || exit 1
683
+ retry 3 curl -fsSL "$url.sha256" -o "$tmp/uv.tar.gz.sha256" || exit 1
684
+ expected="$(head -1 "$tmp/uv.tar.gz.sha256")"
685
+ expected="${expected%%[[:space:]]*}"
686
+ if present shasum; then
687
+ actual="$(shasum -a 256 "$tmp/uv.tar.gz")"
688
+ elif present sha256sum; then
689
+ actual="$(sha256sum "$tmp/uv.tar.gz")"
690
+ else
691
+ color_err "sha256 検算コマンド (shasum / sha256sum) がありません"
692
+ exit 1
693
+ fi
694
+ actual="${actual%%[[:space:]]*}"
695
+ if [[ -z "$expected" || "$actual" != "$expected" ]]; then
696
+ color_err "uv tarball の sha256 が一致しません"
697
+ exit 1
698
+ fi
699
+ tar -xzf "$tmp/uv.tar.gz" -C "$tmp"
700
+ uv_src="$(find "$tmp" -type f -name uv | head -1)"
701
+ [[ -n "$uv_src" ]] || { color_err "uv tarball に uv がありません"; exit 1; }
702
+ mv "$uv_src" "$staged"
703
+ chmod +x "$staged"
704
+ "$staged" --version >/dev/null 2>&1 || { color_err "取得した uv を実行できません"; exit 1; }
705
+ if [[ -e "$destination" || -L "$destination" ]]; then
706
+ color_err "$destination は uv と名前衝突しています。既存ファイルを退避するか PATH を修正してください"
707
+ exit 1
708
+ fi
709
+ mv "$staged" "$destination"
710
+ )
711
+
712
+ _hub_venv_usable() {
713
+ local venv_dir="${1:-$HUB_VENV_DIR}" python base_prefix version package spec distribution expected
714
+ python="$venv_dir/bin/python3"
715
+ [[ -x "$python" ]] || return 1
716
+ base_prefix="$($python -c 'import sys; print(sys.base_prefix)' 2>/dev/null)" || return 1
717
+ [[ -e "$base_prefix/bin/python3" ]] || return 1
718
+ version="$($python -c 'import sys; print(f"{sys.version_info[0]}.{sys.version_info[1]}")' 2>/dev/null)" || return 1
719
+ [[ "$version" == "$HUB_PYTHON_MINOR" ]] || return 1
720
+ for package in "${HUB_PYTHON_PACKAGES[@]}"; do
721
+ distribution="${package%%==*}"
722
+ expected="${package#*==}"
723
+ "$python" -c 'import importlib.metadata, sys; raise SystemExit(0 if importlib.metadata.version(sys.argv[1]) == sys.argv[2] else 1)' \
724
+ "$distribution" "$expected" >/dev/null 2>&1 || return 1
725
+ done
726
+ for spec in "${HUB_PYTHON_IMPORTS[@]}"; do
727
+ "$python" -c 'import importlib, sys; importlib.import_module(sys.argv[1])' "$spec" >/dev/null 2>&1 || return 1
728
+ done
729
+ }
730
+
731
+ _build_hub_venv() (
732
+ set -e
733
+ local new_dir="$1" built=0
734
+ trap '(( built == 1 )) || rm -rf "$new_dir"' EXIT
735
+ trap 'exit 130' INT
736
+ trap 'exit 143' TERM
737
+ rm -rf "$new_dir"
738
+ uv venv --python "$HUB_PYTHON_MINOR" "$new_dir" || exit 1
739
+ uv pip install --python "$new_dir/bin/python3" --only-binary :all: "${HUB_PYTHON_PACKAGES[@]}" || exit 1
740
+ _hub_venv_usable "$new_dir" || { color_err "構築した Hub Python venv の検証に失敗しました"; exit 1; }
741
+ built=1
742
+ )
743
+
744
+ _acquire_hub_python_lock() {
745
+ local lock_dir="$1" attempts=0 owner_file token owner_pid
746
+ owner_file="$lock_dir/owner"
747
+ # ⚠️ ここで時刻を採らない。`printf -v t '%(%s)T'` は bash 4.2 以降の機能で、macOS 標準の
748
+ # bash 3.2 では「invalid format character」で失敗する。`set -euo pipefail` の下なので
749
+ # インストーラ自体がここで止まる(実測。テストの bash は Homebrew の 5.x なので緑になってしまう)。
750
+ # mkdir lock では「観測 → 回収 → 再取得」の間に別プロセスが取り直した lock を、遅れて来た
751
+ # 回収者が持ち去れる。回収の rename が保証するのは、同時に回収する複数の勝者が 1 つになる
752
+ # ことまでで、破壊的操作の直前に所有を再確認しても TOCTOU の窓は消えない。
753
+ # そのため自動回収は行わず、異常終了後は表示されたディレクトリを人が削除して再実行する。
754
+ token="$$-${RANDOM:-0}-${RANDOM:-0}"
755
+ while ! mkdir "$lock_dir" 2>/dev/null; do
756
+ attempts=$((attempts + 1))
757
+ if (( attempts >= 50 )); then
758
+ owner_pid=""
759
+ if [[ -f "$owner_file" ]]; then
760
+ {
761
+ read -r _ || true
762
+ read -r owner_pid || true
763
+ } < "$owner_file"
764
+ fi
765
+ if [[ "$owner_pid" =~ ^[0-9]+$ ]]; then
766
+ color_err "Hub Python lock を取得できませんでした: $lock_dir。保持しているプロセス (PID $owner_pid) が動いているか ps -p $owner_pid で確かめ、動いていなければこのディレクトリを削除して再実行してください"
767
+ else
768
+ color_err "Hub Python lock を取得できませんでした: $lock_dir。インストーラが走っていないならこのディレクトリを削除して再実行してください"
769
+ fi
770
+ return 1
771
+ fi
772
+ sleep 0.1
773
+ done
774
+ if ! printf '%s\n%s\n' "$token" "$$" > "$owner_file"; then
775
+ rm -rf "$lock_dir" 2>/dev/null || true
776
+ return 1
777
+ fi
778
+ HUB_PYTHON_LOCK_TOKEN="$token"
779
+ }
780
+
781
+ _replace_hub_venv() (
782
+ local new_dir="$1" old_dir="$2" moved_old=0 installed=0
783
+ cleanup_replacement() {
784
+ if (( moved_old == 1 && installed == 0 )) && [[ -e "$old_dir" ]] && [[ ! -e "$HUB_VENV_DIR" ]]; then
785
+ mv "$old_dir" "$HUB_VENV_DIR"
786
+ fi
787
+ }
788
+ trap cleanup_replacement EXIT
789
+ if [[ -e "$HUB_VENV_DIR" || -L "$HUB_VENV_DIR" ]]; then
790
+ mv "$HUB_VENV_DIR" "$old_dir" || return 1
791
+ moved_old=1
792
+ fi
793
+ mv "$new_dir" "$HUB_VENV_DIR" || return 1
794
+ installed=1
795
+ )
796
+
797
+ _publish_hub_python_wrapper() (
798
+ local local_bin="$HOME/.local/bin" wrapper="$HOME/.local/bin/hub-python3"
799
+ local staged content wrapper_content
800
+ local marker='# hub-agent managed: hub-python3 (hub-agent のインストーラが生成・更新します。手で編集しないでください)'
801
+ local managed_line="exec \"$HUB_VENV_DIR/bin/python3\" \"\$@\""
802
+ mkdir -p "$local_bin"
803
+ if [[ -L "$wrapper" ]]; then
804
+ color_err "$wrapper は名前衝突しています。既存 symlink を退避するか PATH を修正してください"
805
+ return 1
806
+ fi
807
+ staged="$(mktemp "$local_bin/.hub-python3.tmp.XXXXXX")" || return 1
808
+ trap 'rm -f "$staged"' EXIT
809
+ printf '%s\n' '#!/usr/bin/env sh' "$marker" "$managed_line" > "$staged" || return 1
810
+ chmod +x "$staged" || return 1
811
+ # ⚠️ `$(<file)` は末尾の改行を落とすので、この比較は「末尾改行の数の違い」と「最終行の改行の
812
+ # 有無」を区別しない。内容のある行が 1 行でも違えば不一致になり、末尾改行だけが違う wrapper を
813
+ # 上書きしても失われる情報は無いので許容する。サンドボックスに cat / stat / wc / cmp が無く、
814
+ # バイト単位の比較には別の機構が要るため、そのために機構を足すことはしない。
815
+ # 以前付けていた sentinel の "x" は末尾改行の差を捉えているように見えて実際は効いていなかった
816
+ # (実測: `$(<a)x` は "a\n" と "a\n\n\n" と "a" のすべてで同じ値になる)ので外した。
817
+ content="$(<"$staged")"
818
+ [[ "$content" == $'#!/usr/bin/env sh\n'"$marker"$'\n'"$managed_line" ]] || return 1
819
+ _hub_python_wrapper_is_managed() {
820
+ [[ -f "$wrapper" && ! -L "$wrapper" ]] || return 1
821
+ wrapper_content="$(<"$wrapper")"
822
+ [[ "$wrapper_content" == "$content" ]] || return 1
823
+ # 未公開ブランチにしか存在しなかった marker なしの旧形は受け入れない。将来内容を変える版では
824
+ # marker で識別し、既知の旧内容を完全一致で列挙する。パスのワイルドカード許可には戻さない。
825
+ # marker をコピーした同一ユーザーの別 wrapper も管理物とみなす。脅威モデルは攻撃者ではなく、
826
+ # 同一ユーザーによる並行実行と中断であるため、このなりすまし耐性は担保しない。
827
+ }
828
+ if [[ -e "$wrapper" ]] && ! _hub_python_wrapper_is_managed; then
829
+ color_err "$wrapper は名前衝突しています。既存ファイルを退避するか PATH を修正してください"
830
+ return 1
831
+ fi
832
+ if [[ -L "$wrapper" ]] || { [[ -e "$wrapper" ]] && ! _hub_python_wrapper_is_managed; }; then
833
+ color_err "$wrapper は公開直前に名前衝突しました。既存ファイルを退避するか PATH を修正してください"
834
+ return 1
835
+ fi
836
+ mv "$staged" "$wrapper" || return 1
837
+ )
838
+
839
+ # lock がまだ自分のものかを確かめる。これは相互排除ではなく best-effort の検出であり、確認と
840
+ # 操作の間の TOCTOU の窓は残る。自動回収の削除後に自分の lock を失うのは、人が lock
841
+ # ディレクトリを削除して別のインストーラを起動した場合だけなので、そのとき黙って並行実行しない
842
+ # ために破壊的操作の直前で検出する。
843
+ _hub_python_lock_is_mine() {
844
+ local lock_dir="$1" want="$2" owner_token=""
845
+ [[ -n "$want" && -f "$lock_dir/owner" ]] || return 1
846
+ read -r owner_token < "$lock_dir/owner" || true
847
+ [[ "$owner_token" == "$want" ]]
848
+ }
849
+
850
+ _hub_python_wrapper_verified() {
851
+ # ⚠️ realpath(sys.executable) で比較しないこと。venv の bin/python3 は土台 interpreter への
852
+ # symlink なので、realpath は venv でも素の土台でも同じ値に解決される(実測)。それでは
853
+ # 「site-packages を持たない素の uv interpreter を指す wrapper」を成功扱いにしてしまう。
854
+ # 識別に使えるのは sys.prefix(venv 自身のパス)。
855
+ local expected_prefix actual_prefix spec
856
+ expected_prefix="$(cd "$HUB_VENV_DIR" 2>/dev/null && pwd -P)" || expected_prefix=""
857
+ actual_prefix="$(hub-python3 -c 'import os, sys; print(os.path.realpath(sys.prefix))' 2>/dev/null)" || actual_prefix=""
858
+ if [[ -z "$expected_prefix" || "$actual_prefix" != "$expected_prefix" ]]; then
859
+ color_err "hub-python3 が Hub Python venv を指していません (expected prefix: ${expected_prefix:-unknown}, actual: ${actual_prefix:-unknown})"
860
+ return 1
861
+ fi
862
+ # 版一致だけでは wheel の破損を見逃すため、wrapper 経由で実際に import できることまで確認する。
863
+ # ⚠️ この import 検査は `_hub_venv_usable` の import 検査と同じ interpreter を通るため重複しており、
864
+ # 独立した回帰テストは無い(wrapper 側だけを失敗させる状態を作れない)。担保は上の prefix 比較で、
865
+ # ここは公開後の wrapper 経路を一度通す念のための確認だと理解して読むこと。
866
+ for spec in "${HUB_PYTHON_IMPORTS[@]}"; do
867
+ if ! hub-python3 -c 'import importlib, sys; importlib.import_module(sys.argv[1])' "$spec" >/dev/null 2>&1; then
868
+ color_err "hub-python3 経由で $spec を import できません"
869
+ return 1
870
+ fi
871
+ done
872
+ color_ok "hub-python3 が Hub Python venv を指すことを確認"
873
+ }
874
+
875
+ _finalize_hub_python() (
876
+ # 退避先は lock token ごとの実行固有名にし、他実行の退避物と削除対象を分離する。
877
+ local new_dir="$1" old_dir="" leftover="" found_leftover=0
878
+ local lock_dir="${HUB_VENV_DIR}.lock" lock_token="" owner_token=""
879
+ trap 'rm -rf "$new_dir"; owner_token=""; if [[ -f "$lock_dir/owner" ]]; then read -r owner_token < "$lock_dir/owner" || true; fi; if [[ -n "$lock_token" && "$owner_token" == "$lock_token" ]]; then rm -rf "$lock_dir"; fi' EXIT
880
+ trap 'exit 130' INT
881
+ trap 'exit 143' TERM
882
+ _acquire_hub_python_lock "$lock_dir" || exit 1
883
+ lock_token="$HUB_PYTHON_LOCK_TOKEN"
884
+ old_dir="${HUB_VENV_DIR}.old.${lock_token}"
885
+ if _hub_venv_usable; then
886
+ color_ok "Hub Python venv は利用可能 ($HUB_VENV_DIR)"
887
+ elif [[ -n "$new_dir" ]]; then
888
+ if ! _hub_python_lock_is_mine "$lock_dir" "$lock_token"; then
889
+ color_err "Hub Python lock を他のインストーラに引き継がれたため差し替えを中止しました: $lock_dir"
890
+ exit 1
891
+ fi
892
+ # 残骸を別名で溜めず、stale lock と同様に手動復旧へ統一する。
893
+ # ⚠️ ここへ来るのは公開先が使えないと判定した場合なので、残骸は「直前まで使えていた venv」で
894
+ # ある可能性が高い。削除だけを案内せず、戻す選択肢を先に示す。
895
+ for leftover in "${HUB_VENV_DIR}".old*; do
896
+ if [[ -e "$leftover" || -L "$leftover" ]]; then
897
+ color_err "退避先が既に存在します: $leftover"
898
+ found_leftover=1
899
+ fi
900
+ done
901
+ if (( found_leftover == 1 )); then
902
+ color_err "前回の中断が残っている可能性があります。直前まで使えていた venv かもしれません。中身を確認して $HUB_VENV_DIR へ手で入れ替えるか、不要なら削除してから再実行してください"
903
+ exit 1
904
+ fi
905
+ _replace_hub_venv "$new_dir" "$old_dir" || exit 1
906
+ color_ok "Hub Python venv を構築 ($HUB_VENV_DIR)"
907
+ else
908
+ color_err "Hub Python venv の再構築結果がありません"
909
+ exit 1
910
+ fi
911
+ if ! _hub_python_lock_is_mine "$lock_dir" "$lock_token"; then
912
+ color_err "Hub Python lock を他のインストーラに引き継がれたため wrapper の公開を中止しました: $lock_dir"
913
+ exit 1
914
+ fi
915
+ _publish_hub_python_wrapper || exit 1
916
+ if ! _hub_python_wrapper_verified; then
917
+ if [[ -e "$old_dir" || -L "$old_dir" ]]; then
918
+ color_err "公開後の検証に失敗しました。公開先 $HUB_VENV_DIR は変更していません。直前まで使えていた venv は $old_dir にあります。中身を確認して手で入れ替えるか、$old_dir を削除してから再実行してください"
919
+ else
920
+ color_err "公開後の検証に失敗しました。公開先 $HUB_VENV_DIR は変更していません。手で確認してください"
921
+ fi
922
+ exit 1
923
+ fi
924
+ # 公開後の最終検証に成功してから、lock を保持したまま実行固有名の退避物を削除する。
925
+ # 差し替えを行わなかった実行ではこのパスは存在せず、他実行が残した退避物は削除しない。
926
+ if ! rm -rf "$old_dir" 2>/dev/null; then
927
+ color_warn "元の venv を削除できませんでした: $old_dir。中身を確認して手動で削除してください"
928
+ fi
929
+ )
930
+
931
+ ensure_hub_python() {
932
+ local local_bin="$HOME/.local/bin" uv_path
933
+ export PATH="$PATH:$local_bin"
934
+ hash -r 2>/dev/null || true
935
+ if present uv; then
936
+ uv_path="$(command -v uv)"
937
+ uv_path="$(cd "$(dirname "$uv_path")" && pwd -P)/$(basename "$uv_path")"
938
+ if ! uv --version >/dev/null 2>&1 || ! uv venv --help >/dev/null 2>&1 \
939
+ || ! uv pip --help >/dev/null 2>&1 || ! uv python --help >/dev/null 2>&1; then
940
+ color_err "発見した uv を利用できません: $uv_path(上書きせず停止します)"
941
+ exit 1
942
+ fi
943
+ color_ok "uv already installed ($uv_path)"
944
+ else
945
+ _install_hub_uv || exit 1
946
+ color_ok "uv $UV_VERSION を $local_bin に導入"
947
+ fi
948
+ persist_profile_snippet 'export PATH="$PATH:$HOME/.local/bin"' "~/.local/bin PATH" \
949
+ "# >>> hub-agent: local-bin PATH (Hub Python) >>>" "# <<< hub-agent: local-bin PATH <<<" \
950
+ "$HOME/.zprofile" "$(bash_login_profile)" || exit 1
951
+
952
+ retry 3 uv python install "$HUB_PYTHON_MINOR" || exit 1
953
+ if _hub_venv_usable; then
954
+ _finalize_hub_python "" || exit 1
955
+ else
956
+ # ⚠️ 構築成功から finalize の trap 設置までの中断で未公開の .new が残らないよう、
957
+ # 構築と finalize を同じ cleanup スコープに入れる。差し替え成功後は .new のパスが
958
+ # 消えているので、この trap の rm は no-op になる。
959
+ (
960
+ new_dir="${HUB_VENV_DIR}.new.$$"
961
+ trap 'rm -rf "$new_dir"' EXIT
962
+ trap 'exit 130' INT
963
+ trap 'exit 143' TERM
964
+ _build_hub_venv "$new_dir" || exit 1
965
+ _finalize_hub_python "$new_dir" || exit 1
966
+ ) || exit 1
967
+ fi
968
+ # 公開後の検証成功後にだけ、Hub Python を指す profile を更新する。
969
+ export HUB_AI_PYTHON="$HUB_VENV_DIR/bin/python3"
970
+ persist_profile_snippet "export HUB_AI_PYTHON=\"$HUB_VENV_DIR/bin/python3\"" "HUB_AI_PYTHON" \
971
+ "# >>> hub-agent: HUB_AI_PYTHON >>>" "# <<< hub-agent: HUB_AI_PYTHON <<<" \
972
+ "$HOME/.zprofile" "$(bash_login_profile)" || exit 1
973
+ }
974
+
624
975
  # timeout を保証する(補助コマンド。AI セッションの Bash やスクリプトが使うが、
625
976
  # hub-agent の常駐動作には必須ではないため、導入失敗は警告して継続する。
626
977
  # 必須ランタイムの python3 とは契約を分けている)。
@@ -1042,7 +1393,7 @@ EOF
1042
1393
 
1043
1394
  main() {
1044
1395
  color_step "hub-agent ワンライナーセットアップを開始"
1045
- STEP_TOTAL=15
1396
+ STEP_TOTAL=16
1046
1397
  STEP_NUM=0
1047
1398
 
1048
1399
  step_header "Homebrew"
@@ -1055,6 +1406,8 @@ main() {
1055
1406
  ensure_build_tools
1056
1407
  step_header "Python 3"
1057
1408
  ensure_python3
1409
+ step_header "Hub Python (uv / shared venv)"
1410
+ ensure_hub_python
1058
1411
  step_header "GNU coreutils (timeout)"
1059
1412
  ensure_timeout
1060
1413
  step_header "npm global prefix"
@@ -0,0 +1,89 @@
1
+ import { createEventMasker, MASK_ENABLED, redactSecretsValue } from "./tool-result-mask.mjs"
2
+
3
+ /** Also cover errors, ready metadata and permission copies outside SDK _emit.
4
+ * Recheck the complete envelope deliberately: ready/history can combine already
5
+ * masked events with newly added metadata. A type-based bypass would trust those
6
+ * unrelated fields too. Do not trade this boundary for a size-based raw fallback. */
7
+ export function installClaudeDisplayMask(client) {
8
+ const send = client.send.bind(client)
9
+ client.send = (payload, ...args) => {
10
+ if (!MASK_ENABLED || !payload?.type?.startsWith("claude.")) return send(payload, ...args)
11
+ let masked
12
+ try {
13
+ masked = redactSecretsValue(payload)
14
+ } catch {
15
+ masked = {
16
+ type: "claude.error",
17
+ stream_id: payload.stream_id,
18
+ session_id: payload.session_id,
19
+ error: "[本文を安全にマスクできないため表示を省略しました]",
20
+ }
21
+ }
22
+ return send(masked, ...args)
23
+ }
24
+ return client
25
+ }
26
+
27
+ /**
28
+ * A partial string cannot be redacted independently: a key or a quoted value may
29
+ * straddle notifications. Keep lifecycle notifications, but publish text,
30
+ * thinking and tool arguments only with the complete assistant message. The
31
+ * SDK/model input and the original event are never modified here.
32
+ */
33
+ export function createClaudeDisplayMasker(initialToolNames) {
34
+ const mask = createEventMasker(initialToolNames)
35
+ return (event) => {
36
+ if (!MASK_ENABLED) return event
37
+ if (event?.type === "stream_event") {
38
+ const partial = event.event
39
+ if (partial?.type === "content_block_delta") {
40
+ return null
41
+ }
42
+ if (partial?.type === "content_block_start") {
43
+ const block = partial.content_block
44
+ return mask({
45
+ ...event,
46
+ event: {
47
+ type: partial.type,
48
+ index: partial.index,
49
+ content_block: {
50
+ type: block?.type,
51
+ ...(block?.id ? { id: block.id } : {}),
52
+ ...(block?.name ? { name: block.name } : {}),
53
+ },
54
+ },
55
+ })
56
+ }
57
+ }
58
+ return mask(event)
59
+ }
60
+ }
61
+
62
+ /** Failure must not disclose either the source payload or an exception message. */
63
+ export function claudeMaskFailure(event) {
64
+ const notice = "[本文を安全にマスクできないため表示を省略しました]"
65
+ if (event?.type === "result") {
66
+ return {
67
+ type: "result",
68
+ subtype: event.subtype,
69
+ is_error: event.is_error,
70
+ session_id: event.session_id,
71
+ result: notice,
72
+ }
73
+ }
74
+ if (event?.type === "system" && event.subtype === "session_state_changed") {
75
+ return { type: "system", subtype: event.subtype, state: event.state }
76
+ }
77
+ if (event?.type === "assistant" || event?.type === "user") {
78
+ // Keep a renderable completed message and numeric accounting only. Do not
79
+ // copy arbitrary usage/provider metadata from the failed source envelope.
80
+ const usage = Object.fromEntries([
81
+ "input_tokens", "output_tokens", "cache_creation_input_tokens", "cache_read_input_tokens",
82
+ ].filter(key => typeof event.message?.usage?.[key] === "number" && Number.isFinite(event.message.usage[key]))
83
+ .map(key => [key, event.message.usage[key]]))
84
+ return { type: event.type, message: { role: event.type,
85
+ content: [{ type: "text", text: notice }], ...(Object.keys(usage).length ? { usage } : {}),
86
+ } }
87
+ }
88
+ return { type: "system", subtype: "mask_error", message: notice }
89
+ }
@@ -27,9 +27,10 @@ import { jsonlPath } from "./claude-history.mjs"
27
27
  import { watchSessionFile } from "./claude-history-watch.mjs"
28
28
  import {
29
29
  MASK_ENABLED,
30
- createEventMasker,
31
30
  maskJsonlFileAtRest,
32
31
  } from "./tool-result-mask.mjs"
32
+ import { createClaudeDisplayMasker, claudeMaskFailure } from "./claude-display-mask.mjs"
33
+ import { copyPermissionInput, preparePermissionInput, restorePermissionInput, PERMISSION_MASK_DENIAL } from "./permission-display-mask.mjs"
33
34
  import { getOrCreateSiblingToken } from "./sibling-token.mjs"
34
35
  import { recordInjectSourcePersistent } from "./inject-source-store.mjs"
35
36
  import { readPositiveIntEnv, sanitizePositiveInt } from "./positive-int-env.mjs"
@@ -693,7 +694,7 @@ export class ClaudeStreamSession {
693
694
  /** マスク (2026-06-23): ライブ stream / watch 用のステートフル masker。tool_use_id→name を
694
695
  * 蓄積し、tool_result の秘匿情報 / 非公開スキルプロンプト本文を表示・配信前に伏せる。
695
696
  * モデル消費後のコピーに作用するためスキル実行は壊れない。 */
696
- this._eventMasker = createEventMasker()
697
+ this._eventMasker = createClaudeDisplayMasker()
697
698
  /** オンディスク jsonl をマスク済みか (close 時の二重書き換えを防ぐ冪等ガード)。 */
698
699
  this._jsonlMasked = false
699
700
 
@@ -702,7 +703,7 @@ export class ClaudeStreamSession {
702
703
  }
703
704
 
704
705
  /** マスク済みイベントを onEvent へ流す共通経路。秘匿情報 / 非公開スキルプロンプト本文を
705
- * 表示・WS 配信前に伏せる。マスク失敗時は素のイベントを流す (表示が止まるより素通し優先)。 */
706
+ * 表示・WS 配信前に伏せる。マスク失敗時も元の本文へ戻さない。 */
706
707
  /** 連携カード種別 (Phase 2): 注入本文と source を FIFO に控える。sendToCwd/sendToStream が
707
708
  * session.sendMessage() を呼ぶ直前に記録し、**drain 昇格時に _emitQueueState が started 本文
708
709
  * 一致で消費**して startedSources として frontend へ載せる。
@@ -846,15 +847,15 @@ export class ClaudeStreamSession {
846
847
  if (MASK_ENABLED) {
847
848
  try {
848
849
  out = this._eventMasker(event)
849
- } catch (err) {
850
+ } catch {
850
851
  this.logger?.warn(
851
- { err: err?.message, stream_id: this.stream_id },
852
+ { stream_id: this.stream_id },
852
853
  "event mask failed",
853
854
  )
854
- out = event
855
+ out = claudeMaskFailure(event)
855
856
  }
856
857
  }
857
- this.onEvent(out)
858
+ if (out != null) this.onEvent(out)
858
859
  }
859
860
 
860
861
  /** セッション終了時にオンディスク jsonl をマスク書き換えする (冪等)。Claude CLI が当該
@@ -947,18 +948,25 @@ export class ClaudeStreamSession {
947
948
  /** SDK options.canUseTool: tool 起動を許可するかを browser に問い合わせる。 */
948
949
  async _canUseTool(toolName, input) {
949
950
  if (!this.onPermission) return { behavior: "allow", updatedInput: input }
951
+ let displayState
952
+ try {
953
+ displayState = MASK_ENABLED ? preparePermissionInput(input, { toolName }) : null
954
+ } catch {
955
+ return { behavior: "deny", message: PERMISSION_MASK_DENIAL }
956
+ }
950
957
  const request_id = randomUUID()
951
958
  return await new Promise((resolve) => {
952
959
  this._permissionResolvers.set(request_id, {
953
960
  resolve,
954
961
  tool_name: toolName,
955
962
  input,
963
+ displayState,
956
964
  })
957
965
  try {
958
- this.onPermission({ tool_name: toolName, input, request_id })
966
+ this.onPermission({ tool_name: toolName, input: displayState ? copyPermissionInput(displayState.displayInput) : input, request_id })
959
967
  } catch (err) {
960
968
  this.logger?.warn(
961
- { err: err.message, stream_id: this.stream_id, tool: toolName },
969
+ { stream_id: this.stream_id },
962
970
  "onPermission callback threw",
963
971
  )
964
972
  this._permissionResolvers.delete(request_id)
@@ -1014,6 +1022,16 @@ export class ClaudeStreamSession {
1014
1022
  this._permissionResolvers.delete(request_id)
1015
1023
  // B11: 現 primary 端末からの permission 応答を活性として記録する。
1016
1024
  this.touch(this.stream_id)
1025
+ if (!["allow", "deny"].includes(decision?.behavior)) {
1026
+ decision = { behavior: "deny", message: PERMISSION_MASK_DENIAL }
1027
+ }
1028
+ if (MASK_ENABLED && decision?.behavior === "allow" && r.displayState) {
1029
+ try {
1030
+ decision = { ...decision, updatedInput: restorePermissionInput(r.displayState.originalInput, r.displayState.displayInput, decision.updatedInput, { toolName: r.tool_name }) }
1031
+ } catch {
1032
+ decision = { behavior: "deny", message: PERMISSION_MASK_DENIAL }
1033
+ }
1034
+ }
1017
1035
  r.resolve(decision)
1018
1036
  return true
1019
1037
  }
@@ -1062,12 +1080,19 @@ export class ClaudeStreamSession {
1062
1080
  if (!this.onPermission || this._permissionResolvers.size === 0) return
1063
1081
  for (const [request_id, r] of this._permissionResolvers) {
1064
1082
  try {
1065
- this.onPermission({ tool_name: r.tool_name, input: r.input, request_id })
1083
+ this.onPermission({ tool_name: r.tool_name, input: r.displayState ? copyPermissionInput(r.displayState.displayInput) : r.input, request_id })
1066
1084
  } catch (err) {
1067
1085
  this.logger?.warn(
1068
- { err: err.message, stream_id: this.stream_id, request_id },
1086
+ { stream_id: this.stream_id, request_id },
1069
1087
  "replay permission failed",
1070
1088
  )
1089
+ this._permissionResolvers.delete(request_id)
1090
+ r.resolve({ behavior: "deny", message: PERMISSION_MASK_DENIAL })
1091
+ try {
1092
+ this.onPermissionCancel?.({ request_ids: [request_id], reason: "replay failed" })
1093
+ } catch {
1094
+ this.logger?.warn({ stream_id: this.stream_id, request_id }, "permission cancel callback failed")
1095
+ }
1071
1096
  }
1072
1097
  }
1073
1098
  }