hanoman 0.1.55 → 0.1.57

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.
@@ -100,7 +100,7 @@ perlu mengambilnya, cukup rujuk tabel di bawah:
100
100
  |---|---|---|
101
101
  | `projects` | `/api/projects*` | project, branch, binding, Help Center |
102
102
  | `backlog` | `/api/specs*` | spec/backlog, dokumen, review diff, integrate |
103
- | `sessions` | `/api/terminal*` (+ WS terminal) | jalankan sesi agen/shell, kirim input — **high-risk (RCE)** |
103
+ | `sessions` | `/api/terminal*` (+ WS terminal) | jalankan sesi agen/shell, kirim input, baca & jawab dialog sesi (SPEC-899) — **high-risk (RCE)** |
104
104
  | `docs` | `/api/prds*`, `/api/projects/:id/{docs,prds}*` | dokumen SoT project & PRD |
105
105
  | `ide` | `/api/projects/:id/{tree,file,file-diff,working-status,graph,commit,git,status,stashes,remotes,compare,archive,pr-url}*` | tree/file working tree, operasi git |
106
106
  | `vps` | `/api/vps*` | kelola VPS, audit, harden, konsol — **high-risk (remote exec)** |
@@ -149,6 +149,11 @@ token selalu **403**, apa pun capability-nya, dan tak ada capability yang bisa m
149
149
  tak ada capability yang bisa berarti apa pun di sana. Backlog & tiket yang sama tersedia lewat
150
150
  `/api/specs` dan `/api/tickets` dengan capability `backlog`/`support` (ADR-0110)
151
151
  - `/api/client-accounts*` — membuat & mencabut akun klien beserta password awalnya (ADR-0110)
152
+ - `/api/session-events` — event pertanyaan sesi untuk hanoman-lead (SPEC-909/ADR-0146). Pemanggilnya
153
+ **hook sesi**, bukan manusia dan bukan agen: kredensialnya token turunan per sesi, dan agen yang
154
+ bisa memalsukan "sesi X bertanya Y" bisa menggerakkan lead atas nama sesi mana pun — itu peniruan
155
+ identitas, bukan capability. Agent token di sini menerima **401** (token sesi tak cocok), bukan
156
+ 403: gate cookie mem-bypass path-nya dan route menegakkan tokennya sendiri
152
157
  - `POST /api/update/apply` dan tulis lain di bawah prefix status (`/api/limits`, `/api/update`,
153
158
  `/api/events`, `/api/fs`, `/api/health`) — **baca**-nya terbuka untuk token mana pun, **tulis**-nya
154
159
  cookie-only
