From 6966eb4c28c4070e7d96f0f43c806c575b650d9a Mon Sep 17 00:00:00 2001 From: Steve Whittaker Date: Tue, 20 Jan 2026 15:11:00 -0600 Subject: [PATCH] Escape backticks and dollar signs in quoteForShell (#777) * Escape backticks and dollar signs in quoteForShell * Sync embedded formulas with .beads/formulas --- internal/config/loader_test.go | 59 +++++++++++++++++++ internal/config/types.go | 8 ++- .../formulas/mol-deacon-patrol.formula.toml | 57 +++++------------- internal/polecat/namepool.go | 2 +- 4 files changed, 83 insertions(+), 43 deletions(-) diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index 06f86759..086d78d2 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -2603,3 +2603,62 @@ func TestBuildStartupCommandWithAgentOverride_IncludesGTRoot(t *testing.T) { t.Errorf("expected GT_ROOT=%s in command, got: %q", townRoot, cmd) } } + +func TestQuoteForShell(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + want string + }{ + { + name: "simple string", + input: "hello", + want: `"hello"`, + }, + { + name: "string with double quote", + input: `say "hello"`, + want: `"say \"hello\""`, + }, + { + name: "string with backslash", + input: `path\to\file`, + want: `"path\\to\\file"`, + }, + { + name: "string with backtick", + input: "run `cmd`", + want: "\"run \\`cmd\\`\"", + }, + { + name: "string with dollar sign", + input: "cost is $100", + want: `"cost is \$100"`, + }, + { + name: "variable expansion prevented", + input: "$HOME/path", + want: `"\$HOME/path"`, + }, + { + name: "empty string", + input: "", + want: `""`, + }, + { + name: "combined special chars", + input: "`$HOME`", + want: "\"\\`\\$HOME\\`\"", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := quoteForShell(tt.input) + if got != tt.want { + t.Errorf("quoteForShell(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} diff --git a/internal/config/types.go b/internal/config/types.go index fbb1c519..57a9de6b 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -580,9 +580,15 @@ func defaultInstructionsFile(provider string) string { // quoteForShell quotes a string for safe shell usage. func quoteForShell(s string) string { - // Simple quoting: wrap in double quotes, escape internal quotes + // Wrap in double quotes, escaping characters that are special in double-quoted strings: + // - backslash (escape character) + // - double quote (string delimiter) + // - backtick (command substitution) + // - dollar sign (variable expansion) escaped := strings.ReplaceAll(s, `\`, `\\`) escaped = strings.ReplaceAll(escaped, `"`, `\"`) + escaped = strings.ReplaceAll(escaped, "`", "\\`") + escaped = strings.ReplaceAll(escaped, "$", `\$`) return `"` + escaped + `"` } diff --git a/internal/formula/formulas/mol-deacon-patrol.formula.toml b/internal/formula/formulas/mol-deacon-patrol.formula.toml index 7ec83e38..1c357490 100644 --- a/internal/formula/formulas/mol-deacon-patrol.formula.toml +++ b/internal/formula/formulas/mol-deacon-patrol.formula.toml @@ -665,71 +665,46 @@ Skip dispatch - system is healthy. [[steps]] id = "costs-digest" -title = "Aggregate daily costs [DISABLED]" +title = "Aggregate daily costs" needs = ["session-gc"] description = """ -**⚠️ DISABLED** - Skip this step entirely. +**DAILY DIGEST** - Aggregate yesterday's session cost wisps. -Cost tracking is temporarily disabled because Claude Code does not expose -session costs in a way that can be captured programmatically. - -**Why disabled:** -- The `gt costs` command uses tmux capture-pane to find costs -- Claude Code displays costs in the TUI status bar, not in scrollback -- All sessions show $0.00 because capture-pane can't see TUI chrome -- The infrastructure is sound but has no data source - -**What we need from Claude Code:** -- Stop hook env var (e.g., `$CLAUDE_SESSION_COST`) -- Or queryable file/API endpoint - -**Re-enable when:** Claude Code exposes cost data via API or environment. - -See: GH#24, gt-7awfj - -**Exit criteria:** Skip this step - proceed to next.""" - -[[steps]] -id = "patrol-digest" -title = "Aggregate daily patrol digests" -needs = ["costs-digest"] -description = """ -**DAILY DIGEST** - Aggregate yesterday's patrol cycle digests. - -Patrol cycles (Deacon, Witness, Refinery) create ephemeral per-cycle digests -to avoid JSONL pollution. This step aggregates them into a single permanent -"Patrol Report YYYY-MM-DD" bead for audit purposes. +Session costs are recorded as ephemeral wisps (not exported to JSONL) to avoid +log-in-database pollution. This step aggregates them into a permanent daily +"Cost Report YYYY-MM-DD" bead for audit purposes. **Step 1: Check if digest is needed** ```bash -# Preview yesterday's patrol digests (dry run) -gt patrol digest --yesterday --dry-run +# Preview yesterday's costs (dry run) +gt costs digest --yesterday --dry-run ``` -If output shows "No patrol digests found", skip to Step 3. +If output shows "No session cost wisps found", skip to Step 3. **Step 2: Create the digest** ```bash -gt patrol digest --yesterday +gt costs digest --yesterday ``` This: -- Queries all ephemeral patrol digests from yesterday -- Creates a single "Patrol Report YYYY-MM-DD" bead with aggregated data -- Deletes the source digests +- Queries all session.ended wisps from yesterday +- Creates a single "Cost Report YYYY-MM-DD" bead with aggregated data +- Deletes the source wisps **Step 3: Verify** -Daily patrol digests preserve audit trail without per-cycle pollution. +The digest appears in `gt costs --week` queries. +Daily digests preserve audit trail without per-session pollution. **Timing**: Run once per morning patrol cycle. The --yesterday flag ensures we don't try to digest today's incomplete data. -**Exit criteria:** Yesterday's patrol digests aggregated (or none to aggregate).""" +**Exit criteria:** Yesterday's costs digested (or no wisps to digest).""" [[steps]] id = "log-maintenance" title = "Rotate logs and prune state" -needs = ["patrol-digest"] +needs = ["costs-digest"] description = """ Maintain daemon logs and state files. diff --git a/internal/polecat/namepool.go b/internal/polecat/namepool.go index bc71f030..ade0be3d 100644 --- a/internal/polecat/namepool.go +++ b/internal/polecat/namepool.go @@ -378,7 +378,7 @@ func ThemeForRig(rigName string) string { for _, b := range []byte(rigName) { hash = hash*31 + uint32(b) } - return themes[hash%uint32(len(themes))] + return themes[hash%uint32(len(themes))] //nolint:gosec // len(themes) is small constant } // GetThemeNames returns the names in a specific theme.