hop: initial project state

This commit is contained in:
Hop
2026-07-11 08:35:07 -07:00
parent ed2cba60db
commit a70773826f
14 changed files with 960 additions and 174 deletions
+123 -5
View File
@@ -7,6 +7,7 @@ import (
"flag"
"fmt"
"io"
"os"
"strings"
)
@@ -14,7 +15,8 @@ const usageText = `Hop — prompt-native version control
Usage:
hop init [path]
hop prompt [--from STATE] [--agent NAME] "instruction"
hop begin [--agent NAME] [--session ID] [--from STATE] (--stdin | --heredoc | "instruction")
hop prompt [--from STATE] [--agent NAME] (--stdin | --heredoc | "instruction")
hop checkpoint STATE
hop check STATE -- COMMAND [ARG...]
hop propose [--summary TEXT] STATE
@@ -34,9 +36,13 @@ Usage:
Add --json anywhere for machine-readable output.
`
const Version = "0.1.0-alpha.1"
const Version = "0.1.0-alpha.2"
func RunCLI(args []string, stdout, stderr io.Writer) int {
return RunCLIWithInput(args, os.Stdin, stdout, stderr)
}
func RunCLIWithInput(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
jsonOutput, args := removeFlag(args, "--json")
if len(args) == 0 || args[0] == "help" || args[0] == "--help" || args[0] == "-h" {
fmt.Fprint(stdout, usageText)
@@ -80,6 +86,64 @@ func RunCLI(args []string, stdout, stderr io.Writer) int {
return 0
}
if command == "begin" {
fs := flag.NewFlagSet("begin", flag.ContinueOnError)
fs.SetOutput(stderr)
from := fs.String("from", "", "continue from an explicit Hop state")
sessionDefault := os.Getenv("CODEX_THREAD_ID")
session := fs.String("session", sessionDefault, "stable interactive-agent session ID")
agentDefault := os.Getenv("HOP_AGENT")
if agentDefault == "" {
if sessionDefault != "" {
agentDefault = "codex"
} else {
agentDefault = "agent"
}
}
agent := fs.String("agent", agentDefault, "agent or harness name")
stdinPrompt := fs.Bool("stdin", false, "read exact prompt bytes from stdin")
heredocPrompt := fs.Bool("heredoc", false, "read prompt from stdin and remove one shell-added final newline")
if err := fs.Parse(commandArgs); err != nil {
return 2
}
message, err := promptMessage(stdin, fs.Args(), *stdinPrompt, *heredocPrompt)
if err != nil {
fmt.Fprintf(stderr, "hop begin: %v\n", err)
return 2
}
service, err := OpenProject(".")
initialized := false
if errors.Is(err, ErrNotHopProject) {
service, _, err = InitProject(ctx, ".")
initialized = err == nil
}
if err != nil {
return printCLIError(err, jsonOutput, stdout, stderr)
}
defer service.Close()
result, err := service.BeginPrompt(ctx, message, *from, *agent, *session)
if err != nil {
return printCLIError(err, jsonOutput, stdout, stderr)
}
begin := BeginResult{PromptResult: result, Initialized: initialized, SessionID: *session}
if jsonOutput {
writeJSON(stdout, map[string]any{"ok": true, "data": begin})
} else {
writeRedactionNotice(stderr, result.Redactions)
if initialized {
fmt.Fprintf(stdout, "Initialized Hop at %s\n", service.Root)
}
if result.Checkpoint != nil {
fmt.Fprintf(stdout, "Checkpointed %s before the follow-up\n", result.Checkpoint.ID)
}
fmt.Fprintf(stdout, "Captured prompt state %s before project effects\nWorkspace: %s\n", result.Prompt.ID, result.Workspace)
fmt.Fprintf(stdout, "Use HOP_STATE_ID=%s HOP_TASK_ID=%s HOP_ATTEMPT_ID=%s for this turn.\n", result.Prompt.ID, result.Task.ID, result.Attempt.ID)
}
return 0
}
service, err := OpenProject(".")
if err != nil {
return printCLIError(err, jsonOutput, stdout, stderr)
@@ -93,20 +157,23 @@ func RunCLI(args []string, stdout, stderr io.Writer) int {
fs.SetOutput(stderr)
from := fs.String("from", "", "continue an existing attempt")
agent := fs.String("agent", "", "agent or harness name")
stdinPrompt := fs.Bool("stdin", false, "read exact prompt bytes from stdin")
heredocPrompt := fs.Bool("heredoc", false, "read prompt from stdin and remove one shell-added final newline")
if err := fs.Parse(commandArgs); err != nil {
return 2
}
if len(fs.Args()) != 1 || strings.TrimSpace(fs.Args()[0]) == "" {
fmt.Fprintln(stderr, "exactly one non-empty prompt argument is required; quote prompts containing spaces")
message, err := promptMessage(stdin, fs.Args(), *stdinPrompt, *heredocPrompt)
if err != nil {
fmt.Fprintf(stderr, "hop prompt: %v\n", err)
return 2
}
message := fs.Args()[0]
result, err := service.CreatePrompt(ctx, message, *from, *agent)
if err != nil {
return printCLIError(err, jsonOutput, stdout, stderr)
}
value = result
if !jsonOutput {
writeRedactionNotice(stderr, result.Redactions)
if result.Checkpoint != nil {
fmt.Fprintf(stdout, "Checkpointed %s before the follow-up\n", result.Checkpoint.ID)
}
@@ -452,6 +519,57 @@ func splitOptionalCommand(args []string) (string, []string, bool) {
return "", nil, false
}
const maxPromptBytes = 16 << 20
func promptMessage(stdin io.Reader, args []string, rawStdin, heredoc bool) (string, error) {
if rawStdin && heredoc {
return "", errors.New("use only one of --stdin or --heredoc")
}
if !rawStdin && !heredoc {
if len(args) != 1 || strings.TrimSpace(args[0]) == "" {
return "", errors.New("provide exactly one non-empty prompt argument, or use --stdin/--heredoc")
}
return args[0], nil
}
if len(args) != 0 {
return "", errors.New("do not combine a prompt argument with --stdin or --heredoc")
}
data, err := io.ReadAll(io.LimitReader(stdin, maxPromptBytes+1))
if err != nil {
return "", fmt.Errorf("read prompt from stdin: %w", err)
}
if len(data) > maxPromptBytes {
return "", fmt.Errorf("prompt exceeds %d bytes", maxPromptBytes)
}
message := string(data)
if heredoc {
if strings.HasSuffix(message, "\r\n") {
message = strings.TrimSuffix(message, "\r\n")
} else {
message = strings.TrimSuffix(message, "\n")
}
}
if strings.TrimSpace(message) == "" {
return "", errors.New("prompt text is required")
}
return message, nil
}
func writeRedactionNotice(w io.Writer, redactions []PromptRedaction) {
total := 0
for _, redaction := range redactions {
total += redaction.Count
}
if total == 0 {
return
}
noun := "credentials"
if total == 1 {
noun = "credential"
}
fmt.Fprintf(w, "Warning: redacted %d potential %s before storing the prompt.\n", total, noun)
}
func writeJSON(w io.Writer, value any) {
encoder := json.NewEncoder(w)
encoder.SetIndent("", " ")
+97 -24
View File
@@ -15,7 +15,7 @@ import (
_ "modernc.org/sqlite"
)
const schemaVersion = 1
const schemaVersion = 2
var (
ErrNotFound = errors.New("hop: not found")
@@ -137,20 +137,21 @@ type migration struct {
statements []string
}
var migrations = []migration{{
version: 1,
statements: []string{
`CREATE TABLE IF NOT EXISTS meta (
var migrations = []migration{
{
version: 1,
statements: []string{
`CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
) STRICT`,
`CREATE TABLE IF NOT EXISTS tasks (
`CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
status TEXT NOT NULL,
created_at TEXT NOT NULL
) STRICT`,
`CREATE TABLE IF NOT EXISTS states (
`CREATE TABLE IF NOT EXISTS states (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED,
@@ -164,7 +165,7 @@ var migrations = []migration{{
digest TEXT NOT NULL,
created_at TEXT NOT NULL
) STRICT`,
`CREATE TABLE IF NOT EXISTS attempts (
`CREATE TABLE IF NOT EXISTS attempts (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
agent TEXT NOT NULL,
@@ -174,7 +175,7 @@ var migrations = []migration{{
status TEXT NOT NULL,
created_at TEXT NOT NULL
) STRICT`,
`CREATE TABLE IF NOT EXISTS state_parents (
`CREATE TABLE IF NOT EXISTS state_parents (
state_id TEXT NOT NULL REFERENCES states(id) ON DELETE CASCADE,
parent_state_id TEXT NOT NULL REFERENCES states(id) ON DELETE RESTRICT,
role TEXT NOT NULL,
@@ -182,7 +183,7 @@ var migrations = []migration{{
PRIMARY KEY (state_id, parent_order),
UNIQUE (state_id, parent_state_id, role)
) STRICT`,
`CREATE TABLE IF NOT EXISTS checks (
`CREATE TABLE IF NOT EXISTS checks (
id TEXT PRIMARY KEY,
attempt_id TEXT NOT NULL REFERENCES attempts(id) ON DELETE CASCADE,
state_id TEXT REFERENCES states(id) ON DELETE SET NULL,
@@ -192,7 +193,7 @@ var migrations = []migration{{
output TEXT NOT NULL,
created_at TEXT NOT NULL
) STRICT`,
`CREATE TABLE IF NOT EXISTS events (
`CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
@@ -201,20 +202,35 @@ var migrations = []migration{{
payload_json TEXT NOT NULL,
created_at TEXT NOT NULL
) STRICT`,
`CREATE INDEX IF NOT EXISTS states_task_created_idx ON states(task_id, created_at, id)`,
`CREATE INDEX IF NOT EXISTS states_attempt_created_idx ON states(attempt_id, created_at, id)`,
`CREATE INDEX IF NOT EXISTS states_kind_created_idx ON states(kind, created_at, id)`,
`CREATE INDEX IF NOT EXISTS state_parents_parent_idx ON state_parents(parent_state_id, state_id)`,
`CREATE INDEX IF NOT EXISTS state_parents_role_idx ON state_parents(state_id, role, parent_order)`,
`CREATE INDEX IF NOT EXISTS attempts_task_created_idx ON attempts(task_id, created_at, id)`,
`CREATE INDEX IF NOT EXISTS attempts_status_created_idx ON attempts(status, created_at, id)`,
`CREATE INDEX IF NOT EXISTS checks_attempt_tree_idx ON checks(attempt_id, tree_hash, created_at, id)`,
`CREATE INDEX IF NOT EXISTS checks_tree_idx ON checks(tree_hash, created_at, id)`,
`CREATE INDEX IF NOT EXISTS events_created_idx ON events(created_at, id)`,
`CREATE INDEX IF NOT EXISTS events_task_idx ON events(task_id, created_at, id)`,
`CREATE INDEX IF NOT EXISTS events_attempt_idx ON events(attempt_id, created_at, id)`,
`CREATE INDEX IF NOT EXISTS states_task_created_idx ON states(task_id, created_at, id)`,
`CREATE INDEX IF NOT EXISTS states_attempt_created_idx ON states(attempt_id, created_at, id)`,
`CREATE INDEX IF NOT EXISTS states_kind_created_idx ON states(kind, created_at, id)`,
`CREATE INDEX IF NOT EXISTS state_parents_parent_idx ON state_parents(parent_state_id, state_id)`,
`CREATE INDEX IF NOT EXISTS state_parents_role_idx ON state_parents(state_id, role, parent_order)`,
`CREATE INDEX IF NOT EXISTS attempts_task_created_idx ON attempts(task_id, created_at, id)`,
`CREATE INDEX IF NOT EXISTS attempts_status_created_idx ON attempts(status, created_at, id)`,
`CREATE INDEX IF NOT EXISTS checks_attempt_tree_idx ON checks(attempt_id, tree_hash, created_at, id)`,
`CREATE INDEX IF NOT EXISTS checks_tree_idx ON checks(tree_hash, created_at, id)`,
`CREATE INDEX IF NOT EXISTS events_created_idx ON events(created_at, id)`,
`CREATE INDEX IF NOT EXISTS events_task_idx ON events(task_id, created_at, id)`,
`CREATE INDEX IF NOT EXISTS events_attempt_idx ON events(attempt_id, created_at, id)`,
},
},
}}
{
version: 2,
statements: []string{
`CREATE TABLE IF NOT EXISTS agent_sessions (
agent TEXT NOT NULL,
session_id TEXT NOT NULL,
head_state_id TEXT NOT NULL REFERENCES states(id) ON DELETE RESTRICT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (agent, session_id)
) STRICT`,
`CREATE INDEX IF NOT EXISTS agent_sessions_head_idx ON agent_sessions(head_state_id)`,
},
},
}
func (s *Store) migrate(ctx context.Context) error {
var current int
@@ -337,6 +353,48 @@ func (s *Store) IsInitialized(ctx context.Context) (bool, error) {
return err == nil, err
}
// AgentSessionHead returns the latest prompt state associated with an agent
// session. Session heads let interactive agents turn follow-up messages into
// descendants without asking the human to carry Hop state IDs between turns.
func (s *Store) AgentSessionHead(ctx context.Context, agent, sessionID string) (string, bool, error) {
if strings.TrimSpace(agent) == "" || strings.TrimSpace(sessionID) == "" {
return "", false, errors.New("hop: agent and session ID are required")
}
var stateID string
err := s.db.QueryRowContext(ctx,
`SELECT head_state_id FROM agent_sessions WHERE agent = ? AND session_id = ?`,
agent, sessionID).Scan(&stateID)
if errors.Is(err, sql.ErrNoRows) {
return "", false, nil
}
if err != nil {
return "", false, fmt.Errorf("read agent session head: %w", err)
}
return stateID, true, nil
}
// SetAgentSessionHead records the prompt state that should parent the next
// message in an interactive agent session.
func (s *Store) SetAgentSessionHead(ctx context.Context, agent, sessionID, stateID string) error {
if strings.TrimSpace(agent) == "" || strings.TrimSpace(sessionID) == "" || strings.TrimSpace(stateID) == "" {
return errors.New("hop: agent, session ID, and state ID are required")
}
if redacted, findings := RedactPromptSecrets(agent + "\n" + sessionID); len(findings) > 0 || redacted != agent+"\n"+sessionID {
return errors.New("hop: refusing to persist a potential credential in agent session metadata")
}
now := formatTime(time.Now().UTC())
if _, err := s.db.ExecContext(ctx,
`INSERT INTO agent_sessions(agent, session_id, head_state_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(agent, session_id) DO UPDATE SET
head_state_id = excluded.head_state_id,
updated_at = excluded.updated_at`,
agent, sessionID, stateID, now, now); err != nil {
return fmt.Errorf("record agent session head: %w", err)
}
return nil
}
// CreateTaskAttemptPrompt atomically creates all three records. The prompt is
// durable and the attempt head points to it before the transaction becomes
// visible, so an orchestrator may safely deliver the prompt after this returns.
@@ -360,6 +418,7 @@ func (s *Store) CreateTaskAttemptPrompt(
if task.Title == "" {
task.Title = prompt.Prompt
}
task.Title, _ = RedactPromptSecrets(task.Title)
if attempt.ID == "" {
attempt.ID = newID("at")
}
@@ -946,6 +1005,8 @@ func (s *Store) AddCheck(ctx context.Context, check Check) (Check, error) {
if check.CreatedAt.IsZero() {
check.CreatedAt = time.Now().UTC()
}
check.Command, _ = redactSecretStrings(check.Command)
check.Output, _ = RedactPromptSecrets(check.Output)
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return Check{}, fmt.Errorf("begin check creation: %w", err)
@@ -1345,6 +1406,15 @@ func insertStateTx(ctx context.Context, tx *sql.Tx, state State, parents []Paren
if state.CreatedAt.IsZero() {
return errors.New("hop: state creation time is required")
}
for name, value := range map[string]string{
"prompt": state.Prompt,
"summary": state.Summary,
"agent": state.Agent,
} {
if redacted, findings := RedactPromptSecrets(value); len(findings) > 0 || redacted != value {
return fmt.Errorf("hop: refusing to persist an unredacted credential in state %s", name)
}
}
_, err := tx.ExecContext(ctx,
`INSERT INTO states(
id, kind, task_id, attempt_id, canonical_anchor_id, source_tree, git_commit,
@@ -1386,6 +1456,9 @@ func insertEventTx(ctx context.Context, tx *sql.Tx, event Event, payload any) er
return fmt.Errorf("encode event payload: %w", err)
}
}
if redacted, findings := RedactPromptSecrets(string(encoded)); len(findings) > 0 || redacted != string(encoded) {
return fmt.Errorf("hop: refusing to persist an unredacted credential in event %q", event.Kind)
}
_, err = tx.ExecContext(ctx,
`INSERT INTO events(id, kind, task_id, attempt_id, state_id, payload_json, created_at)
VALUES (?, ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), ?, ?)`,
+18 -6
View File
@@ -86,13 +86,25 @@ type AcceptResult struct {
Warnings []string `json:"warnings,omitempty"`
}
type PromptRedaction struct {
Kind string `json:"kind"`
Count int `json:"count"`
}
type PromptResult struct {
Prompt State `json:"prompt"`
Checkpoint *State `json:"checkpoint,omitempty"`
Task Task `json:"task"`
Attempt Attempt `json:"attempt"`
Workspace string `json:"workspace"`
Deliver []string `json:"deliver"`
Prompt State `json:"prompt"`
Checkpoint *State `json:"checkpoint,omitempty"`
Task Task `json:"task"`
Attempt Attempt `json:"attempt"`
Workspace string `json:"workspace"`
Deliver []string `json:"deliver"`
Redactions []PromptRedaction `json:"redactions,omitempty"`
}
type BeginResult struct {
PromptResult
Initialized bool `json:"initialized"`
SessionID string `json:"session_id,omitempty"`
}
type EnvironmentResult struct {
+245
View File
@@ -0,0 +1,245 @@
package hop
import (
"math"
"regexp"
"sort"
"strings"
)
const redactedMarkerPrefix = "[REDACTED:"
type secretPattern struct {
pattern *regexp.Regexp
group int
kind string
priority int
}
type secretCandidate struct {
start int
end int
kind string
priority int
}
var promptSecretPatterns = []secretPattern{
{
pattern: regexp.MustCompile(`(?s)-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?-----END [A-Z0-9 ]*PRIVATE KEY-----`),
kind: "private_key",
priority: 120,
},
{
pattern: regexp.MustCompile(`(?s)<secret>.*?</secret>`),
kind: "explicit_secret",
priority: 120,
},
{
pattern: regexp.MustCompile(`\bsk-ant-[A-Za-z0-9_-]{20,}\b`),
kind: "api_key",
priority: 110,
},
{
pattern: regexp.MustCompile(`\bsk-(?:proj-|svcacct-)?[A-Za-z0-9_-]{20,}\b`),
kind: "api_key",
priority: 110,
},
{
pattern: regexp.MustCompile(`\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})\b`),
kind: "access_token",
priority: 110,
},
{
pattern: regexp.MustCompile(`\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}\b`),
kind: "api_key",
priority: 110,
},
{
pattern: regexp.MustCompile(`\bAIza[0-9A-Za-z_-]{30,}\b`),
kind: "api_key",
priority: 110,
},
{
pattern: regexp.MustCompile(`\bxox[baprs]-[A-Za-z0-9-]{20,}\b`),
kind: "access_token",
priority: 110,
},
{
pattern: regexp.MustCompile(`\b(?:AKIA|ASIA|AIDA|AROA|AIPA|ANPA|ANVA|AGPA)[A-Z0-9]{16}\b`),
kind: "access_key",
priority: 110,
},
{
pattern: regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b`),
kind: "auth_token",
priority: 105,
},
{
pattern: regexp.MustCompile(`(?i)(authorization[ \t]*:[ \t]*(?:bearer|token|basic)[ \t]+)([A-Za-z0-9._~+/=-]{12,})`),
group: 2,
kind: "auth_token",
priority: 100,
},
{
pattern: regexp.MustCompile(`(?i)\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|https?)://([^@\s/]+)@`),
group: 1,
kind: "connection_credential",
priority: 100,
},
{
pattern: regexp.MustCompile(
`(?i)\b[a-z0-9_.-]*(?:api[ _-]?key|access[ _-]?key|secret[ _-]?key|access[ _-]?token|auth[ _-]?token|client[ _-]?secret|api[ _-]?secret|password|passwd|credential|token|secret|key)\b[ \t]*(?:=|:|is\b)[ \t]*["']?([^\s"'` + "`" + `]{12,})`,
),
group: 1,
kind: "credential",
priority: 80,
},
}
// RedactPromptSecrets removes high-confidence credentials before a prompt is
// passed to any durable Hop component. It returns only category/count metadata;
// secret values, hashes, and byte positions are deliberately discarded.
func RedactPromptSecrets(prompt string) (string, []PromptRedaction) {
if prompt == "" {
return prompt, nil
}
var candidates []secretCandidate
for _, detector := range promptSecretPatterns {
for _, match := range detector.pattern.FindAllStringSubmatchIndex(prompt, -1) {
index := detector.group * 2
if index+1 >= len(match) || match[index] < 0 || match[index+1] <= match[index] {
continue
}
start, end := match[index], match[index+1]
if detector.kind == "credential" {
for end > start && strings.ContainsRune(".,;:!?)]}", rune(prompt[end-1])) {
end--
}
}
if end <= start || strings.HasPrefix(prompt[start:end], redactedMarkerPrefix) {
continue
}
if detector.kind == "credential" && !likelyCredentialValue(prompt[start:end]) {
continue
}
candidates = append(candidates, secretCandidate{
start: start,
end: end,
kind: detector.kind,
priority: detector.priority,
})
}
}
if len(candidates) == 0 {
return prompt, nil
}
sort.Slice(candidates, func(i, j int) bool {
if candidates[i].priority != candidates[j].priority {
return candidates[i].priority > candidates[j].priority
}
if candidates[i].start != candidates[j].start {
return candidates[i].start < candidates[j].start
}
return candidates[i].end > candidates[j].end
})
selected := make([]secretCandidate, 0, len(candidates))
for _, candidate := range candidates {
overlaps := false
for _, existing := range selected {
if candidate.start < existing.end && existing.start < candidate.end {
overlaps = true
break
}
}
if !overlaps {
selected = append(selected, candidate)
}
}
sort.Slice(selected, func(i, j int) bool { return selected[i].start < selected[j].start })
counts := make(map[string]int)
var redacted strings.Builder
cursor := 0
for _, candidate := range selected {
redacted.WriteString(prompt[cursor:candidate.start])
redacted.WriteString(redactedMarkerPrefix)
redacted.WriteString(candidate.kind)
redacted.WriteByte(']')
counts[candidate.kind]++
cursor = candidate.end
}
redacted.WriteString(prompt[cursor:])
kinds := make([]string, 0, len(counts))
for kind := range counts {
kinds = append(kinds, kind)
}
sort.Strings(kinds)
findings := make([]PromptRedaction, 0, len(kinds))
for _, kind := range kinds {
findings = append(findings, PromptRedaction{Kind: kind, Count: counts[kind]})
}
return redacted.String(), findings
}
func redactSecretStrings(values []string) ([]string, []PromptRedaction) {
redacted := make([]string, len(values))
counts := make(map[string]int)
for index, value := range values {
var findings []PromptRedaction
redacted[index], findings = RedactPromptSecrets(value)
for _, finding := range findings {
counts[finding.Kind] += finding.Count
}
}
kinds := make([]string, 0, len(counts))
for kind := range counts {
kinds = append(kinds, kind)
}
sort.Strings(kinds)
findings := make([]PromptRedaction, 0, len(kinds))
for _, kind := range kinds {
findings = append(findings, PromptRedaction{Kind: kind, Count: counts[kind]})
}
return redacted, findings
}
func likelyCredentialValue(value string) bool {
if len(value) < 12 {
return false
}
classes := 0
var lower, upper, digit, symbol bool
counts := make(map[byte]int)
for index := 0; index < len(value); index++ {
character := value[index]
counts[character]++
switch {
case character >= 'a' && character <= 'z':
lower = true
case character >= 'A' && character <= 'Z':
upper = true
case character >= '0' && character <= '9':
digit = true
default:
symbol = true
}
}
for _, present := range []bool{lower, upper, digit, symbol} {
if present {
classes++
}
}
if classes < 2 {
return false
}
entropy := 0.0
length := float64(len(value))
for _, count := range counts {
probability := float64(count) / length
entropy -= probability * math.Log2(probability)
}
return entropy >= 3.0
}
+141
View File
@@ -0,0 +1,141 @@
package hop
import (
"bytes"
"context"
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
)
func TestRedactPromptSecrets(t *testing.T) {
apiKey := "sk-proj-" + strings.Repeat("A7", 24)
githubToken := "ghp_" + strings.Repeat("b9", 20)
bearer := strings.Repeat("abcDEF123._-", 3)
generic := strings.Repeat("customKey9", 4)
privateKey := "-----BEGIN PRIVATE KEY-----\n" + strings.Repeat("base64material", 4) + "\n-----END PRIVATE KEY-----"
tests := []struct {
name string
prompt string
secret string
marker string
}{
{"provider API key", "Use " + apiKey + " for this request", apiKey, "[REDACTED:api_key]"},
{"provider access token", "token=" + githubToken, githubToken, "[REDACTED:access_token]"},
{"authorization header", "Authorization: Bearer " + bearer, bearer, "[REDACTED:auth_token]"},
{"contextual generic key", "OPENAI_API_KEY=\"" + generic + "\"", generic, "[REDACTED:credential]"},
{"natural language key", "my api key is " + generic, generic, "[REDACTED:credential]"},
{"private key", "deploy with\n" + privateKey + "\nnow", privateKey, "[REDACTED:private_key]"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
redacted, findings := RedactPromptSecrets(test.prompt)
if strings.Contains(redacted, test.secret) {
t.Fatalf("redacted prompt still contains secret: %q", redacted)
}
if !strings.Contains(redacted, test.marker) {
t.Fatalf("redacted prompt = %q, want marker %q", redacted, test.marker)
}
if len(findings) == 0 {
t.Fatal("redaction metadata is empty")
}
})
}
plain := "Read OPENAI_API_KEY from the environment; the key is configuration; inspect commit 0123456789abcdef0123456789abcdef."
if redacted, findings := RedactPromptSecrets(plain); redacted != plain || len(findings) != 0 {
t.Fatalf("plain prompt changed to %q with findings %#v", redacted, findings)
}
}
func TestPromptSecretNeverReachesDurableProjectBytes(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "base.txt"), "base\n")
service, _, err := InitProject(context.Background(), root)
if err != nil {
t.Fatal(err)
}
secret := "sk-proj-" + strings.Repeat("NeverPersist7", 4)
result, err := service.CreatePrompt(context.Background(), "Use this API key: "+secret, "", "test")
if err != nil {
t.Fatal(err)
}
if len(result.Redactions) != 1 || result.Redactions[0].Count != 1 {
t.Fatalf("redactions = %#v, want one finding", result.Redactions)
}
if strings.Contains(result.Prompt.Prompt, secret) || !strings.Contains(result.Prompt.Prompt, redactedMarkerPrefix) {
t.Fatalf("stored prompt was not redacted: %q", result.Prompt.Prompt)
}
check, err := service.RunCheck(context.Background(), result.Prompt.ID, []string{
"sh", "-c", "printf '%s' '" + secret + "'",
})
if err != nil {
t.Fatal(err)
}
if strings.Contains(strings.Join(check.Command, " "), secret) || strings.Contains(check.Output, secret) {
t.Fatalf("stored check leaked secret: %#v / %q", check.Command, check.Output)
}
proposal, err := service.Propose(context.Background(), result.Prompt.ID, "Completed with "+secret)
if err != nil {
t.Fatal(err)
}
if strings.Contains(proposal.Proposal.Summary, secret) {
t.Fatalf("stored proposal summary leaked secret: %q", proposal.Proposal.Summary)
}
if err := service.Close(); err != nil {
t.Fatal(err)
}
needle := []byte(secret)
for _, tree := range []string{filepath.Join(root, ".hop"), filepath.Join(root, ".git", "refs", "hop")} {
err := filepath.WalkDir(tree, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() {
return nil
}
contents, readErr := os.ReadFile(path)
if readErr != nil {
return readErr
}
if bytes.Contains(contents, needle) {
t.Errorf("secret leaked into durable file %s", path)
}
return nil
})
if err != nil && !os.IsNotExist(err) {
t.Fatal(err)
}
}
}
func TestBeginJSONNeverEchoesPromptSecret(t *testing.T) {
root := t.TempDir()
previousDirectory, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(root); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(previousDirectory) })
secret := "ghp_" + strings.Repeat("NoEcho7", 6)
var stdout, stderr bytes.Buffer
code := RunCLIWithInput(
[]string{"begin", "--agent", "codex", "--session", "secret-test", "--stdin", "--json"},
strings.NewReader("Use token="+secret), &stdout, &stderr)
if code != 0 {
t.Fatalf("begin exited %d: %s", code, stderr.String())
}
if strings.Contains(stdout.String(), secret) || strings.Contains(stderr.String(), secret) {
t.Fatal("begin output echoed the prompt secret")
}
if !strings.Contains(stdout.String(), redactedMarkerPrefix) || !strings.Contains(stdout.String(), `"redactions"`) {
t.Fatalf("begin JSON omitted redaction evidence: %s", stdout.String())
}
}
+71 -8
View File
@@ -113,7 +113,16 @@ func OpenProject(start string) (*Service, error) {
store.Close()
return nil, err
}
if filepath.Clean(recordedRoot) != filepath.Clean(root) {
recordedInfo, recordedErr := os.Stat(recordedRoot)
rootInfo, rootErr := os.Stat(root)
if recordedErr != nil || rootErr != nil {
store.Close()
if recordedErr != nil {
return nil, fmt.Errorf("inspect recorded Hop root: %w", recordedErr)
}
return nil, fmt.Errorf("inspect discovered Hop root: %w", rootErr)
}
if !os.SameFile(recordedInfo, rootInfo) {
store.Close()
return nil, fmt.Errorf("Hop database belongs to %s, not %s", recordedRoot, root)
}
@@ -137,10 +146,58 @@ func (s *Service) CreatePrompt(ctx context.Context, message, fromStateID, agent
if strings.TrimSpace(message) == "" {
return PromptResult{}, fmt.Errorf("prompt text is required")
}
message, redactions := RedactPromptSecrets(message)
agent, _ = RedactPromptSecrets(agent)
var result PromptResult
var err error
if fromStateID == "" {
return s.createInitialPrompt(ctx, message, agent)
result, err = s.createInitialPrompt(ctx, message, agent)
} else {
result, err = s.createFollowupPrompt(ctx, message, fromStateID, agent)
}
return s.createFollowupPrompt(ctx, message, fromStateID, agent)
if err != nil {
return PromptResult{}, err
}
result.Redactions = redactions
return result, nil
}
// BeginPrompt is the interactive-agent entry point. It resolves follow-up
// ancestry from a stable harness session ID, creates the prompt state before
// project effects, and advances the session pointer for the next turn.
func (s *Service) BeginPrompt(ctx context.Context, message, fromStateID, agent, sessionID string) (PromptResult, error) {
if strings.TrimSpace(agent) == "" {
agent = "agent"
}
if redactedAgent, findings := RedactPromptSecrets(agent); len(findings) > 0 || redactedAgent != agent {
return PromptResult{}, errors.New("hop: refusing to use a potential credential as an agent name")
}
if redactedSession, findings := RedactPromptSecrets(sessionID); len(findings) > 0 || redactedSession != sessionID {
return PromptResult{}, errors.New("hop: refusing to use a potential credential as an agent session ID")
}
release, err := acquireProjectLock(ctx, s.Root, "prompt")
if err != nil {
return PromptResult{}, err
}
defer release()
if fromStateID == "" && sessionID != "" {
if stateID, exists, err := s.Store.AgentSessionHead(ctx, agent, sessionID); err != nil {
return PromptResult{}, err
} else if exists {
fromStateID = stateID
}
}
result, err := s.CreatePrompt(ctx, message, fromStateID, agent)
if err != nil {
return PromptResult{}, err
}
if sessionID != "" {
if err := s.Store.SetAgentSessionHead(ctx, agent, sessionID, result.Prompt.ID); err != nil {
return PromptResult{}, err
}
}
return result, nil
}
func (s *Service) createInitialPrompt(ctx context.Context, message, agent string) (PromptResult, error) {
@@ -364,14 +421,16 @@ func (s *Service) RunCheck(ctx context.Context, stateID string, argv []string) (
if removeErr != nil {
return Check{}, removeErr
}
storedCommand, _ := redactSecretStrings(argv)
storedOutput, _ := RedactPromptSecrets(result.Output)
check := Check{
ID: checkID,
AttemptID: attempt.ID,
StateID: checkpoint.ID,
TreeHash: checkpoint.SourceTree,
Command: append([]string(nil), argv...),
Command: storedCommand,
ExitCode: result.ExitCode,
Output: result.Output,
Output: storedOutput,
CreatedAt: time.Now().UTC(),
}
check, err = s.Store.AddCheck(ctx, check)
@@ -411,6 +470,7 @@ func (s *Service) Propose(ctx context.Context, stateID, summary string) (Proposa
if strings.TrimSpace(summary) == "" {
summary = task.Title
}
summary, _ = RedactPromptSecrets(summary)
parents := canonicalizeParents([]Parent{{StateID: attempt.HeadStateID, Role: "run_parent", Order: 0}})
proposal := State{
ID: newID("r"),
@@ -531,13 +591,15 @@ func (s *Service) Accept(ctx context.Context, proposalID string, checkCommand []
if removeErr != nil {
return AcceptResult{}, removeErr
}
storedCommand, _ := redactSecretStrings(checkCommand)
storedOutput, _ := RedactPromptSecrets(result.Output)
check := Check{
ID: checkID,
AttemptID: proposal.AttemptID,
TreeHash: finalTree,
Command: append([]string(nil), checkCommand...),
Command: storedCommand,
ExitCode: result.ExitCode,
Output: result.Output,
Output: storedOutput,
CreatedAt: time.Now().UTC(),
}
if check.ExitCode != 0 {
@@ -681,6 +743,7 @@ func (s *Service) recordValidationFailure(
{StateID: proposal.ID, Role: "run_parent", Order: 0},
{StateID: current.ID, Role: "integration_parent", Order: 1},
})
storedCommand, _ := redactSecretStrings(command)
failed := State{
ID: newID("f"),
Kind: StateFailed,
@@ -689,7 +752,7 @@ func (s *Service) recordValidationFailure(
CanonicalAnchorID: current.ID,
SourceTree: tree,
GitCommit: commit,
Summary: "Final validation failed: " + shellQuote(command),
Summary: "Final validation failed: " + shellQuote(storedCommand),
Agent: "hop",
CreatedAt: time.Now().UTC(),
Parents: parents,
+86
View File
@@ -120,6 +120,76 @@ func TestCLIJSONWorkflow(t *testing.T) {
}
}
func TestCLIBeginAutoInitializesAndContinuesCodexSession(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "base.txt"), "base\n")
previousDirectory, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(root); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(previousDirectory) })
firstPrompt := " Add a Desktop-safe file\nwithout changing this spacing. "
started := runCLIJSONInputTest(t,
[]string{"begin", "--agent", "codex", "--session", "thread-test", "--heredoc", "--json"},
firstPrompt+"\n")
first := objectField(t, started, "data")
if initialized, _ := first["initialized"].(bool); !initialized {
t.Fatal("begin did not report automatic Hop initialization")
}
firstState := objectField(t, first, "prompt")
if got := stringField(t, firstState, "prompt"); got != firstPrompt {
t.Fatalf("heredoc prompt = %q, want %q", got, firstPrompt)
}
firstAttempt := objectField(t, first, "attempt")
workspace := stringField(t, first, "workspace")
writeTestFile(t, filepath.Join(workspace, "desktop.txt"), "first turn\n")
followupPrompt := "Now preserve this final newline.\n"
followed := runCLIJSONInputTest(t,
[]string{"begin", "--agent", "codex", "--session", "thread-test", "--stdin", "--json"},
followupPrompt)
second := objectField(t, followed, "data")
if initialized, _ := second["initialized"].(bool); initialized {
t.Fatal("follow-up begin unexpectedly reinitialized Hop")
}
secondState := objectField(t, second, "prompt")
if got := stringField(t, secondState, "prompt"); got != followupPrompt {
t.Fatalf("stdin prompt = %q, want %q", got, followupPrompt)
}
secondAttempt := objectField(t, second, "attempt")
if stringField(t, secondAttempt, "id") != stringField(t, firstAttempt, "id") {
t.Fatal("Codex session follow-up created a new attempt")
}
if stringField(t, second, "workspace") != workspace {
t.Fatal("Codex session follow-up changed workspaces")
}
checkpoint := objectField(t, second, "checkpoint")
if stringField(t, checkpoint, "kind") != string(StateCheckpoint) {
t.Fatal("Codex session follow-up did not checkpoint prior effects")
}
service, err := OpenProject(root)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = service.Close() })
head, exists, err := service.Store.AgentSessionHead(context.Background(), "codex", "thread-test")
if err != nil {
t.Fatal(err)
}
if !exists || head != stringField(t, secondState, "id") {
t.Fatalf("session head = %q, %v; want second prompt", head, exists)
}
assertTreeFiles(t, service, stringField(t, checkpoint, "git_commit"), map[string]string{
"base.txt": "base\n",
"desktop.txt": "first turn\n",
})
}
func TestDoctorRejectsCommitTreeAndDigestMismatch(t *testing.T) {
ctx := context.Background()
service, initial := newTestProject(t, map[string]string{"base.txt": "base\n"})
@@ -559,6 +629,22 @@ func runCLIJSONTest(t *testing.T, args []string) map[string]any {
return result
}
func runCLIJSONInputTest(t *testing.T, args []string, input string) map[string]any {
t.Helper()
var stdout, stderr bytes.Buffer
if code := RunCLIWithInput(args, strings.NewReader(input), &stdout, &stderr); code != 0 {
t.Fatalf("hop %s exited %d\nstdout: %s\nstderr: %s", strings.Join(args, " "), code, stdout.String(), stderr.String())
}
var result map[string]any
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
t.Fatalf("decode JSON from hop %s: %v\n%s", strings.Join(args, " "), err, stdout.String())
}
if ok, _ := result["ok"].(bool); !ok {
t.Fatalf("hop %s returned non-ok JSON: %#v", strings.Join(args, " "), result)
}
return result
}
func objectField(t *testing.T, object map[string]any, key string) map[string]any {
t.Helper()
value, ok := object[key].(map[string]any)
+8 -1
View File
@@ -26,6 +26,13 @@ func TestInstallSkillBundle(t *testing.T) {
t.Fatalf("installed %s is empty", relative)
}
}
metadata, err := os.ReadFile(filepath.Join(result.Path, "agents", "openai.yaml"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(metadata), "allow_implicit_invocation: true") {
t.Fatal("installed skill does not permit Desktop implicit invocation")
}
if _, err := InstallSkill(base, false); err == nil || !strings.Contains(err.Error(), "already exists") {
t.Fatalf("second install error = %v, want existing-skill error", err)
}
@@ -57,7 +64,7 @@ func TestSkillCLIWorksOutsideHopProject(t *testing.T) {
stdout.Reset()
stderr.Reset()
code = RunCLI([]string{"skill", "print"}, &stdout, &stderr)
if code != 0 || !strings.Contains(stdout.String(), "Require a durable Hop prompt state") {
if code != 0 || !strings.Contains(stdout.String(), "Capture the current prompt first") {
t.Fatalf("skill print exited %d\nstdout: %s\nstderr: %s", code, stdout.String(), stderr.String())
}
}
+3 -1
View File
@@ -20,6 +20,8 @@ import (
const maxRecordedOutput = 128 * 1024
var ErrNotHopProject = errors.New("not inside a Hop project")
func FindHopRoot(start string) (string, error) {
if configured := os.Getenv("HOP_ROOT"); configured != "" {
return requireHopRoot(configured)
@@ -48,7 +50,7 @@ func FindHopRoot(start string) (string, error) {
break
}
}
return "", fmt.Errorf("not inside a Hop project; run 'hop init' first")
return "", fmt.Errorf("%w; run 'hop init' first", ErrNotHopProject)
}
func requireHopRoot(root string) (string, error) {