@@ -466,6 +471,15 @@ dan seluruh `/api/vps*` (remote exec) **tidak ikut**, begitu pula merge/rebase (
466
471
  penghapusan backlog, dan perubahan `stage`. Batasan ini ada di katalog toolnya, bukan di token:
467
472
  token yang punya `sessions:write` sekalipun tak akan menemukan tool untuk memakainya. Lihat §8.
468
473
 
474
+ **Menjawab dialog sesi** (`GET /api/terminal/sessions/:id/dialog`,
475
+ `POST /api/terminal/sessions/:id/dialog/answer`, dan sejak SPEC-909 · ADR-0146 juga
476
+ `POST /api/terminal/sessions/:id/dialog/takeover` — merebut sesi dari hanoman-lead) sengaja **tak
477
+ punya tool** — dan alasannya lebih tajam dari sekadar "mengeksekusi": endpoint itu menjawab pertanyaan
478
+ yang **secara desain ditujukan kepada manusia** (`AskUserQuestion`). Agen yang bisa memanggilnya
479
+ bisa menjawab pertanyaannya sendiri, dan gerbang "manusia terakhir yang memutuskan" runtuh lewat
480
+ pintu belakang. Capability-nya tetap ada (`sessions:read`/`sessions:write`) karena peta itu berlaku
481
+ untuk seluruh permukaan HTTP, bukan hanya untuk yang muncul di MCP.
482
+
469
483
  **Lampiran backlog** (`/api/specs/:id/attachments*`, SPEC-843 · ADR-0124) juga sengaja **tak punya
470
484
  tool**: berkasnya lahir dari disk manusia, bukan dari model, dan tool MCP berbentuk JSON sehingga
471
485
  byte biner tak punya representasi di sana. REST-nya tetap terjangkau agent token
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hanoman",
3
- "version": "0.1.55",
3
+ "version": "0.1.57",
4
4
  "description": "Orchestrator + dashboard workflow docs-driven untuk sesi Claude Code / Codex",
5
5
  "type": "module",
6
6
  "repository": {
@@ -23,6 +23,7 @@
23
23
  "web",
24
24
  "prisma",
25
25
  "docs",
26
+ "scripts",
26
27
  "README.md",
27
28
  "LICENSE"
28
29
  ],
@@ -0,0 +1,9 @@
1
+ -- SPEC-880 · ADR-0135 · penanda "ditangani oleh": daftar hanoman client pemegang project.
2
+ --
3
+ -- Ditulis tangan (bukan `migrate dev`): worktree tetangga membuat `migrate dev` me-reset DB saat
4
+ -- ada drift. ADITIF murni — satu kolom NULLABLE tanpa default, tak ada tabel diredefinisi.
5
+ --
6
+ -- TANPA backfill, sengaja: sebelum spec ini penandanya memang tak ada di mana pun, dan menebaknya
7
+ -- dari SessionResult.deviceId akan mencampur "jejak eksekusi" dengan "pernyataan kepemilikan".
8
+ -- NULL = "belum ditetapkan", dan itu jawaban yang jujur.
9
+ ALTER TABLE "Project" ADD COLUMN "handledBy" JSONB;
@@ -0,0 +1,10 @@
1
+ -- SPEC-883 · ADR-0137 · penandaan komponen hasil probe + profil provisioning.
2
+ --
3
+ -- Ditulis tangan (bukan `migrate dev`): worktree tetangga membuat `migrate dev` me-reset DB saat
4
+ -- ada drift. ADITIF murni — tiga kolom NULLABLE tanpa default, tak ada tabel diredefinisi.
5
+ --
6
+ -- TANPA backfill, sengaja: sebelum spec ini status komponen memang tak pernah diukur di mana pun.
7
+ -- NULL = "belum diperiksa", dan itu jawaban yang jujur — bukan "tak ada komponen".
8
+ ALTER TABLE "Vps" ADD COLUMN "components" JSONB;
9
+ ALTER TABLE "Vps" ADD COLUMN "componentsCheckedAt" DATETIME;
10
+ ALTER TABLE "Vps" ADD COLUMN "provisionProfile" TEXT;
@@ -0,0 +1,3 @@
1
+ -- SPEC-885 · ADR-0138 · aditif, nullable, tanpa default: nol backfill dan aman untuk hub
2
+ -- produksi yang sedang berjalan (hub = live, migrate additif saja).
3
+ ALTER TABLE "Vps" ADD COLUMN "lastPublishedAt" DATETIME;
@@ -24,6 +24,10 @@ model Project {
24
24
  // SPEC-486 · ADR-0103 · kebijakan auto-merge saat backlog item selesai (LOCAL — tak masuk
25
25
  // FIELDS sync, cermin repoDir/schedulerOptIn). null = tanpa auto-merge (default, nol backfill).
26
26
  autoMerge Json?
27
+ // SPEC-880 · ADR-0135 · daftar hanoman client yang memegang project ini: [{deviceId,name}].
28
+ // BERBEDA dari repoDir/schedulerOptIn/leadOptIn/autoMerge di atas — kolom ini MASUK FIELDS sync,
29
+ // karena justru menyeberangnya nilai ini yang jadi inti SPEC-880. null = belum ditetapkan.
30
+ handledBy Json?
27
31
  specs Spec[]
28
32
  tickets Ticket[]
29
33
  customAgents CustomAgent[] // SPEC-450 · ADR-0094 · custom agent milik project ini
@@ -287,6 +291,17 @@ model Vps {
287
291
  lastAuditAt DateTime?
288
292
  audit Json? // VpsCheck[] — [{ check, status, detail }]
289
293
  hardened Boolean @default(false) // derived: semua check kritis pass
294
+ // SPEC-883 · ADR-0137 · hasil MODE=probe provision.sh: { [ComponentId]: { status, detail } }.
295
+ // DITULIS HANYA dari probe, tak pernah dari niat "kami barusan memasang X" (SPEC-487).
296
+ // LOCAL-ONLY: ketiganya sengaja di luar FIELDS.vps (pelajaran SPEC-880).
297
+ components Json?
298
+ componentsCheckedAt DateTime?
299
+ provisionProfile String? // "lab" | "production" — profil yang terakhir diterapkan
300
+ // SPEC-885 · ADR-0138 · kapan baris ini TERAKHIR diterbitkan ke change-feed. LOCAL-ONLY dengan
301
+ // alasan yang sama seperti tiga kolom di atas: ia properti mesin ini, bukan pernyataan bersama.
302
+ // Dipakai `runHealth` untuk denyut berjangka saat `health` tak berubah — tanpa itu `lastSeenAt`
303
+ // (yang IKUT menyeberang) akan mendarat basi selamanya di tiap client.
304
+ lastPublishedAt DateTime?
290
305
  version Int @default(0) // SPEC-213 · version-stamp sync (ADR-0045)
291
306
  updatedAt DateTime @updatedAt
292
307
  snapshots VpsAuditSnapshot[] // SPEC-220 · riwayat audit kepatuhan
@@ -0,0 +1,12 @@
1
+ # SPEC-883 · ADR-0137 · image sesi agen untuk profil production (ADR-0117).
2
+ # Kredensial TIDAK pernah masuk image: sandboxArgv me-mount HANOMAN_AGENT_CREDENTIAL_DIR
3
+ # sebagai /agent-home:ro dan menyetel HOME=/agent-home.
4
+ FROM docker.io/library/node:22-bookworm-slim
5
+
6
+ # SPEC-909 · ADR-0146 · `curl` dipakai hook sesi untuk mengirim pertanyaan agen ke server.
7
+ # Tanpanya hook diam & `exit 0` — tak pernah memblokir agen, tapi juga tak pernah sampai.
8
+ RUN apt-get update && apt-get install -y --no-install-recommends \
9
+ git ca-certificates ripgrep curl && rm -rf /var/lib/apt/lists/*
10
+ RUN npm i -g @anthropic-ai/claude-code @openai/codex
11
+
12
+ WORKDIR /workspace
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env bash
2
+ # hanoman-audit · SPEC-164 — via `ssh <host> 'sudo -n bash -s' < audit.sh`.
3
+ # Output per baris: CHECK <nama> <pass|fail|warn> <detail…> — diparsing server/src/services/vps-audit.ts.
4
+ set -u
5
+ emit() { echo "CHECK $1 $2 ${3:-}"; }
6
+ emit_stack() { echo "STACK $1 $2 ${3:-}"; } # SPEC-221 · deteksi stack app-layer (advisory)
7
+
8
+ # Script sampai sini artinya ssh+bash hidup; yang diverifikasi tinggal root/sudo.
9
+ if [ "$(id -u)" = 0 ]; then emit sudo_ok pass root
10
+ else emit sudo_ok fail "bukan root — beri passwordless sudo pada user ini"; fi
11
+
12
+ # ponytail: path overridable HANYA untuk test (fixture os-release); default = /etc/os-release
13
+ . "${HANOMAN_OS_RELEASE:-/etc/os-release}" 2>/dev/null || true
14
+ FAM=""
15
+ case "${ID:-} ${ID_LIKE:-}" in
16
+ *debian*|*ubuntu*) FAM=deb ;;
17
+ *rhel*|*fedora*|*centos*|*rocky*|*alma*|*opencloudos*) FAM=rhel ;;
18
+ esac
19
+ if [ -n "$FAM" ]; then emit os_supported pass "${ID:-?} ${VERSION_ID:-?}"
20
+ else
21
+ emit os_supported fail "${ID:-unknown} — hanya keluarga debian/rhel/opencloudos"
22
+ exit 0 # check lain tak bermakna di distro asing
23
+ fi
24
+
25
+ # --- sshd: konfigurasi EFEKTIF (sshd -T), bukan berkas — drop-in & default ikut terbaca.
26
+ SSHD_T=$( (sshd -T 2>/dev/null || /usr/sbin/sshd -T 2>/dev/null) || true )
27
+ sshd_opt() { echo "$SSHD_T" | awk -v k="$1" '$1==k{print $2; exit}'; }
28
+ SSH_PORT=22
29
+ if [ -z "$SSHD_T" ]; then
30
+ # Dua sebab: sshd tak terpasang, atau kita bukan root. Keduanya = konfigurasi tak terbaca.
31
+ emit ssh_root_login fail "sshd -T tak terbaca (sshd tak terpasang / bukan root)"
32
+ emit ssh_password_auth fail "sshd -T tak terbaca (sshd tak terpasang / bukan root)"
33
+ else
34
+ v=$(sshd_opt permitrootlogin)
35
+ # `without-password` adalah bagaimana sshd -T menormalkan `prohibit-password` (alias lama):
36
+ # tanpa baris ini, VPS yang root-nya sudah key-only dilaporkan fail selamanya (ADR-0025 §5).
37
+ case "$v" in
38
+ no|prohibit-password|without-password) emit ssh_root_login pass "$v" ;;
39
+ *) emit ssh_root_login fail "PermitRootLogin ${v:-default}" ;;
40
+ esac
41
+ v=$(sshd_opt passwordauthentication)
42
+ if [ "$v" = "no" ]; then emit ssh_password_auth pass
43
+ else emit ssh_password_auth fail "PasswordAuthentication ${v:-default}"; fi
44
+ p=$(sshd_opt port); SSH_PORT=${p:-22}
45
+ fi
46
+
47
+ # --- firewall
48
+ if [ "$FAM" = deb ]; then
49
+ if ufw status 2>/dev/null | grep -q "Status: active"; then emit firewall pass "ufw active"
50
+ else emit firewall fail "ufw tidak aktif"; fi
51
+ else
52
+ if firewall-cmd --state 2>/dev/null | grep -q running; then emit firewall pass "firewalld running"
53
+ else emit firewall fail "firewalld tidak jalan"; fi
54
+ fi
55
+
56
+ # --- fail2ban
57
+ if systemctl is-active --quiet fail2ban 2>/dev/null; then emit fail2ban pass aktif
58
+ else emit fail2ban fail "service tidak aktif"; fi
59
+
60
+ # --- auto security update
61
+ if [ "$FAM" = deb ]; then
62
+ if grep -qs '"1"' /etc/apt/apt.conf.d/20auto-upgrades; then emit auto_updates pass "unattended-upgrades"
63
+ else emit auto_updates fail "unattended-upgrades belum dikonfigurasi"; fi
64
+ else
65
+ if systemctl is-enabled --quiet dnf-automatic.timer 2>/dev/null; then emit auto_updates pass "dnf-automatic.timer"
66
+ else emit auto_updates fail "dnf-automatic.timer tidak aktif"; fi
67
+ fi
68
+
69
+ # --- warning-level
70
+ if timedatectl show -p NTP --value 2>/dev/null | grep -qi yes; then emit ntp pass aktif
71
+ else emit ntp warn "NTP tidak aktif — timedatectl set-ntp true"; fi
72
+
73
+ # Listener publik di luar {port SSH, 80, 443}. Loopback tak dihitung.
74
+ PORTS=$(ss -tlnH 2>/dev/null | awk '{print $4}' | grep -E '^(0\.0\.0\.0|\[::\]|\*):' \
75
+ | sed -E 's/.*:([0-9]+)$/\1/' | sort -un | grep -vE "^(${SSH_PORT}|80|443)$" \
76
+ | tr '\n' ',' | sed 's/,$//')
77
+ if [ -n "$PORTS" ]; then emit open_ports warn "port publik tak terdaftar: $PORTS"
78
+ else emit open_ports pass; fi
79
+
80
+ if [ "$FAM" = deb ]; then
81
+ N=$(apt-get -s upgrade 2>/dev/null | grep -c '^Inst.*[Ss]ecurity')
82
+ else
83
+ N=$(dnf -q updateinfo list security 2>/dev/null | grep -c .)
84
+ fi
85
+ if [ "${N:-0}" -gt 0 ] 2>/dev/null; then emit pending_updates warn "$N security update tertunda"
86
+ else emit pending_updates pass; fi
87
+
88
+ # =====================================================================================
89
+ # SPEC-220 · CHECK per itemId katalog (item ber-probe). itemId = kunci kanonik katalog
90
+ # (server/src/vps/catalog). Diparsing mapToCatalog() → scoring. Legacy CHECK di atas tetap.
91
+ # =====================================================================================
92
+
93
+ # --- firewall aktif → fw-b1 (reuse deteksi FAM di atas)
94
+ if [ "$FAM" = deb ]; then
95
+ ufw status 2>/dev/null | grep -q "Status: active" && emit fw-b1 pass || emit fw-b1 fail "ufw nonaktif"
96
+ else
97
+ firewall-cmd --state 2>/dev/null | grep -q running && emit fw-b1 pass || emit fw-b1 fail "firewalld mati"
98
+ fi
99
+ # --- block ping/ICMP → fw-b3 (sysctl icmp_echo_ignore_all)
100
+ [ "$(sysctl -n net.ipv4.icmp_echo_ignore_all 2>/dev/null)" = 1 ] && emit fw-b3 pass || emit fw-b3 fail "ICMP echo tidak diblok"
101
+ # --- fail2ban → ids-b1
102
+ systemctl is-active --quiet fail2ban 2>/dev/null && emit ids-b1 pass || emit ids-b1 fail "fail2ban nonaktif"
103
+ # --- auto security update → sys-b1
104
+ if [ "$FAM" = deb ]; then
105
+ grep -qs '"1"' /etc/apt/apt.conf.d/20auto-upgrades && emit sys-b1 pass || emit sys-b1 fail "unattended-upgrades belum aktif"
106
+ else
107
+ systemctl is-enabled --quiet dnf-automatic.timer 2>/dev/null && emit sys-b1 pass || emit sys-b1 fail "dnf-automatic.timer nonaktif"
108
+ fi
109
+
110
+ # --- sshd (konfigurasi efektif via $SSHD_T / sshd_opt yang sudah didefinisikan di atas)
111
+ if [ -n "$SSHD_T" ]; then
112
+ # ssh-b1 ganti port default
113
+ [ "$SSH_PORT" != 22 ] && emit ssh-b1 pass "port $SSH_PORT" || emit ssh-b1 fail "masih port 22"
114
+ # ssh-b2 disable root login
115
+ case "$(sshd_opt permitrootlogin)" in no|prohibit-password|without-password) emit ssh-b2 pass ;; *) emit ssh-b2 fail "PermitRootLogin $(sshd_opt permitrootlogin)" ;; esac
116
+ # ssh-b3 disable password auth
117
+ [ "$(sshd_opt passwordauthentication)" = no ] && emit ssh-b3 pass || emit ssh-b3 fail "PasswordAuthentication $(sshd_opt passwordauthentication)"
118
+ # ssh-i2 idle timeout (ClientAliveInterval > 0)
119
+ cai=$(sshd_opt clientaliveinterval); [ "${cai:-0}" -gt 0 ] 2>/dev/null && emit ssh-i2 pass "ClientAliveInterval $cai" || emit ssh-i2 fail "ClientAliveInterval 0"
120
+ # ssh-i3 X11 & TCP forwarding off
121
+ { [ "$(sshd_opt x11forwarding)" = no ] && [ "$(sshd_opt allowtcpforwarding)" = no ]; } && emit ssh-i3 pass || emit ssh-i3 fail "X11/TCP forwarding aktif"
122
+ # ssh-i5 MaxAuthTries <= 4
123
+ mat=$(sshd_opt maxauthtries); [ "${mat:-6}" -le 4 ] 2>/dev/null && emit ssh-i5 pass "MaxAuthTries $mat" || emit ssh-i5 fail "MaxAuthTries ${mat:-default}"
124
+ else
125
+ for k in ssh-b1 ssh-b2 ssh-b3 ssh-i2 ssh-i3 ssh-i5; do emit "$k" fail "sshd -T tak terbaca"; done
126
+ fi
127
+
128
+ # --- kernel sysctl → ker-*
129
+ sctl() { sysctl -n "$1" 2>/dev/null; }
130
+ [ "$(sctl net.ipv4.ip_forward)" = 0 ] && emit ker-b1 pass || emit ker-b1 fail "ip_forward=$(sctl net.ipv4.ip_forward)"
131
+ [ "$(sctl net.ipv4.tcp_syncookies)" = 1 ] && emit ker-b2 pass || emit ker-b2 fail "tcp_syncookies off"
132
+ [ "$(sctl net.ipv4.conf.all.accept_source_route)" = 0 ] && emit ker-b3 pass || emit ker-b3 fail "source_route on"
133
+ [ "$(sctl net.ipv4.conf.all.accept_redirects)" = 0 ] && emit ker-b4 pass || emit ker-b4 fail "icmp_redirects on"
134
+ [ "$(sctl net.ipv4.conf.all.rp_filter)" = 1 ] && emit ker-b5 pass || emit ker-b5 fail "rp_filter off"
135
+ [ "$(sctl kernel.randomize_va_space)" = 2 ] && emit ker-i4 pass || emit ker-i4 fail "ASLR != 2"
136
+ [ "$(sctl fs.suid_dumpable)" = 0 ] && emit ker-i6 pass || emit ker-i6 fail "suid_dumpable != 0"
137
+
138
+ # =============== SPEC-221 · deteksi stack app-layer (advisory) ===============
139
+ # STACK <section> <present|absent> <detail>. `absent` BUKAN bukti pasti — layanan bisa jalan
140
+ # di Docker yang tak terdeteksi probe bare-metal; karena itu jadi SARAN N/A, bukan auto-exclude.
141
+ has_cmd() { command -v "$1" >/dev/null 2>&1; }
142
+ # aaPanel
143
+ { [ -d /www/server/panel ] || has_cmd bt; } && emit_stack aapanel present "aaPanel terdeteksi" \
144
+ || emit_stack aapanel absent "tak ada /www/server/panel"
145
+ # web server
146
+ if has_cmd nginx || has_cmd apache2 || has_cmd httpd \
147
+ || pgrep -x nginx >/dev/null 2>&1 || pgrep -x apache2 >/dev/null 2>&1 || pgrep -x httpd >/dev/null 2>&1; then
148
+ emit_stack webserver present "nginx/apache terdeteksi"
149
+ else emit_stack webserver absent "tak ada nginx/apache (cek Docker manual)"; fi
150
+ # database
151
+ if has_cmd mysql || has_cmd mariadb || has_cmd psql \
152
+ || pgrep -x mysqld >/dev/null 2>&1 || pgrep -x postgres >/dev/null 2>&1 \
153
+ || ss -tlnH 2>/dev/null | grep -qE ':(3306|5432)\b'; then
154
+ emit_stack database present "db terdeteksi"
155
+ else emit_stack database absent "tak ada mysql/postgres (cek Docker manual)"; fi
156
+ # ssl/tls
157
+ if has_cmd certbot || [ -d /etc/letsencrypt/live ]; then emit_stack ssl present "certbot/letsencrypt"
158
+ else emit_stack ssl absent "tak ada certbot"; fi
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env bash
2
+ # hanoman-harden · SPEC-164 — idempotent; dijalankan HANYA lewat tombol Harden,
3
+ # via `ssh <host> 'sudo -n env SSH_PORT=<port> SSH_USER=<user> bash -s' < harden.sh`.
4
+ # Output: STEP <nama> <ok|fail> <detail…>. TIDAK membuat user, TIDAK mengganti port SSH,
5
+ # TIDAK menyentuh service custom — itu jalur sesi Claude (SPEC-164 §5).
6
+ set -u
7
+ SSH_PORT="${SSH_PORT:-22}"
8
+ step() { echo "STEP $1 $2 ${3:-}"; }
9
+
10
+ [ "$(id -u)" = 0 ] || { step precheck fail "bukan root"; exit 1; }
11
+ # ponytail: path overridable HANYA untuk test (fixture os-release); default = /etc/os-release
12
+ . "${HANOMAN_OS_RELEASE:-/etc/os-release}" 2>/dev/null || true
13
+ case "${ID:-} ${ID_LIKE:-}" in
14
+ *debian*|*ubuntu*) FAM=deb ;;
15
+ *rhel*|*fedora*|*centos*|*rocky*|*alma*|*opencloudos*) FAM=rhel ;;
16
+ *) step precheck fail "distro ${ID:-unknown} tidak didukung"; exit 1 ;;
17
+ esac
18
+ step precheck ok "$FAM ssh_port=$SSH_PORT"
19
+
20
+ pkg() { if [ "$FAM" = deb ]; then DEBIAN_FRONTEND=noninteractive apt-get -qq install -y "$@" >/dev/null 2>&1
21
+ else dnf -q install -y "$@" >/dev/null 2>&1; fi; }
22
+ [ "$FAM" = deb ] && apt-get -qq update >/dev/null 2>&1
23
+
24
+ # 1 · firewall — allow port SSH DULU, baru enable (anti-lockout)
25
+ if [ "$FAM" = deb ]; then
26
+ pkg ufw
27
+ if ufw allow "$SSH_PORT/tcp" >/dev/null 2>&1 && ufw allow 80/tcp >/dev/null 2>&1 \
28
+ && ufw allow 443/tcp >/dev/null 2>&1 && ufw --force enable >/dev/null 2>&1; then
29
+ step firewall ok "ufw aktif, allow $SSH_PORT/80/443"
30
+ else step firewall fail "ufw gagal"; fi
31
+ else
32
+ pkg firewalld
33
+ if systemctl enable --now firewalld >/dev/null 2>&1 \
34
+ && firewall-cmd -q --permanent --add-port="$SSH_PORT/tcp" \
35
+ && firewall-cmd -q --permanent --add-service=http \
36
+ && firewall-cmd -q --permanent --add-service=https \
37
+ && firewall-cmd -q --reload; then
38
+ step firewall ok "firewalld aktif, allow $SSH_PORT/http/https"
39
+ else step firewall fail "firewalld gagal"; fi
40
+ fi
41
+
42
+ # 2 · fail2ban
43
+ # fail2ban di RHEL-family butuh repo tambahan: EPEL, kecuali OpenCloudOS yang
44
+ # memakai EPOL (epel-release tak ada di sana). Best-effort — gagal tak fatal.
45
+ if [ "$FAM" = rhel ]; then
46
+ if [ "${ID:-}" = opencloudos ]; then pkg epol-release; else pkg epel-release; fi
47
+ fi
48
+ if pkg fail2ban; then
49
+ mkdir -p /etc/fail2ban/jail.d
50
+ cat > /etc/fail2ban/jail.d/hanoman.conf <<'EOF'
51
+ [sshd]
52
+ enabled = true
53
+ # backend=systemd: baca journald, bukan /var/log/secure. Di RHEL/OpenCloudOS
54
+ # journald-only berkas itu belum ada → backend file default gagal konfigurasi
55
+ # jail sshd → service mati (SPEC-190). Deb sudah default systemd; aman lintas distro.
56
+ backend = systemd
57
+ maxretry = 3
58
+ bantime = 1h
59
+ findtime = 10m
60
+ EOF
61
+ if systemctl enable --now fail2ban >/dev/null 2>&1 && systemctl restart fail2ban >/dev/null 2>&1; then
62
+ step fail2ban ok
63
+ else step fail2ban fail "service gagal start"; fi
64
+ else step fail2ban fail "instalasi gagal"; fi
65
+
66
+ # 3 · auto security update
67
+ if [ "$FAM" = deb ]; then
68
+ if pkg unattended-upgrades; then
69
+ printf 'APT::Periodic::Update-Package-Lists "1";\nAPT::Periodic::Unattended-Upgrade "1";\n' \
70
+ > /etc/apt/apt.conf.d/20auto-upgrades
71
+ step auto_updates ok "unattended-upgrades"
72
+ else step auto_updates fail "instalasi gagal"; fi
73
+ else
74
+ if pkg dnf-automatic && systemctl enable --now dnf-automatic.timer >/dev/null 2>&1; then
75
+ step auto_updates ok "dnf-automatic.timer"
76
+ else step auto_updates fail; fi
77
+ fi
78
+
79
+ # 4 · sshd — drop-in + sshd -t WAJIB pass sebelum reload (anti-lockout).
80
+ # User terkonfigurasi = root → prohibit-password (key-only), bukan `no`:
81
+ # `no` memutus akses hanoman sendiri (ADR-0025 §5).
82
+ #
83
+ # Nama berawalan `01-`, bukan `99-`: sshd memakai nilai PERTAMA yang ditemukan, dan
84
+ # Include memuat drop-in berurutan nama. Image cloud Ubuntu memasang
85
+ # 50-cloud-init.conf berisi `PasswordAuthentication yes` — sebuah 99-hanoman.conf
86
+ # akan dibaca sesudahnya dan diabaikan diam-diam (diverifikasi dengan sshd -T).
87
+ ROOT_LOGIN=no
88
+ [ "${SSH_USER:-}" = root ] && ROOT_LOGIN=prohibit-password
89
+ DROPIN=/etc/ssh/sshd_config.d/01-hanoman.conf
90
+ mkdir -p /etc/ssh/sshd_config.d
91
+ grep -qE '^Include /etc/ssh/sshd_config.d' /etc/ssh/sshd_config 2>/dev/null \
92
+ || sed -i '1i Include /etc/ssh/sshd_config.d/*.conf' /etc/ssh/sshd_config
93
+ cat > "$DROPIN" <<EOF
94
+ PermitRootLogin $ROOT_LOGIN
95
+ PasswordAuthentication no
96
+ MaxAuthTries 3
97
+ EOF
98
+ if sshd -t 2>/dev/null || /usr/sbin/sshd -t 2>/dev/null; then
99
+ systemctl reload sshd 2>/dev/null || systemctl reload ssh 2>/dev/null
100
+ step ssh ok "PermitRootLogin $ROOT_LOGIN · PasswordAuthentication no"
101
+ else
102
+ rm -f "$DROPIN"
103
+ step ssh fail "sshd -t menolak konfigurasi — drop-in dibatalkan"
104
+ fi
105
+
106
+ # 5 · NTP
107
+ if timedatectl set-ntp true 2>/dev/null; then step ntp ok; else step ntp fail; fi
@@ -0,0 +1,270 @@
1
+ #!/usr/bin/env bash
2
+ # hanoman-provision · SPEC-883 · ADR-0137
3
+ #
4
+ # Marker "hanoman-provision" di baris atas WAJIB: server mengirim skrip ini lewat stdin dan
5
+ # fixture test (server/test/fixtures/fake-ssh.sh) mencabang atas isi stdin memakai marker itu.
6
+ #
7
+ # Dua mode, dipilih lewat env MODE:
8
+ # MODE=probe → COMP <id> <ok|partial|absent> <detail>
9
+ # MODE=apply ITEMS=a,b PROFILE=lab DRY_RUN=1 → STEP <id> <would|ok|fail|skip> <detail>
10
+ #
11
+ # NOL asumsi tentang SSH: skrip ini dijalankan lewat `ssh … bash -s` DAN secara lokal oleh
12
+ # `hanoman provision`. Jangan pernah membaca $SSH_*, /dev/tty, atau berasumsi ada tty.
13
+ # NOL rahasia: tak pernah membaca, menulis, atau meminta kredensial agen.
14
+ set -uo pipefail # sengaja TANPA -e: satu komponen gagal tak boleh membunuh laporan sisanya
15
+
16
+ MODE="${MODE:-probe}"
17
+ PROFILE="${PROFILE:-lab}"
18
+ DOMAIN="${DOMAIN:-}"
19
+ DRY_RUN="${DRY_RUN:-}"
20
+ HANOMAN_PORT="${HANOMAN_PORT:-8787}"
21
+ HANOMAN_USER="${HANOMAN_USER:-hanoman}"
22
+ HANOMAN_DATA="${HANOMAN_DATA:-/var/lib/hanoman}"
23
+ IMAGE="${IMAGE:-hanoman-agent:latest}"
24
+
25
+ comp() { echo "COMP $1 $2 ${3:-}"; }
26
+ step() { echo "STEP $1 $2 ${3:-}"; }
27
+ have() { command -v "$1" >/dev/null 2>&1; }
28
+
29
+ # ---------- probe ----------
30
+
31
+ probe_base() { if have git && have tmux && have curl; then comp base ok "git+tmux+curl"; else comp base absent "paket dasar belum lengkap"; fi; }
32
+ probe_node() { if have node; then comp node ok "$(node --version 2>/dev/null | head -1)"; else comp node absent ""; fi; }
33
+ probe_podman() { if have podman; then comp podman ok "$(podman --version 2>/dev/null | head -1)"; else comp podman absent ""; fi; }
34
+ probe_gh() { if have gh; then comp gh partial not-logged-in; else comp gh absent ""; fi; }
35
+
36
+ probe_caddy() {
37
+ if ! have caddy; then comp caddy absent ""; return; fi
38
+ if systemctl is-active --quiet caddy 2>/dev/null; then comp caddy ok "$(caddy version 2>/dev/null | head -1)"
39
+ else comp caddy partial service-inactive; fi
40
+ }
41
+
42
+ probe_hanoman() {
43
+ if ! have hanoman; then comp hanoman absent ""; return; fi
44
+ local v; v="$(hanoman --version 2>/dev/null | head -1)"
45
+ if systemctl is-active --quiet hanoman 2>/dev/null; then comp hanoman ok "$v"
46
+ else comp hanoman partial "service-inactive $v"; fi
47
+ }
48
+
49
+ probe_agent_image() {
50
+ if ! have podman; then comp agent-image absent "podman tak ada"; return; fi
51
+ local id; id="$(podman image inspect --format '{{.Id}}' "$IMAGE" 2>/dev/null | head -1)"
52
+ if [ -n "$id" ]; then comp agent-image ok "${id:0:12}"; else comp agent-image absent ""; fi
53
+ }
54
+
55
+ # Komponen ber-login TIDAK PERNAH `ok`: biner ada ≠ siap dipakai (SPEC-487, marker ≠ bukti).
56
+ probe_agent_cli() {
57
+ local id="$1"
58
+ if have "$id"; then comp "$id" partial "not-logged-in $("$id" --version 2>/dev/null | head -1)"
59
+ else comp "$id" absent ""; fi
60
+ }
61
+
62
+ do_probe() {
63
+ probe_base; probe_node; probe_hanoman; probe_caddy; probe_podman; probe_agent_image
64
+ probe_agent_cli claude; probe_agent_cli codex; probe_gh
65
+ }
66
+
67
+ # ---------- apply ----------
68
+
69
+ FAILED="" # daftar id yang gagal, dipakai gerbang blocked-by
70
+ mark_failed() { FAILED="$FAILED $1"; }
71
+ has_failed() { case " $FAILED " in *" $1 "*) return 0 ;; *) return 1 ;; esac; }
72
+
73
+ # Prasyarat per komponen, DUPLIKAT dari katalog TypeScript dengan sengaja: server sudah
74
+ # mengirim daftar terurut & lengkap, tabel ini hanya dipakai untuk menerbitkan `blocked-by`
75
+ # yang benar saat sebuah prasyarat gagal di tengah jalan. Jaga keduanya selaras.
76
+ deps_of() {
77
+ case "$1" in
78
+ node) echo "base" ;;
79
+ hanoman) if [ "$PROFILE" = production ]; then echo "node podman"; else echo "node"; fi ;;
80
+ podman) echo "base" ;;
81
+ agent-image) echo "podman" ;;
82
+ claude|codex) if [ "$PROFILE" = production ]; then echo "agent-image"; else echo "node"; fi ;;
83
+ gh) echo "base" ;;
84
+ *) echo "" ;;
85
+ esac
86
+ }
87
+
88
+ pkg_install() {
89
+ if have apt-get; then DEBIAN_FRONTEND=noninteractive apt-get install -y "$@" >/dev/null 2>&1
90
+ elif have dnf; then dnf install -y "$@" >/dev/null 2>&1
91
+ else return 127; fi
92
+ }
93
+
94
+ # Alamat publik mesin ini. curl ke resolver eksternal adalah satu-satunya cara yang jujur di
95
+ # balik NAT; kegagalannya (offline) memulangkan string kosong dan gerbang DNS menolak apa adanya.
96
+ public_ip() { curl -fsS --max-time 5 https://api.ipify.org 2>/dev/null | head -1; }
97
+ resolve_a() { getent ahostsv4 "$1" 2>/dev/null | awk '{print $1}' | head -1; }
98
+
99
+ apply_base() {
100
+ pkg_install curl git tmux ca-certificates build-essential python3 ||
101
+ pkg_install curl git tmux ca-certificates gcc gcc-c++ make python3
102
+ }
103
+ apply_node() { curl -fsSL https://deb.nodesource.com/setup_22.x 2>/dev/null | bash - >/dev/null 2>&1 && pkg_install nodejs; }
104
+ apply_gh() { pkg_install gh; }
105
+ apply_claude() { npm i -g @anthropic-ai/claude-code >/dev/null 2>&1; }
106
+ apply_codex() { npm i -g @openai/codex >/dev/null 2>&1; }
107
+
108
+ apply_podman() {
109
+ pkg_install podman || return 1
110
+ id -u "$HANOMAN_USER" >/dev/null 2>&1 || useradd --system --create-home --home-dir "$HANOMAN_DATA" "$HANOMAN_USER"
111
+ sudo -u "$HANOMAN_USER" podman network exists hanoman-egress 2>/dev/null ||
112
+ sudo -u "$HANOMAN_USER" podman network create --internal hanoman-egress >/dev/null 2>&1
113
+ }
114
+
115
+ apply_agent_image() {
116
+ local cf="${CONTAINERFILE:-/tmp/hanoman-agent.Containerfile}"
117
+ [ -f "$cf" ] || return 1
118
+ sudo -u "$HANOMAN_USER" podman build -t "$IMAGE" -f "$cf" >/dev/null 2>&1
119
+ }
120
+
121
+ # Profil lab TIDAK menyetel NODE_ENV=production: gerbang assertRuntimeBoundary menuntut Podman,
122
+ # credential dir, dan egress proxy di sana. Konsekuensinya cookie sesi lahir tanpa flag `Secure`
123
+ # (server/src/services/auth.ts) — itu sebabnya profil lab tak boleh melayani permukaan Help publik.
124
+ write_env() {
125
+ umask 077
126
+ { echo "HANOMAN_HOME=$HANOMAN_DATA"
127
+ echo "PORT=$HANOMAN_PORT"
128
+ echo "HOST=127.0.0.1"
129
+ echo "HANOMAN_TMUX_SOCKET=hanoman-prod"
130
+ if [ "$PROFILE" = production ]; then
131
+ echo "NODE_ENV=production"
132
+ echo "HANOMAN_SESSION_SANDBOX=podman"
133
+ echo "HANOMAN_SESSION_IMAGE=$IMAGE"
134
+ echo "HANOMAN_SESSION_NETWORK=hanoman-egress"
135
+ echo "HANOMAN_EGRESS_PROXY=${EGRESS_PROXY:-http://127.0.0.1:3128}"
136
+ echo "HANOMAN_AGENT_CREDENTIAL_DIR=$HANOMAN_DATA/agent-credentials"
137
+ echo "HANOMAN_TRUST_PROXY=1"
138
+ echo "HANOMAN_SINGLE_ORIGIN=1"
139
+ [ -n "$DOMAIN" ] && echo "HANOMAN_CONTROL_ORIGINS=https://$DOMAIN"
140
+ fi
141
+ } > /etc/hanoman.env || return 1
142
+ chown "root:$HANOMAN_USER" /etc/hanoman.env 2>/dev/null
143
+ chmod 0640 /etc/hanoman.env
144
+ }
145
+
146
+ write_unit() {
147
+ cat > /etc/systemd/system/hanoman.service <<UNIT
148
+ [Unit]
149
+ Description=hanoman orchestrator + dashboard
150
+ After=network-online.target
151
+ Wants=network-online.target
152
+
153
+ [Service]
154
+ Type=simple
155
+ User=$HANOMAN_USER
156
+ Group=$HANOMAN_USER
157
+ WorkingDirectory=$HANOMAN_DATA
158
+ UMask=0077
159
+ Environment=HOME=$HANOMAN_DATA
160
+ EnvironmentFile=/etc/hanoman.env
161
+ ExecStart=/usr/bin/env hanoman
162
+ Restart=on-failure
163
+ RestartSec=3
164
+ NoNewPrivileges=true
165
+ PrivateTmp=true
166
+
167
+ [Install]
168
+ WantedBy=multi-user.target
169
+ UNIT
170
+ }
171
+
172
+ apply_hanoman() {
173
+ id -u "$HANOMAN_USER" >/dev/null 2>&1 || useradd --system --create-home --home-dir "$HANOMAN_DATA" "$HANOMAN_USER"
174
+ install -d -o "$HANOMAN_USER" -g "$HANOMAN_USER" -m 0700 "$HANOMAN_DATA" || return 1
175
+ npm i -g hanoman >/dev/null 2>&1 || return 1
176
+ write_env || return 1
177
+ write_unit || return 1
178
+ systemctl daemon-reload >/dev/null 2>&1
179
+ systemctl enable --now hanoman >/dev/null 2>&1
180
+ }
181
+
182
+ apply_caddy() {
183
+ pkg_install caddy || return 1
184
+ mkdir -p /etc/caddy
185
+ cat > /etc/caddy/Caddyfile <<CADDY
186
+ $DOMAIN {
187
+ header {
188
+ Strict-Transport-Security "max-age=31536000; includeSubDomains"
189
+ X-Content-Type-Options "nosniff"
190
+ Referrer-Policy "no-referrer"
191
+ }
192
+ encode zstd gzip
193
+ reverse_proxy 127.0.0.1:$HANOMAN_PORT
194
+ }
195
+ CADDY
196
+ caddy validate --config /etc/caddy/Caddyfile >/dev/null 2>&1 || return 1
197
+ systemctl enable --now caddy >/dev/null 2>&1
198
+ systemctl reload caddy >/dev/null 2>&1
199
+ }
200
+
201
+ # Gerbang DNS mendahului instalasi apa pun: sertifikat ACME yang gagal terbit meninggalkan Caddy
202
+ # hidup tanpa TLS DAN membakar rate-limit Let's Encrypt. Menolak di depan jauh lebih murah.
203
+ caddy_gate() {
204
+ [ -n "$DOMAIN" ] || { echo "domain-required"; return 1; }
205
+ local want got
206
+ want="$(public_ip)"; got="$(resolve_a "$DOMAIN")"
207
+ [ -n "$got" ] || { echo "dns-unresolved $DOMAIN"; return 1; }
208
+ [ "$got" = "$want" ] || { echo "dns-mismatch $got != ${want:-tak-diketahui}"; return 1; }
209
+ return 0
210
+ }
211
+
212
+ # Status satu komponen (kolom ketiga baris COMP) — dipakai gerbang idempotensi.
213
+ probe_one() {
214
+ case "$1" in
215
+ base) probe_base ;; node) probe_node ;; hanoman) probe_hanoman ;; caddy) probe_caddy ;;
216
+ podman) probe_podman ;; agent-image) probe_agent_image ;; gh) probe_gh ;;
217
+ claude|codex) probe_agent_cli "$1" ;;
218
+ esac | awk '{print $3}'
219
+ }
220
+
221
+ # Komponen ber-login berhenti di `partial`, jadi `partial` DIANGGAP sudah terpasang untuk mereka —
222
+ # memasang ulang biner tak akan membuat siapa pun login.
223
+ already_present() {
224
+ local id="$1" st="$2"
225
+ [ "$st" = ok ] && return 0
226
+ if [ "$st" = partial ]; then
227
+ case "$id" in claude|codex|gh) return 0 ;; esac
228
+ fi
229
+ return 1
230
+ }
231
+
232
+ apply_one() {
233
+ local id="$1"
234
+
235
+ for d in $(deps_of "$id"); do
236
+ if has_failed "$d"; then step "$id" skip "blocked-by $d"; mark_failed "$id"; return; fi
237
+ done
238
+
239
+ if [ "$id" = caddy ]; then
240
+ local why
241
+ if ! why="$(caddy_gate)"; then step caddy fail "$why"; mark_failed caddy; return; fi
242
+ fi
243
+
244
+ local st; st="$(probe_one "$id")"
245
+ if already_present "$id" "$st"; then step "$id" skip "already-present"; return; fi
246
+
247
+ if [ -n "$DRY_RUN" ]; then step "$id" would "akan dipasang"; return; fi
248
+
249
+ local rc=0
250
+ case "$id" in
251
+ base) apply_base || rc=$? ;; node) apply_node || rc=$? ;; hanoman) apply_hanoman || rc=$? ;;
252
+ caddy) apply_caddy || rc=$? ;; podman) apply_podman || rc=$? ;;
253
+ agent-image) apply_agent_image || rc=$? ;; claude) apply_claude || rc=$? ;;
254
+ codex) apply_codex || rc=$? ;; gh) apply_gh || rc=$? ;;
255
+ *) step "$id" fail "komponen tak dikenal"; mark_failed "$id"; return ;;
256
+ esac
257
+
258
+ if [ "$rc" -eq 0 ]; then step "$id" ok "terpasang"; else step "$id" fail "kode keluar $rc"; mark_failed "$id"; fi
259
+ }
260
+
261
+ do_apply() {
262
+ IFS=',' read -ra arr <<< "${ITEMS:-}"
263
+ for id in "${arr[@]}"; do [ -n "$id" ] && apply_one "$id"; done
264
+ }
265
+
266
+ case "$MODE" in
267
+ probe) do_probe ;;
268
+ apply) do_apply ;;
269
+ *) echo "STEP _ fail MODE tak dikenal: $MODE"; exit 2 ;;
270
+ esac