diff --git a/ARCHITECTURE.json b/ARCHITECTURE.json index 3f4cc85..df2f2de 100644 --- a/ARCHITECTURE.json +++ b/ARCHITECTURE.json @@ -71,6 +71,20 @@ "threshold": "only `runpodctl` was listed, so `runpod config`/`runpod project deploy`/`runpod pod create` and serverless `curl api.runpod.ai/v2//run` all leaked — same whitelist-too-narrow bypass as VAST_VERBS", "source": "modules/cloud-guard.ts" }, + { + "id": "SEGMENT_LOCAL_SANCTION", + "state": "pos-conv", + "value": "the hexa-builtin sanction in detectRawCloudCli is per-SEGMENT, not whole-command — one `hexa cloud …` token must not exempt a raw-provider tail", + "threshold": "a top-level `if (isHexaBuiltin(cmd)) return null` exempted the ENTIRE string, so `hexa cloud pods; vastai create instance` leaked (the sanctioned head whitelisted the chained raw CLI) — same whitelist-too-broad failure mode as VAST_VERBS/BOTH_RUNPOD_CLIS; fix judges each segment on its own head", + "source": "modules/cloud-guard.ts" + }, + { + "id": "RENT_HUMAN_CONSENT_GATE", + "state": "pos-conv", + "value": "a GPU-pod rent (`hexa cloud rent|up`) is a real-money action gated on a fresh HUMAN consent token (`!sidecar sign rent`) minted only via the TUI bang — the agent's own `sidecar sign` mint and any write into ~/.sidecar/signs/ are code-blocked (SIGN-SELF-MINT/SIGN-FORGE); one-shot, consumed on admission, TTL/one-shot are code constants (no config opt-out). Gate rests on 'the agent cannot emit a `!` bang', not on the text parser being airtight", + "threshold": "renting a pod costs money but nothing forced a human in the loop — the agent could `hexa cloud rent` unilaterally; ports the retired hexa `_sign_guard.hexa` consent gate (old `! sidecar sign commons` pattern). Indirect rents (bash -c/eval/ssh/pool wrapper) are blocked 'run it directly'; structural closure (hexa itself requiring the token) is a documented phase-2", + "source": "modules/sign.ts" + }, { "id": "COMPANION_SURFACE_NOT_INJECTED", "state": "pos-conv", diff --git a/CHANGELOG.jsonl b/CHANGELOG.jsonl index 51270e5..d651526 100644 --- a/CHANGELOG.jsonl +++ b/CHANGELOG.jsonl @@ -1,3 +1,4 @@ +{"ts":"2026-07-24","title":"feat(sign): GPU-pod-rent consent gate — !sidecar sign rent (human-only one-shot token)","body":"New sidecar sign command + GPU-pod-rent consent gate: renting a pod (hexa cloud rent/up) is a real-money action, so pre bash now BLOCKS it (CLOUD-RENT-UNSIGNED) until the HUMAN mints a one-shot consent token via the TUI bang '!sidecar sign rent' — ported from the retired hexa _sign_guard.hexa (old '! sidecar sign commons' pattern). modules/sign.ts: host-global plain-epoch token store ~/.sidecar/signs/.sign, TTL rent=600s, one-shot (consumed atomically on admission via rename-then-unlink; pre-consume stops parallel double-spend); CLI verbs list/mint/check/clear; helpers signFresh/consumeSign/detectSelfMint/signForgeBashTarget/isSignsPath. cloud-guard.ts: detectCloudRent (direct head-match gated; indirect bash-c/eval/ssh/pool wrapper blocked); fixed pre-existing segment-local weakness — isHexaBuiltin exempted the WHOLE command string so 'hexa cloud pods; raw-create' leaked; now sanctioned per-SEGMENT. pre.ts: SIGN-SELF-MINT + SIGN-FORGE(bash) in preBash, rent gate LAST (never burns the one-shot token), SIGN-FORGE(write) in preWrite. TTL/one-shot are CODE constants (no config opt-out). No new hook wiring: both surfaces already route bash and write. Docs: commands/sign.md, templates/sign.md. Skipped as over-eng (sol): repo-scope hash, JSON/nonce token, MCP pre-tool deny — phase-2. Smoke: unsigned/self-mint/forge block; mint->rent allow+consume->re-block; echo/grep allow; indirect block; seg-local block; tsc+lint+ci green."} {"ts":"2026-07-19","title":"commons: add no-bare-jargon slug","body":"User-facing prose must gloss every jargon/acronym/hyphenated/backtick term at first use (plain-word swap or term(=plain meaning)); elevates the easy-style advisory into an always-on governance hard rule. Also trimmed the lab ARCHITECTURE node under the 300-char cell cap."} {"ts":"2026-07-18","title":"fix(lab): guard child.stdin null (TS18047) — origin/main tsc RED 복구","body":"child.stdin 은 spawn 타입상 Writable|null 인데 널가드 없이 .write/.end 호출 → strict tsc TS18047(2곳: fable·sol 백엔드). origin/main 이 이 상태로 머지돼 sidecar 전체 ci(npx tsc --noEmit)가 RED. optional chaining(child.stdin?.write/?.end)으로 가드 — stdin 이 null 이면 spawn 실패라 error 핸들러가 이미 settle(127) 처리하므로 안전. tsc exit 0 복구."} {"ts":"2026-07-18","title":"feat(easy): 0순위 맨-은어 금지 + 경중 2단(인라인풀이/7요소) + bare-jargon-gloss lint 축","body":"LEAN(매턴 주입) 재설계: \"7요소를 사소하지 않은 개념마다\"는 진행보고·요약처럼 은어 섞인 서술에는 안 걸려 통째로 스킵됐다(실패사례 H_9774 CEMENTING류 은어 범벅). 이제 0순위=맨-은어 금지(no bare jargon): 프로젝트 은어·약어·전문용어가 산문에 처음 나오면 그 자리에서 `용어(=쉬운 뜻)`로 풀이. 경중 2단 도입 — Tier1 인라인 풀이(기본·필수·대부분의 산문) vs Tier2 풀 7요소(새 개념·도구 자체가 답의 주인공일 때만). 기존 \"제외\" 목록은 표기-제외로 재정의(풀이 의무는 남음)해 회피 구실 봉쇄. modules/easy.ts header() lean 리드+활성 배너도 7요소 최상위→0순위 풀이 우선으로 lockstep 수정(LEAN과 지시충돌 제거). FULL 레퍼런스에 before→after 리라이트 gold(실패사례 박제)+인라인풀이 미니 gold+체크리스트 1번=bare-jargon 스캔 추가. easy lint 6번째 축 bare-jargon-gloss 추가(하이픈결합·백틱 은어 후보 → 첫 등장 근처 풀이 없으면 경고 · =다음 한글=풀이/=다음 은어=수식 구분 · 쉬운말(용어) 앞쪽형+term(=뜻) 뒤쪽형 양방향 · common allowlist 오탐억제 · advisory exit0). ko(주입대상)+en(canonical base) 동기화. lean byte ko 957→1519·en→1454. tsc 0 · lint BAD 4/4 flag·GOOD 3형태 통과 검증."} @@ -27,5 +28,3 @@ {"ts":"2026-07-08","title":"feat(worktree+ing): ING↔stranded task linkage (#7) + worktree stop-check last-Stop nudge (#5)","body":"stranded-work 가시성 마무리 2건(Fable 랭킹 #5·#7). (#7 ING↔stranded 링크) ing Item 에 branch? 필드 추가 + ing add/next 가 현재 branch(BOARD_ROOT 기준·≠main/master/HEAD)를 스탬프 → stranded 워크트리/브랜치를 그 태스크로 역링크. 신규 export findIngForBranch(branch,items): Tier1=branch 정확 스탬프 매치(최신), Tier2=무스탬프 구엔트리용 보수적 fallback(전체 branch명 리터럴이 text 에 포함 AND 이름이 distinctive(len≥8||\"/\") AND 유일매치일 때만), 모호/무매치→null(잘못된 매치가 no-match보다 해로우므로 FP-averse). worktree inject + sidecar end 6/6b 의 각 stranded 라인에 ` — 🔄 ING#: ` 인라인(readItems 1콜·stranded 있을 때만·clean=0B). (#5 Stop-time 넛지) 신규 `sidecar worktree stop-check`(warn-only·never block): prompt-scan(다음 프롬프트)·worktree inject(다음 세션)·shipStopCheck(미커밋만)이 모두 못 보는 유일 창=세션 마지막 Stop 에 worktree 에 committed-but-unpushed 남기고 터미널 닫는 경우를 warm-context 에서 상기. stop_hook_active anti-wedge + 0-linked fast-exit(classify per-wt git콜 회피) + git-common-dir keyed dedup(branch@tip: 같은 tip 침묵·새 커밋 재경고·해소된 키 prune) 로 긴 세션서 매 Stop 잔소리 방지. 배선 setup.ts Stop[] + hooks/hooks.json Stop 양쪽(setup-ts-1) + install-hooks --global. Stop 훅은 CC 전용이라 CC만 배선(warn-only → wire-to-prod 위반 아님). help/toolkit/ARCHITECTURE(surface-stop·detail-worktree·module-ing) lockstep. QA: findIngForBranch 5케이스(Tier1/Tier2/short-blocked/no-match/empty) PASS · ing add worktree 내 branch 스탬프 확인 · worktree inject 가 `🔄 ING#N` 링크 표시 · stop-check WARN→dedup 침묵→anti-wedge 침묵→new tip 재경고 전부 출력검증 · ci/help/architecture lint green."} {"ts":"2026-07-08","title":"feat(load+end): active stranded-work gauges — wt N(M⚠)🔴 in load line (TTL-cached) + end classify() upgrade + stranded-branch check","body":"stranded-work 가시성 후속 2건(Fable 랭킹 #4·#6). (#4) load 라인 wt 게이지가 stranded 워크트리를 능동 표시: `wt N(M⚠)` + M>0면 무조건 🔴(개수 3/10 임계 무시) — 매 턴 항상 보이는 표면이라 방치작업이 즉시 눈에 띔. classify()는 워크트리당 ~4 git콜이라 매 UserPromptSubmit sync 핫패스엔 과함 → readOpenPRs 와 동일한 git-common-dir TTL캐시(60s) sync 헬퍼 readStrandedWt(포slain 재사용·stranded=비-main 워크트리 dirty|unpushed 정의 동일), cold 턴만 git콜 지불. (#6) `sidecar end` 세션종료 점검을 classify() 로 업그레이드: 방치(⚠ dirty/unpushed)와 정리대기(○ clean merged)를 구분(기존 raw count는 구분 불가), stranded 워크트리는 warn++ 하고 항목별 resume/폐기 명령 출력 + 신규 6b 항목으로 워크트리없는 stranded 로컬브랜치(strandedBranches 재사용 — 소유자의 'stale 브랜치' 케이스)도 flag. 둘 다 기존 워크트리 감지(classify/strandedWorktrees/strandedBranches) 재사용, 발명 0. help 무변경(내부 게이지·기존 verb). ARCHITECTURE detail-3(wt 게이지 stranded 🔴) lockstep. QA: end 가 라이브 stranded 브랜치(ing) flag·load 가 임시 stranded 워크트리에 `wt 1(1⚠)🔴`·clean시 기존 표시 유지·ci green."} {"ts":"2026-07-08","title":"feat(worktree+prompt-scan): surface stranded worktrees/branches at session-start & first-action (stderr→emitInject bugfix + worktree inject verb)","body":"세션이 stale 브랜치/워크트리에 방치되는 걸 세션 시작·첫 행동 시점에 잡는다. Fable investigate-mode 조사로 근본원인 확정: 신호는 이미 계산되는데 stderr로 나가 에이전트 context에 안 닿음(배선 결함, detection 결함 아님). (A) prompt-scan.ts 버그픽스: strandedWorktrees() 블록을 stderr→emitInject(stdout additionalContext, Claude Code 프로토콜)로 라우팅 — 매 UserPromptSubmit(첫 행동+지속) 노출, clean이면 0바이트(self-limiting). 헤더 주석은 \"additionalContext로 주입\"이라 주장했는데 실제 stderr였던 문서-코드 불일치를 정정. keyword/hint는 per-turn bloat 회피 위해 stderr 유지. (B) 신규 `sidecar worktree inject` verb(SessionStart+PreCompact+PostCompact WARN·gc 직후): strandedWorktrees()+신규 strandedBranches()(워크트리없는 stranded 로컬브랜치 — classify() 사각지대, 라이브 `ing` 브랜치가 증거)+refs/reaped tip 카운트를 이전세션 이월 방치작업으로 항목별 resume/보존/폐기 명령과 함께 surface, clean이면 0바이트. config worktree.branchAllow 로 의도적 장수브랜치 예외. WARN(SessionStart는 block 불가·FP-averse·persistence가 teeth). 배선: setup.ts SessionStart/Pre/PostCompact[] + hooks/hooks.json SessionStart 양쪽(setup-ts-1) + install-hooks --global + grep 검증. classify()는 isAgent뿐 아니라 모든 linked worktree 열거라 WORKTREE_GONE_ONLY_PILEUP 의 isAgent-only 사각지대 미반복(단 cross-repo는 여전히 repo-local·별개과제). help/toolkit/ARCHITECTURE(detail-worktree·surface-precompact) lockstep. QA: prompt-scan stranded-wt→stdout inject·worktree inject 라이브 ing 브랜치+임시 stranded wt 둘 다 emit·clean=0바이트·ci/help/architecture lint green."} -{"ts":"2026-07-08","title":"git-context: per-turn stale warning","body":"git-context per-turn stale warning closes READ-STALE gap (silent on clean, loud every turn while behind; both SSOTs + help lockstep)."} -{"ts":"2026-07-20","title":"commons: add cloudflare-global-key slug","body":"Cloudflare API 인증은 계정 Global API Key(secret get cloudflare.global_api_key + 계정 이메일)를 자격증명으로 쓰도록 commons SSOT에 새 슬러그 추가. 스코프 제한 토큰을 글로벌키 자리에 쓰거나 키를 인라인/로그로 노출하는 것은 금지(git-safety·secret CLI 참조)."} diff --git a/TOOLKIT.jsonl b/TOOLKIT.jsonl index 55b905d..39b7560 100644 --- a/TOOLKIT.jsonl +++ b/TOOLKIT.jsonl @@ -48,6 +48,7 @@ {"id":"pool","kind":"hook","usage":"pool {list|add|rm|on|status|specs}","use":"host roster + remote exec + cores/mem/GPU probe (~/.sidecar/pool.json, global)","triggers":["호스트 로스터","host roster","원격 실행","원격에서 실행","어느 머신","어느 호스트","남는 머신","비는 머신","유휴 머신","idle host","원격 머신","다른 컴퓨터에서","pool list","pool status","머신 목록","호스트 목록","서버 목록","cpu 여유","gpu 여유","어디서 돌릴","ssh 로 돌"]} {"id":"mem-guard","kind":"hook","usage":"mem-guard {status|check|install|uninstall}","use":"OOM prevention — free-RAM preflight before bg-spawn + opt-in launchd notify watchdog"} {"id":"secret","kind":"hook","usage":"secret [args]","use":"passthrough to the secret CLI (Keychain creds · get/set/rotate/list/init/backup/sync)","triggers":["api key","API 키","api 키","키가 필요","키 필요","credential","자격증명","access token","비밀키","인증 정보","토큰 저장","secret get","secret set","키 저장"],"detail":"⚠ `get` exposes the value in context — prefer inline `$(secret get )` for tool args"} +{"id":"sign","kind":"hook","usage":"sign [|list|check |clear []]","use":"user-consent gate — a GPU-pod rent (`hexa cloud rent|up`) is BLOCKED until the HUMAN mints a one-shot token via the TUI bang `!sidecar sign rent`","detail":"agent self-mint/forge denied · TTL 10min · consumed on use · list/check/clear agent-safe (minting is human-only — the `!` bang bypasses PreToolUse)"} {"id":"lsp","kind":"hook","usage":"lsp {wire|status|rebuild }","use":"editor LSP wiring (.lsp.json; hexa-lang `hexa lsp` for .hexa by default)","detail":"+ background rebuild of prebuilt hexa LSP binaries when their grammar source is edited"} {"id":"research","kind":"hook","usage":"research {arxiv [--n N] [--sort relevance|date|updated] | yt [lang] | web [--n N] | fetch }","use":"arXiv / YouTube transcript / keyless web search (DuckDuckGo) / page fetch (no key)","triggers":["arxiv","arXiv","논문 찾","논문 검색","선행연구","관련 연구","related work","survey 논문","서베이","SOTA","최신 연구","preprint","literature review","유튜브 자막","youtube transcript","영상 자막","transcript 추출"]} {"id":"watch","kind":"hook","usage":"watch [question] [flags]","use":"download (yt-dlp) → frames (ffmpeg) + transcript (captions/Whisper) for the agent","triggers":["영상 분석","비디오 분석","동영상 분석","영상 요약","프레임 추출","영상에서 추출","유튜브 영상 보","video frames"]} diff --git a/cli/index.ts b/cli/index.ts index 41abeae..b01b65a 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -31,6 +31,7 @@ import { runVerdict } from "../modules/verdict.ts"; import { runAtlas } from "../modules/atlas.ts"; import { runEnd } from "../modules/end.ts"; import { runSecret } from "../modules/secret.ts"; +import { runSign } from "../modules/sign.ts"; import { runLsp } from "../modules/lsp.ts"; import { runWorktree } from "../modules/worktree.ts"; import { runImagine } from "../modules/imagine.ts"; @@ -143,6 +144,8 @@ hook delegates (wire these into your agent's settings.json): mem-guard {status|check|install|uninstall} OOM prevention — free-RAM preflight before bg-spawn + opt-in launchd notify watchdog secret [args] passthrough to the secret CLI (Keychain creds · get/set/rotate/list/init/backup/sync) ⚠ \`get\` exposes the value in context — prefer inline \`\$(secret get )\` for tool args + sign [|list|check |clear []] user-consent gate — a GPU-pod rent (\`hexa cloud rent|up\`) is BLOCKED until the HUMAN mints a one-shot token via the TUI bang \`!sidecar sign rent\` + agent self-mint/forge denied · TTL 10min · consumed on use · list/check/clear agent-safe (minting is human-only — the \`!\` bang bypasses PreToolUse) lsp {wire|status|rebuild } editor LSP wiring (.lsp.json; hexa-lang \`hexa lsp\` for .hexa by default) + background rebuild of prebuilt hexa LSP binaries when their grammar source is edited research {arxiv [--n N] [--sort relevance|date|updated] | yt [lang] | web [--n N] | fetch } arXiv / YouTube transcript / keyless web search (DuckDuckGo) / page fetch (no key) @@ -324,6 +327,8 @@ async function main(): Promise { return runEnd(rest); case "secret": return runSecret(rest); + case "sign": + return runSign(rest); case "lsp": return runLsp(rest); case "worktree": diff --git a/commands/sign.md b/commands/sign.md new file mode 100644 index 0000000..3ccdbd0 --- /dev/null +++ b/commands/sign.md @@ -0,0 +1,12 @@ +--- +description: /sign [|list|check |clear []] — user-consent gate. A GPU-pod rent (`hexa cloud rent|up`) is BLOCKED until the HUMAN mints a one-shot token via the TUI bang `!sidecar sign rent`; list/check/clear are agent-safe, minting is human-only. Triggers — "sign", "렌트 승인", "consent token", "/sign". +argument-hint: "[|list|check |clear []]" +allowed-tools: Bash +--- + +⚠️ Minting a token is HUMAN-ONLY. To authorize a GPU-pod rent, type the bang directly in the +prompt: `!sidecar sign rent` — the `!` bang bypasses the tool guard. Running `/sign rent` +(or `sidecar sign rent` as a tool call) is DENIED by design (SIGN-SELF-MINT). This delegator is +for the agent-safe verbs: `list` · `check ` · `clear []`. + +!`command -v sidecar >/dev/null 2>&1 && sidecar sign $ARGUMENTS || echo "sidecar CLI not found — install dancinlab/sidecar (~/.sidecar/cli + ~/.local/bin/sidecar on PATH)"` diff --git a/modules/cloud-guard.ts b/modules/cloud-guard.ts index bf25e5d..c4ca2cf 100644 --- a/modules/cloud-guard.ts +++ b/modules/cloud-guard.ts @@ -11,7 +11,7 @@ // // strip ' and " so a quoted token still resolves to its bare form -function stripQuotes(s: string): string { +export function stripQuotes(s: string): string { let out = ""; for (const c of s) if (c !== "'" && c !== '"') out += c; return out; @@ -23,7 +23,7 @@ function stripQuotes(s: string): string { // pipe — otherwise `vast`/`runpod` get mis-read as a command head and false-block. // (Stripping quotes first then splitting on `|`, the old approach, lost that // boundary.) Each returned segment still carries its quotes; callers stripQuotes. -function segments(raw: string): string[] { +export function segments(raw: string): string[] { const segs: string[] = []; let cur = ""; let q: string | null = null; @@ -69,7 +69,7 @@ function isHexaBuiltin(cmd: string): boolean { // blocked; listing only `runpodctl` left the entire `runpod` CLI surface open. const CLI_COMMANDS = new Set(["runpodctl", "runpod", "vastai", "vast"]); -function leadToken(segment: string): { head: string; rest: string[] } { +export function leadToken(segment: string): { head: string; rest: string[] } { let toks = segment.trim().split(/\s+/).filter(Boolean); if (toks[0] === "sudo") toks = toks.slice(1); while (toks[0] && /^[A-Za-z_][A-Za-z0-9_]*=/.test(toks[0])) toks = toks.slice(1); // env-assignments @@ -84,22 +84,68 @@ function leadToken(segment: string): { head: string; rest: string[] } { // • provider control endpoints anywhere: api.runpod.io / rest.runpod.io / // api.runpod.ai (serverless) / console.vast.ai export function detectRawCloudCli(rawCmd: string): string | null { - const cmd = stripQuotes(rawCmd); - if (isHexaBuiltin(cmd)) return null; - - // segment on UNQUOTED shell separators only (a `|` inside a quoted regex is data) + // SEGMENT-LOCAL sanctioning: sanction only the hexa-builtin SEGMENT, not the whole + // command string. A blanket `if (isHexaBuiltin(cmd)) return null` at the top let a + // chained `hexa cloud pods; vastai create instance …` sail through — one sanctioned + // token exempted the raw-provider tail. Judge each segment on its own head (same + // whitelist-too-broad failure mode as VAST_VERBS / BOTH_RUNPOD_CLIS, one level up). for (const seg of segments(rawCmd)) { + if (isHexaBuiltin(seg)) continue; // this segment is the sanctioned path — skip it const { head, rest } = leadToken(seg); - if (!head) continue; - if (CLI_COMMANDS.has(head)) return `raw provider CLI \`${head}\``; - if (head === "cloud" && rest[0] === "rent") return "`cloud rent` (raw provider rent)"; + if (head) { + if (CLI_COMMANDS.has(head)) return `raw provider CLI \`${head}\``; + if (head === "cloud" && rest[0] === "rent") return "`cloud rent` (raw provider rent)"; + } + // provider control-plane API endpoints (curl/wget/python hitting them) — anywhere + // in a NON-sanctioned segment (a legit `hexa cloud …` arg mentioning one is skipped). + const api = /\b(api\.runpod\.io|rest\.runpod\.io|api\.runpod\.ai|console\.vast\.ai)\b/.exec(stripQuotes(seg)); + if (api) return `provider API endpoint \`${api[1]}\``; } - // provider control-plane API endpoints (curl/wget/python hitting them) — match - // anywhere, since the endpoint is the intent regardless of token position. - const api = /\b(api\.runpod\.io|rest\.runpod\.io|api\.runpod\.ai|console\.vast\.ai)\b/.exec(cmd); - if (api) return `provider API endpoint \`${api[1]}\``; + return null; +} + +// A GPU-pod RENT invocation via the sanctioned hexa builtin — `hexa cloud rent` (or +// alias `hexa cloud up`). This COSTS MONEY, so `pre bash` gates it behind a fresh user +// consent token (see modules/sign.ts). Two shapes: +// • direct — the rent is a top-level simple command (head-position, quote-aware): +// `hexa cloud rent vast …`, `/opt/hexa/bin/hexa cloud up …`, +// `VAR=x hexa cloud rent …`. Only THIS shape can be sign-gated + allowed. +// • indirect — the rent verb hides inside an execution wrapper (`bash -c '…'`, `eval`, +// `ssh host '…'`, `sidecar pool … -- …`) the gate can't see through, so it +// is BLOCKED with "issue it directly" — NOT a mere mention (echo/grep of the +// words stay null because their segment head is echo/grep, not a wrapper). +export type RentHit = { kind: "direct"; verb: "rent" | "up" } | { kind: "indirect"; via: string }; + +function baseName(head: string): string { + const s = stripQuotes(head); + return s.slice(s.lastIndexOf("/") + 1); +} +export function detectCloudRent(rawCmd: string): RentHit | null { + // direct: a simple-command segment headed by `hexa` (any path) → `cloud rent|up`. + for (const seg of segments(rawCmd)) { + const { head, rest } = leadToken(seg); + if (baseName(head) === "hexa" && rest[0] === "cloud" && (rest[1] === "rent" || rest[1] === "up")) { + return { kind: "direct", verb: rest[1] as "rent" | "up" }; + } + } + // indirect: the rent string appears, but only flag it inside an EXECUTION wrapper + // (so plain echo/grep/commit-message mentions never trip). Require both the wrapper + // head AND the rent verb in that same segment's payload. + const rentRe = /hexa\s+cloud\s+(rent|up)\b/; + if (!rentRe.test(stripQuotes(rawCmd))) return null; + for (const seg of segments(rawCmd)) { + const { head, rest } = leadToken(seg); + const b = baseName(head); + const isShellC = ["bash", "sh", "zsh", "dash", "ksh"].includes(b) && rest.includes("-c"); + const isEval = b === "eval"; + const isSsh = b === "ssh"; + const isPool = b === "sidecar" && rest[0] === "pool"; + if ((isShellC || isEval || isSsh || isPool) && rentRe.test(stripQuotes(seg))) { + return { kind: "indirect", via: isSsh ? "an ssh remote command" : isPool ? "a `sidecar pool` remote command" : `a \`${b}${isEval ? "" : " -c"}\` wrapper` }; + } + } return null; } diff --git a/modules/pre.ts b/modules/pre.ts index 95cbddd..b643744 100644 --- a/modules/pre.ts +++ b/modules/pre.ts @@ -19,7 +19,8 @@ import { detectBranchSwitch, detectMainRefMove } from "./git-checkout-guard.ts"; import { detectAddAll } from "./git-add-guard.ts"; import { detectDangerousBash } from "./danger-guard.ts"; import { detectSecretLiteral } from "./secret-guard.ts"; -import { detectRawCloudCli, detectHandrolledShardFanout } from "./cloud-guard.ts"; +import { detectRawCloudCli, detectHandrolledShardFanout, detectCloudRent } from "./cloud-guard.ts"; +import { signFresh, consumeSign, detectSelfMint, signForgeBashTarget, isSignsPath } from "./sign.ts"; import { worktreeAddAdvisory } from "./worktree.ts"; import { convergenceForFile } from "./architecture.ts"; import { docWriteViolation } from "./docs.ts"; @@ -385,6 +386,25 @@ export async function preBash(_args: string[]): Promise { ); } + // sign-gate self-mint / forge — a user CONSENT token (`!sidecar sign `) is a + // HUMAN-only action (the TUI `!` bang bypasses this hook; the agent's own run does + // NOT). Block the agent minting or forging one; list/check/clear stay allowed. These + // are position-independent hard blocks, so their order vs. the rent gate is moot. + const selfMint = detectSelfMint(cmd); + if (selfMint) { + return emitBlock( + "SIGN-SELF-MINT", + `\`${selfMint}\` mints a USER consent token — a human-only action; an agent minting it defeats the gate. BLOCKED. Ask the USER to type \`!sidecar sign rent\` in the Claude Code prompt (the TUI \`!\` bang mints; your own run does not). Allowed for you: \`sidecar sign\` (list) · \`sidecar sign check rent\` · \`sidecar sign clear\`. No override.` + ); + } + const forge = signForgeBashTarget(cmd, cwd); + if (forge) { + return emitBlock( + "SIGN-FORGE", + `writing \`${forge}\` under the sign-token store (~/.sidecar/signs/) forges a USER consent token — the money-gate root of trust. BLOCKED. The token is minted only by the human via \`!sidecar sign rent\`. Inspect/clear with \`sidecar sign\` / \`sidecar sign clear\`. No override.` + ); + } + // worktree-add advisory — non-blocking (stranded-work + branch-reuse hygiene) const wtAdv = await worktreeAddAdvisory(cmd).catch(() => ""); if (wtAdv) emitWarn("WORKTREE-HYGIENE", wtAdv); @@ -426,9 +446,43 @@ export async function preBash(_args: string[]): Promise { if (rule.action === "block") return emitBlock(rule.id, rule.reason); if (rule.action === "warn") emitWarn(rule.id, rule.reason); } + + // GPU-pod RENT consent gate — LAST blocking check so an earlier guard never burns the + // one-shot token. `hexa cloud rent|up` costs real money, so it needs a fresh USER + // `rent` sign (minted only via `!sidecar sign rent`; the agent's own mint is blocked + // above). Indirect (wrapper-hidden) rents can't be gated → blocked with "run direct". + const rent = detectCloudRent(cmd); + if (rent?.kind === "indirect") { + return emitBlock( + "CLOUD-RENT-INDIRECT", + `a \`hexa cloud rent|up\` runs inside ${rent.via} — an execution wrapper the consent gate can't see through. BLOCKED. Issue the rent as ONE direct top-level \`hexa cloud rent …\` command so the sign gate can gate it (after the user signs with \`!sidecar sign rent\`).` + ); + } + if (rent?.kind === "direct") { + // consume-on-admission (atomic one-shot): fresh token → this rent is authorized and + // the token is spent; else block with the sign instructions. + if (!consumeSign("rent")) return emitBlock("CLOUD-RENT-UNSIGNED", RENT_BLOCK_MSG); + emitContext( + "sign-gate: user-signed `rent` token consumed (one-shot) — THIS `hexa cloud rent` is authorized. A further rent needs a fresh `!sidecar sign rent`." + ); + } return 0; } +// The block relayed to the agent when a rent is attempted without a fresh user token. +// The exact mint string `!sidecar sign rent` sits alone so it survives relay verbatim; +// cost-first + retry-ONCE + no-loop keep the agent from spinning on the gate. +const RENT_BLOCK_MSG = + "`hexa cloud rent` RENTS A GPU POD — this costs real money and needs the USER's sign-off first. BLOCKED. Do this, in order: " + + "(1) tell the user WHAT you intend to rent — provider · GPU type/count · est. $/hr · purpose · expected duration; " + + "(2) ask the user to type, in the Claude Code prompt: !sidecar sign rent " + + "(the sign must come FROM the human — your own `sidecar sign rent` is denied by design); " + + "(3) WAIT for their confirmation, then retry the SAME rent command ONCE. " + + "The token is ONE-SHOT and valid 10 min: one sign = one rent command. Do NOT retry in a loop — " + + "if it blocks again the token expired or was already consumed, go back to step 1. " + + "Allowed for you: `sidecar sign check rent` · `sidecar sign` (list) · `hexa cloud status`. " + + "No env-var, flag, or inline-marker override exists."; + function checkBypassPatterns(content: string, patterns: string[], exemptions: string[]): string[] { const hits: string[] = []; const lines = content.split("\n"); @@ -469,6 +523,15 @@ export async function preWrite(_args: string[]): Promise { input.content === undefined && (input.new_string !== undefined || editText.length > 0); if (!filePath) return 0; + // sign-token forge guard — a Write/Edit under ~/.sidecar/signs/ forges a USER consent + // token (the money-gate root of trust). Human-only via `!sidecar sign rent`; deny. + if (isSignsPath(filePath)) { + return emitBlock( + "SIGN-FORGE", + `\`${filePath}\` is inside the sign-token store (~/.sidecar/signs/) — writing here forges a USER consent token. BLOCKED (minted only by the human via \`!sidecar sign rent\`). Inspect/clear with \`sidecar sign\` / \`sidecar sign clear\`. No override.` + ); + } + // convergence-on-touch — surface this file's recorded recurrence-prevention learnings // as INJECTED context (additionalContext, not stderr) so the agent doesn't reintroduce // a defect already learned-from. Computed up-front; emitted on the non-block path at the diff --git a/modules/sign.ts b/modules/sign.ts new file mode 100644 index 0000000..3bde128 --- /dev/null +++ b/modules/sign.ts @@ -0,0 +1,256 @@ +// sign — user-consent token gate (money/irreversible actions need a HUMAN sign-off). +// +// A tiny host-global token store lets the USER pre-authorize a cost action the agent +// must not take unilaterally. Today the one key is `rent` — a GPU-pod rent +// (`hexa cloud rent|up`) costs real money, so `pre bash` refuses it until a fresh +// `rent` token exists. The token is minted ONLY by the human typing the TUI bang +// `!sidecar sign rent` (the `!` bang bypasses PreToolUse, so it mints freely); the +// agent's OWN `sidecar sign rent` is DENIED by the sign-guard in `pre.ts` +// (detectSelfMint) and any write under the store is denied (signForgeBashTarget / +// isSignsPath). So the gate rests on "the agent cannot emit a `!` bang", exactly like +// the archive `_sign_guard.hexa` it ports (verified 2026-05-25 on Claude Code). +// +// This is enforcement WITHIN the hook threat model, not an OS boundary: base64/eval/ +// remote hosts holding their own provider creds are out of reach of a text guard. The +// realistic channels (direct mint, token forge, common wrappers) are closed here; the +// structural closure (hexa itself requiring the token) is a documented phase-2 follow-up. +import { homedir } from "node:os"; +import { resolve, sep } from "node:path"; +import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, readdirSync, renameSync } from "node:fs"; +import { info, ok, warn, loudFail } from "../lib/log.ts"; +import { segments, leadToken, stripQuotes } from "./cloud-guard.ts"; + +// Host-global store (NOT repo-local): a rent can be launched from any cwd / linked +// worktree, so the consent token must be found regardless of where the agent runs — +// same rationale as `~/.sidecar/accounts.json`. Resolve home via os.homedir(), never +// $HOME (an agent-controllable env var must not relocate the root of trust). +const SIGNS_DIR = resolve(homedir(), ".sidecar", "signs"); + +// Per-key policy is a CODE constant, deliberately NOT config: a config-tunable TTL is +// an opt-out with extra steps (the agent edits harness.config.json freely, so +// `sign.ttl: 9e9` would neuter the gate). Guard parameters of a no-override guard live +// in code — same doctrine as cloud-guard.ts having no config gate at all. +// TTL rent=600s: the guard fires at command LAUNCH (PreToolUse), so it need NOT cover +// the multi-minute ssh-ready wait — only the agent's preamble between the user signing +// and issuing the rent. 10 min is comfortable for that and still a real window. +const POLICY: Record = { + rent: { ttlSec: 600 }, +}; +const DEFAULT_TTL = 300; +const ttlFor = (key: string): number => POLICY[key]?.ttlSec ?? DEFAULT_TTL; + +function signPath(key: string): string { + return resolve(SIGNS_DIR, `${key}.sign`); +} + +// Epoch seconds stored in the token, or null if absent / malformed. Plain-epoch text +// (byte-compatible with the archive) — no JSON/nonce ceremony needed for a single-user +// local consent flag; the atomic-claim unlink below covers the one real race. +function readEpoch(key: string): number | null { + try { + const n = Number(readFileSync(signPath(key), "utf8").trim()); + return Number.isFinite(n) && n > 0 ? n : null; + } catch { + return null; + } +} + +// A fresh (unexpired) token exists for `key`. READ-ONLY — does not consume. +export function signFresh(key: string): boolean { + const epoch = readEpoch(key); + if (epoch == null) return false; + return Date.now() / 1000 - epoch <= ttlFor(key); +} + +// Consume a one-shot token: atomically claim (rename) then unlink, so two parallel +// hooks can't double-spend one sign — only one racer wins the rename. Returns true iff +// THIS caller consumed a FRESH token (i.e. the action is authorized). Consume-on- +// admission (at the guard's allow path), NOT on the action's success: there is no +// reliable Bash post-hook to key success off, and pre-consume is what stops a parallel +// double-spend. A burned token on a rent that then fails is the accepted trade for a +// hard "one sign = one spend decision" invariant with zero new hook wiring. +export function consumeSign(key: string): boolean { + if (!signFresh(key)) return false; + const src = signPath(key); + const claim = `${src}.claim.${process.pid}`; + try { + renameSync(src, claim); // atomic — the sole synchronization point + } catch { + return false; // lost the race, or it expired/vanished between check and rename + } + try { + unlinkSync(claim); + } catch { + /* best-effort cleanup — the token is already spent (renamed away) */ + } + return true; +} + +// ── guard detectors (used by pre.ts) ────────────────────────────────────────── + +// Subverbs the AGENT may run: they only inspect or REDUCE privilege, never mint. +const MINT_ALLOWED = new Set(["", "list", "--list", "status", "check", "clear", "help", "-h", "--help"]); +// command-position wrappers to peel before reading the real head +const WRAPPERS = new Set(["sudo", "command", "env", "nohup", "time", "xargs"]); + +// A `sidecar sign ` MINT issued by the agent, in any command-head position and +// quote-aware (so `echo "sidecar sign rent"` / `grep 'sidecar sign rent'` never match — +// their segment head is echo/grep). Returns the offending token, or null. +export function detectSelfMint(rawCmd: string): string | null { + for (const seg of segments(rawCmd)) { + let { head, rest } = leadToken(seg); + while (WRAPPERS.has(head) && rest.length) { + head = rest[0]; + rest = rest.slice(1); + } + const base = head.slice(head.lastIndexOf("/") + 1); // ./bin/sidecar, abs paths → sidecar + if (base === "sidecar" && rest[0] === "sign" && !MINT_ALLOWED.has(rest[1] ?? "")) { + return `sidecar sign ${rest[1]}`; + } + // shell -c / eval indirection: the inner mint is quoted (masked from the head scan) + // → scan the raw segment for a non-read `sidecar sign …`. + if ((["bash", "sh", "zsh", "dash", "ksh"].includes(base) && rest.includes("-c")) || base === "eval") { + if (/(^|[\s;|&(])sidecar\s+sign\s+(?!clear\b|list\b|status\b|check\b|help\b|--list\b|--help\b|-h\b)\S/.test(stripQuotes(seg))) { + return "sidecar sign … (via shell -c/eval)"; + } + } + } + return null; +} + +function underSigns(absPath: string): boolean { + return absPath === SIGNS_DIR || absPath.startsWith(SIGNS_DIR + sep); +} + +// A Write/Edit file_path that lands inside the sign store (forging a consent token). +export function isSignsPath(filePath: string): boolean { + if (!filePath) return false; + const expanded = filePath.startsWith("~") ? filePath.replace(/^~(?=\/|$)/, homedir()) : filePath; + return underSigns(resolve(expanded)); +} + +// A Bash write-channel targeting the sign store: `> f` · `>> f` · `tee f` · `cp/mv … +// dest` · `dd of=f`. Relative targets resolve against the payload cwd. Returns the +// offending absolute path, or null. Not a sandbox — narrows the common channels; a +// `python -c open(...)` / `sed -i` computed path is out of a text guard's reach (phase 2). +export function signForgeBashTarget(rawCmd: string, cwd: string): string | null { + const base = cwd || process.env.PWD || process.cwd(); + const abs = (t: string): string => { + const s = stripQuotes(t).replace(/^~(?=\/|$)/, homedir()); + return s.startsWith("/") ? resolve(s) : resolve(base, s); + }; + // redirect targets: `>path` `>>path` `1>path` (glued) or `> path` (next token) + const toks = stripQuotes(rawCmd).split(/\s+/).filter(Boolean); + for (let i = 0; i < toks.length; i++) { + const m = toks[i].match(/^\d*>>?(.*)$/); + if (m) { + const p = m[1] || toks[i + 1] || ""; + if (p && underSigns(abs(p))) return abs(p); + } + if (toks[i].startsWith("of=") && underSigns(abs(toks[i].slice(3)))) return abs(toks[i].slice(3)); + } + // tee / cp / mv / install / ln / touch / truncate destination args (any non-flag) + for (const seg of segments(rawCmd)) { + const { head, rest } = leadToken(seg); + const b = head.slice(head.lastIndexOf("/") + 1); + if (["tee", "cp", "mv", "install", "touch", "ln", "truncate"].includes(b)) { + for (const a of rest) if (a && !a.startsWith("-") && underSigns(abs(a))) return abs(a); + } + } + return null; +} + +// ── CLI surface (`sidecar sign …`) ──────────────────────────────────────────── + +function mint(key: string): number { + mkdirSync(SIGNS_DIR, { recursive: true }); + writeFileSync(signPath(key), `${Math.floor(Date.now() / 1000)}\n`); + const min = Math.round(ttlFor(key) / 60); + ok(`sign: ✅ signed '${key}' — ONE ${key} action allowed for the next ${min} min (one-shot; consumed on use).`); + info(` (list: sidecar sign · clear early: sidecar sign clear ${key})`); + return 0; +} + +function list(): number { + const files = existsSync(SIGNS_DIR) ? readdirSync(SIGNS_DIR).filter((f) => f.endsWith(".sign")) : []; + if (!files.length) { + info("sign: no active tokens"); + return 0; + } + const now = Date.now() / 1000; + info("sign: active tokens"); + for (const f of files) { + const key = f.replace(/\.sign$/, ""); + const epoch = readEpoch(key); + if (epoch == null) { + info(` ${key.padEnd(10)} corrupt`); + continue; + } + const remain = Math.round(ttlFor(key) - (now - epoch)); + info(remain > 0 ? ` ${key.padEnd(10)} ACTIVE ${remain}s remaining (one-shot)` : ` ${key.padEnd(10)} EXPIRED`); + } + return 0; +} + +function check(key: string): number { + const epoch = readEpoch(key); + if (epoch != null && signFresh(key)) { + info(`${key}: valid ${Math.round(ttlFor(key) - (Date.now() / 1000 - epoch))}s`); + return 0; + } + info(`${key}: none`); + return 1; +} + +function clear(key?: string): number { + if (!existsSync(SIGNS_DIR)) return 0; + if (key) { + try { + unlinkSync(signPath(key)); + ok(`sign: cleared '${key}'`); + } catch { + info(`sign: no token '${key}'`); + } + return 0; + } + for (const f of readdirSync(SIGNS_DIR).filter((f) => f.endsWith(".sign") || f.includes(".sign.claim."))) { + try { + unlinkSync(resolve(SIGNS_DIR, f)); + } catch { + /* ignore */ + } + } + ok("sign: cleared all tokens"); + return 0; +} + +function usage(): void { + info("sidecar sign — gated cost/irreversible actions need YOUR sign-off before the agent may take them."); + info(" mint (human-only, via TUI bang): ! sidecar sign (key: rent)"); + info(" list: sidecar sign (or list / --list / status)"); + info(" check: sidecar sign check "); + info(" clear: sidecar sign clear []"); +} + +// NOTE: when the AGENT invokes `sidecar sign `, pre.ts (detectSelfMint) has +// ALREADY denied the tool call — this dispatcher only ever mints for the human's TUI +// `!` bang (which bypasses PreToolUse). list/check/clear are agent-safe. +export async function runSign(args: string[]): Promise { + const sub = args[0] ?? ""; + if (sub === "" || sub === "list" || sub === "--list" || sub === "status") return list(); + if (sub === "help" || sub === "-h" || sub === "--help") { + usage(); + return 0; + } + if (sub === "check") { + if (!args[1]) { + loudFail("sign check: needs a key (e.g. `sidecar sign check rent`)"); + return 2; + } + return check(args[1]); + } + if (sub === "clear") return clear(args[1]); + // otherwise: a MINT of key=sub + if (!(sub in POLICY)) warn(`sign: '${sub}' is not a known key (known: ${Object.keys(POLICY).join(", ")}) — minting anyway with default TTL`); + return mint(sub); +} diff --git a/templates/sign.md b/templates/sign.md new file mode 100644 index 0000000..5abf9e5 --- /dev/null +++ b/templates/sign.md @@ -0,0 +1,46 @@ +# sign — user-consent gate for cost / irreversible actions + +A tiny host-global token store (`~/.sidecar/signs/.sign`) lets the HUMAN pre-authorize an +action the agent must not take on its own. Today the one key is `rent`: a GPU-pod rent +(`hexa cloud rent` / alias `hexa cloud up`) costs real money, so `pre bash` refuses it until a +fresh `rent` token exists. + +## Security model (why the agent can't self-authorize) + +The token is minted ONLY by the human typing the TUI bang `!sidecar sign rent`. The `!` bang runs +outside the PreToolUse hook, so it mints freely; the agent's own `sidecar sign rent` tool call +routes THROUGH the hook and is denied (`SIGN-SELF-MINT`), as is any write into the store +(`SIGN-FORGE`). So the gate rests on one fact: **the agent cannot emit a `!` bang** (it is +user-only input) — not on the text parser being airtight. This is enforcement within the hook +threat model, not an OS boundary. + +## The agent's protocol when a rent is blocked (`CLOUD-RENT-UNSIGNED`) + +1. Tell the user WHAT you intend to rent: provider · GPU type/count · est. $/hr · purpose · + expected duration (say "estimate" when no exact quote is available — never invent a figure). +2. Ask the user to type, in the prompt: `!sidecar sign rent` +3. WAIT for their confirmation, then retry the SAME rent command ONCE. Do NOT loop — if it + blocks again the token expired or was already consumed; go back to step 1. + +Agent-safe verbs (never blocked): `sidecar sign` (list) · `sidecar sign check rent` · +`sidecar sign clear []`. + +## Semantics + +- **One-shot**: the token is consumed atomically the moment a direct `hexa cloud rent|up` command + is admitted (not on rent success — there is no reliable Bash post-hook, and pre-consume stops + two parallel sessions double-spending one sign). One sign = one rent-issuing command. +- **TTL 10 min**: the guard fires at command LAUNCH, so the TTL need not cover the multi-minute + ssh-ready wait — only the agent's preamble between the user signing and issuing the rent. +- **Indirect rents are blocked, not gated**: a rent hidden inside `bash -c` / `eval` / `ssh` / + `sidecar pool … --` (`CLOUD-RENT-INDIRECT`) can't be seen through — issue the rent as one direct + top-level command so the gate can gate it. +- **No override**: no env-var, flag, or inline marker weakens this. TTL/one-shot are code + constants, not config (a config-tunable TTL would be an opt-out the agent could edit). + +## Phase-2 follow-up (out of this repo's scope, documented not built) + +Text-guard enforcement can't reach base64/eval-constructed commands or a remote pool host that +holds its own provider credentials. The structural closure is to make `hexa cloud rent|up` itself +require a valid `sidecar sign check rent` before provisioning — then scripts, `eval`, and remote +rents all fail without a local token, and the human can still self-sign in their own terminal.