hop: initial project state
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Hop is an experimental prompt-native version-control kernel for coding agents.
|
Hop is an experimental prompt-native version-control kernel for coding agents.
|
||||||
|
|
||||||
Every instruction becomes an immutable state before it is delivered. Agent work produces checkpoint and proposal states beneath it. Landing a proposal creates a new accepted state without moving the user’s Git branch or checkout.
|
Every instruction becomes an immutable state before project effects. Agent work produces checkpoint and proposal states beneath it. Landing a proposal creates a new accepted state without moving the user’s Git branch or checkout. Controller integrations may capture the stronger pre-delivery boundary as well.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
A0 accepted
|
A0 accepted
|
||||||
@@ -22,7 +22,8 @@ Git provides source-tree storage, diffs, worktrees, and interoperability. Hop’
|
|||||||
This repository contains the first local alpha kernel. It supports:
|
This repository contains the first local alpha kernel. It supports:
|
||||||
|
|
||||||
- Existing and unborn Git repositories
|
- Existing and unborn Git repositories
|
||||||
- Pre-delivery prompt states
|
- Codex Desktop first-action prompt capture and session-aware follow-ups
|
||||||
|
- Controller-grade pre-delivery prompt capture
|
||||||
- Isolated detached worktrees per attempt
|
- Isolated detached worktrees per attempt
|
||||||
- Immutable checkpoints and proposals
|
- Immutable checkpoints and proposals
|
||||||
- Checks bound to exact source trees
|
- Checks bound to exact source trees
|
||||||
@@ -33,9 +34,10 @@ This repository contains the first local alpha kernel. It supports:
|
|||||||
- Forward-only undo of the latest accepted transition
|
- Forward-only undo of the latest accepted transition
|
||||||
- Git-compatible accepted commits under `refs/hop/accepted`
|
- Git-compatible accepted commits under `refs/hop/accepted`
|
||||||
- SQLite WAL state graph and machine-readable JSON output
|
- SQLite WAL state graph and machine-readable JSON output
|
||||||
- Embedded, installable vendor-neutral agent skill
|
- Pre-persistence credential redaction across prompts, summaries, and check evidence
|
||||||
|
- Embedded, installable vendor-neutral agent skill with implicit invocation enabled
|
||||||
|
|
||||||
It does not yet include an agent-process adapter, project knowledge, claims, remote synchronization, a GUI, or semantic merging.
|
It does not yet include a trusted raw-prompt hook, project knowledge, claims, remote synchronization, a GUI, or semantic merging.
|
||||||
|
|
||||||
See [the product blueprint](docs/product-blueprint.md) for the complete model,
|
See [the product blueprint](docs/product-blueprint.md) for the complete model,
|
||||||
design principles, and phased roadmap.
|
design principles, and phased roadmap.
|
||||||
@@ -43,20 +45,19 @@ design principles, and phased roadmap.
|
|||||||
## How the pieces fit
|
## How the pieces fit
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Human/controller
|
Prompt in Codex Desktop
|
||||||
│ records exact prompt
|
│
|
||||||
▼
|
▼
|
||||||
hop start ──> prompt state + isolated workspace
|
Hop skill's first action ──> hop begin ──> prompt state + workspace
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
agent + Hop skill
|
edit → check → propose
|
||||||
edit → check → propose
|
│
|
||||||
│
|
▼
|
||||||
▼
|
Human review → land → accepted state
|
||||||
Human/controller review → land → accepted state
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The controller owns prompt delivery and acceptance. The skill teaches the agent how to behave inside the assigned state. The Hop kernel computes and stores trees, evidence, ancestry, conflicts, and accepted history. A skill alone cannot guarantee that an initial prompt was saved before the agent saw it; `hop start` or a future process adapter must create that boundary first.
|
The user continues typing into Codex Desktop normally. The skill invokes `hop begin` before inspecting or changing the project. `hop begin` initializes Hop when needed, uses `CODEX_THREAD_ID` to recognize follow-ups, checkpoints prior effects, and returns the isolated workspace. The skill then confines work to that workspace. This is a pre-project-effect boundary: the prompt is stored after Codex receives it but before the agent performs project work. A controller or future trusted prompt hook can provide strict pre-delivery capture.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
@@ -81,28 +82,17 @@ Export it to another agent’s skills directory with:
|
|||||||
hop skill install --path /path/to/agent/skills
|
hop skill install --path /path/to/agent/skills
|
||||||
```
|
```
|
||||||
|
|
||||||
Initialize Hop without changing the current Git branch, working tree, or index:
|
Restart Codex after installation if the skill is not yet visible. Then use Codex Desktop normally. The skill is configured for implicit invocation on every local repository turn. Its first project action is equivalent to:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
hop init
|
hop begin --agent codex --heredoc <<'HOP_PROMPT_EOF'
|
||||||
|
<the current user message>
|
||||||
|
HOP_PROMPT_EOF
|
||||||
```
|
```
|
||||||
|
|
||||||
Create a prompt state and isolated workspace. The string passed to `hop start` must be the same instruction delivered to the agent:
|
The agent—not the user—runs this command. It initializes Hop without changing the current Git branch, working tree, or index, stores the prompt, and returns the state IDs and isolated workspace. Follow-up messages in the same Codex task automatically continue the same Hop attempt.
|
||||||
|
|
||||||
```bash
|
Codex chooses implicitly invoked skills from their descriptions. Explicitly mentioning `$hop` remains the deterministic fallback if a task does not activate it automatically.
|
||||||
PROMPT='Use $hop. Add password reset emails'
|
|
||||||
hop start --agent codex "$PROMPT"
|
|
||||||
```
|
|
||||||
|
|
||||||
Pass the prompt as one quoted argument; Hop preserves its bytes, including leading/trailing whitespace and newlines. Hop prints the prompt-state ID, task/attempt IDs, workspace, and environment. The prompt is durable before the command returns, so it is then safe to deliver to an agent.
|
|
||||||
|
|
||||||
Load the environment and enter the isolated workspace using the returned prompt-state ID:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
eval "$(hop env P_...)"
|
|
||||||
```
|
|
||||||
|
|
||||||
Now launch Codex, Claude Code, or another harness from that workspace and deliver `$PROMPT`. Explicitly invoking `$hop` is the most reliable way to load the skill. The skill verifies the Hop state, confines edits to the workspace, records exact-tree checks, and freezes the result as a proposal.
|
|
||||||
|
|
||||||
After the agent edits the printed workspace:
|
After the agent edits the printed workspace:
|
||||||
|
|
||||||
@@ -131,15 +121,17 @@ Inspect or hand the skill to a harness without installing it:
|
|||||||
hop skill print
|
hop skill print
|
||||||
```
|
```
|
||||||
|
|
||||||
Create a follow-up prompt in an existing attempt:
|
For a harness or controller that can capture prompts before delivery, initialize and start explicitly:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
hop prompt --from P_... "Use Resend instead of SendGrid"
|
hop init
|
||||||
|
hop start --agent codex --heredoc <<'HOP_PROMPT_EOF'
|
||||||
|
Add password reset emails
|
||||||
|
HOP_PROMPT_EOF
|
||||||
|
eval "$(hop env P_...)"
|
||||||
```
|
```
|
||||||
|
|
||||||
Hop first captures the workspace as a checkpoint, then writes the follow-up prompt as its child before returning.
|
In this mode, deliver the same message only after `hop start` succeeds. Use `hop prompt --from P_... --heredoc` for controller-managed follow-ups.
|
||||||
|
|
||||||
Load the returned prompt state with `eval "$(hop env P_...)"` before delivering that exact follow-up to the agent.
|
|
||||||
|
|
||||||
Undo the latest accepted transition without rewriting history:
|
Undo the latest accepted transition without rewriting history:
|
||||||
|
|
||||||
@@ -195,7 +187,7 @@ Add `--json` anywhere:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
hop --json status
|
hop --json status
|
||||||
hop start --agent codex --json "Add password reset"
|
hop begin --agent codex --heredoc --json
|
||||||
```
|
```
|
||||||
|
|
||||||
Successful output follows:
|
Successful output follows:
|
||||||
@@ -215,4 +207,8 @@ Hop currently treats any shared changed path as a conflict. Disjoint files can s
|
|||||||
|
|
||||||
Agent-reported scope and test claims are never used as the source of truth. Hop computes source trees and changed paths itself.
|
Agent-reported scope and test claims are never used as the source of truth. Hop computes source trees and changed paths itself.
|
||||||
|
|
||||||
Prompt text and check output are currently stored locally in SQLite without encryption or redaction. Do not put credentials or sensitive secrets into this alpha ledger.
|
Non-secret prompt text and check output are stored locally in SQLite without encryption. Before persistence, Hop redacts high-confidence provider tokens and contextual credentials, private keys, authorization headers, and credential-bearing URLs. The same boundary sanitizes proposal summaries plus recorded validation commands and output.
|
||||||
|
|
||||||
|
Detection cannot recognize every private or future token format. Prefer environment variables or a secret manager, and rotate any real credential pasted into any agent prompt even when Hop reports that it redacted the value.
|
||||||
|
|
||||||
|
Skill-driven Desktop capture stores the agent's verbatim transcription of the visible message and attachment references. It cannot prove byte-for-byte fidelity with Codex's raw submission. A trusted `UserPromptSubmit` hook is the future deterministic capture boundary; the skill remains the no-UI-change alpha workflow.
|
||||||
|
|||||||
@@ -279,6 +279,7 @@ A plausible CLI:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
hop init
|
hop init
|
||||||
|
hop begin --agent codex --heredoc # agent first action in Codex Desktop
|
||||||
hop run --agent codex "Add password reset emails"
|
hop run --agent codex "Add password reset emails"
|
||||||
hop run --agent claude "Redesign account settings"
|
hop run --agent claude "Redesign account settings"
|
||||||
hop prompt P185 "Use Resend, not SendGrid"
|
hop prompt P185 "Use Resend, not SendGrid"
|
||||||
@@ -307,7 +308,7 @@ hop state checkpoint --manifest checkpoint.json --json
|
|||||||
hop propose --manifest result.json --json
|
hop propose --manifest result.json --json
|
||||||
```
|
```
|
||||||
|
|
||||||
The orchestrator should durably create the prompt-state, task/attempt grouping, and workspace before launching an agent, then inject `HOP_STATE_ID`, `HOP_TASK_ID`, `HOP_ATTEMPT_ID`, and the workspace path. The skill teaches the workflow; the process boundary enforces it.
|
Hop supports two capture strengths. In Codex Desktop, the skill makes `hop begin` the agent's first project action, providing a practical pre-effect boundary without changing the user's prompt-box workflow. A trusted prompt hook or orchestrator can durably create the prompt-state, task/attempt grouping, and workspace before model delivery, then inject `HOP_STATE_ID`, `HOP_TASK_ID`, `HOP_ATTEMPT_ID`, and the workspace path. The skill teaches and applies the workflow; a hook or process boundary can enforce the stronger pre-delivery invariant.
|
||||||
|
|
||||||
## The MVP
|
## The MVP
|
||||||
|
|
||||||
@@ -316,9 +317,9 @@ The smallest complete product is a **parallel-agent landing queue** backed by Gi
|
|||||||
### It must do
|
### It must do
|
||||||
|
|
||||||
1. Initialize Hop inside an existing Git repository.
|
1. Initialize Hop inside an existing Git repository.
|
||||||
2. Turn every submitted prompt into a durable child state before agent execution.
|
2. Turn every submitted prompt into a durable child state before project effects, with a pre-delivery mode where the harness supports it.
|
||||||
3. Create a task/attempt grouping and isolated Git worktree from the prompt’s parent state.
|
3. Create a task/attempt grouping and isolated Git worktree from the prompt’s parent state.
|
||||||
4. Launch or attach Codex and Claude Code through thin adapters.
|
4. Attach Codex Desktop through a skill/session binding and support thin controller adapters for other agents.
|
||||||
5. Capture immutable checkpoint, proposal, failure, and cancellation states beneath each prompt-state.
|
5. Capture immutable checkpoint, proposal, failure, and cancellation states beneath each prompt-state.
|
||||||
6. Record claims, actual changed files, agent/environment identity, and status.
|
6. Record claims, actual changed files, agent/environment identity, and status.
|
||||||
7. Show the universal state graph and detect claim/file overlap.
|
7. Show the universal state graph and detect claim/file overlap.
|
||||||
@@ -329,6 +330,8 @@ The smallest complete product is a **parallel-agent landing queue** backed by Gi
|
|||||||
12. Undo an accepted state through a compensating prompt/integration state.
|
12. Undo an accepted state through a compensating prompt/integration state.
|
||||||
13. Generate a small `PROJECT.md` from accepted facts.
|
13. Generate a small `PROJECT.md` from accepted facts.
|
||||||
14. Install a vendor-neutral agent skill and expose stable JSON CLI output.
|
14. Install a vendor-neutral agent skill and expose stable JSON CLI output.
|
||||||
|
15. Redact credentials before prompt text reaches state digests, titles, events,
|
||||||
|
validation evidence, or any durable database/write-ahead-log page.
|
||||||
|
|
||||||
### It should not do yet
|
### It should not do yet
|
||||||
|
|
||||||
@@ -366,7 +369,7 @@ The smallest complete product is a **parallel-agent landing queue** backed by Gi
|
|||||||
### Week 2: attempts and proposals
|
### Week 2: attempts and proposals
|
||||||
|
|
||||||
- Codex and Claude adapters
|
- Codex and Claude adapters
|
||||||
- immutable prompt-state creation before delivery, plus checkpoint and proposal states
|
- skill-driven pre-effect capture, controller-grade pre-delivery capture, checkpoint states, and proposal states
|
||||||
- claims with leases
|
- claims with leases
|
||||||
- proposal nomination and receipts that reference sealed states
|
- proposal nomination and receipts that reference sealed states
|
||||||
|
|
||||||
@@ -473,7 +476,12 @@ Only accepted prompt-states or integration states can introduce facts. Require p
|
|||||||
|
|
||||||
### Prompt history leaks secrets
|
### Prompt history leaks secrets
|
||||||
|
|
||||||
Separate private transcripts from shareable receipts. Redact secrets, support configurable retention, and encrypt sensitive local records. Never require hidden model reasoning to be stored.
|
Make secret removal a pre-persistence boundary, not a display filter. Retain a
|
||||||
|
typed redaction marker and count, but never the credential, a reversible form,
|
||||||
|
or a credential hash. Apply the same sanitizer to summaries, recorded commands,
|
||||||
|
and check output. Separate private transcripts from shareable receipts, support
|
||||||
|
configurable retention, and encrypt sensitive local records. Never require
|
||||||
|
hidden model reasoning to be stored.
|
||||||
|
|
||||||
### Claims create deadlocks
|
### Claims create deadlocks
|
||||||
|
|
||||||
|
|||||||
+123
-5
@@ -7,6 +7,7 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,7 +15,8 @@ const usageText = `Hop — prompt-native version control
|
|||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
hop init [path]
|
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 checkpoint STATE
|
||||||
hop check STATE -- COMMAND [ARG...]
|
hop check STATE -- COMMAND [ARG...]
|
||||||
hop propose [--summary TEXT] STATE
|
hop propose [--summary TEXT] STATE
|
||||||
@@ -34,9 +36,13 @@ Usage:
|
|||||||
Add --json anywhere for machine-readable output.
|
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 {
|
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")
|
jsonOutput, args := removeFlag(args, "--json")
|
||||||
if len(args) == 0 || args[0] == "help" || args[0] == "--help" || args[0] == "-h" {
|
if len(args) == 0 || args[0] == "help" || args[0] == "--help" || args[0] == "-h" {
|
||||||
fmt.Fprint(stdout, usageText)
|
fmt.Fprint(stdout, usageText)
|
||||||
@@ -80,6 +86,64 @@ func RunCLI(args []string, stdout, stderr io.Writer) int {
|
|||||||
return 0
|
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(".")
|
service, err := OpenProject(".")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return printCLIError(err, jsonOutput, stdout, stderr)
|
return printCLIError(err, jsonOutput, stdout, stderr)
|
||||||
@@ -93,20 +157,23 @@ func RunCLI(args []string, stdout, stderr io.Writer) int {
|
|||||||
fs.SetOutput(stderr)
|
fs.SetOutput(stderr)
|
||||||
from := fs.String("from", "", "continue an existing attempt")
|
from := fs.String("from", "", "continue an existing attempt")
|
||||||
agent := fs.String("agent", "", "agent or harness name")
|
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 {
|
if err := fs.Parse(commandArgs); err != nil {
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
if len(fs.Args()) != 1 || strings.TrimSpace(fs.Args()[0]) == "" {
|
message, err := promptMessage(stdin, fs.Args(), *stdinPrompt, *heredocPrompt)
|
||||||
fmt.Fprintln(stderr, "exactly one non-empty prompt argument is required; quote prompts containing spaces")
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "hop prompt: %v\n", err)
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
message := fs.Args()[0]
|
|
||||||
result, err := service.CreatePrompt(ctx, message, *from, *agent)
|
result, err := service.CreatePrompt(ctx, message, *from, *agent)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return printCLIError(err, jsonOutput, stdout, stderr)
|
return printCLIError(err, jsonOutput, stdout, stderr)
|
||||||
}
|
}
|
||||||
value = result
|
value = result
|
||||||
if !jsonOutput {
|
if !jsonOutput {
|
||||||
|
writeRedactionNotice(stderr, result.Redactions)
|
||||||
if result.Checkpoint != nil {
|
if result.Checkpoint != nil {
|
||||||
fmt.Fprintf(stdout, "Checkpointed %s before the follow-up\n", result.Checkpoint.ID)
|
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
|
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) {
|
func writeJSON(w io.Writer, value any) {
|
||||||
encoder := json.NewEncoder(w)
|
encoder := json.NewEncoder(w)
|
||||||
encoder.SetIndent("", " ")
|
encoder.SetIndent("", " ")
|
||||||
|
|||||||
+97
-24
@@ -15,7 +15,7 @@ import (
|
|||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
const schemaVersion = 1
|
const schemaVersion = 2
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrNotFound = errors.New("hop: not found")
|
ErrNotFound = errors.New("hop: not found")
|
||||||
@@ -137,20 +137,21 @@ type migration struct {
|
|||||||
statements []string
|
statements []string
|
||||||
}
|
}
|
||||||
|
|
||||||
var migrations = []migration{{
|
var migrations = []migration{
|
||||||
version: 1,
|
{
|
||||||
statements: []string{
|
version: 1,
|
||||||
`CREATE TABLE IF NOT EXISTS meta (
|
statements: []string{
|
||||||
|
`CREATE TABLE IF NOT EXISTS meta (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
value TEXT NOT NULL
|
value TEXT NOT NULL
|
||||||
) STRICT`,
|
) STRICT`,
|
||||||
`CREATE TABLE IF NOT EXISTS tasks (
|
`CREATE TABLE IF NOT EXISTS tasks (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
status TEXT NOT NULL,
|
status TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
) STRICT`,
|
) STRICT`,
|
||||||
`CREATE TABLE IF NOT EXISTS states (
|
`CREATE TABLE IF NOT EXISTS states (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
kind TEXT NOT NULL,
|
kind TEXT NOT NULL,
|
||||||
task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED,
|
task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED,
|
||||||
@@ -164,7 +165,7 @@ var migrations = []migration{{
|
|||||||
digest TEXT NOT NULL,
|
digest TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
) STRICT`,
|
) STRICT`,
|
||||||
`CREATE TABLE IF NOT EXISTS attempts (
|
`CREATE TABLE IF NOT EXISTS attempts (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
|
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
|
||||||
agent TEXT NOT NULL,
|
agent TEXT NOT NULL,
|
||||||
@@ -174,7 +175,7 @@ var migrations = []migration{{
|
|||||||
status TEXT NOT NULL,
|
status TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
) STRICT`,
|
) 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,
|
state_id TEXT NOT NULL REFERENCES states(id) ON DELETE CASCADE,
|
||||||
parent_state_id TEXT NOT NULL REFERENCES states(id) ON DELETE RESTRICT,
|
parent_state_id TEXT NOT NULL REFERENCES states(id) ON DELETE RESTRICT,
|
||||||
role TEXT NOT NULL,
|
role TEXT NOT NULL,
|
||||||
@@ -182,7 +183,7 @@ var migrations = []migration{{
|
|||||||
PRIMARY KEY (state_id, parent_order),
|
PRIMARY KEY (state_id, parent_order),
|
||||||
UNIQUE (state_id, parent_state_id, role)
|
UNIQUE (state_id, parent_state_id, role)
|
||||||
) STRICT`,
|
) STRICT`,
|
||||||
`CREATE TABLE IF NOT EXISTS checks (
|
`CREATE TABLE IF NOT EXISTS checks (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
attempt_id TEXT NOT NULL REFERENCES attempts(id) ON DELETE CASCADE,
|
attempt_id TEXT NOT NULL REFERENCES attempts(id) ON DELETE CASCADE,
|
||||||
state_id TEXT REFERENCES states(id) ON DELETE SET NULL,
|
state_id TEXT REFERENCES states(id) ON DELETE SET NULL,
|
||||||
@@ -192,7 +193,7 @@ var migrations = []migration{{
|
|||||||
output TEXT NOT NULL,
|
output TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
) STRICT`,
|
) STRICT`,
|
||||||
`CREATE TABLE IF NOT EXISTS events (
|
`CREATE TABLE IF NOT EXISTS events (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
kind TEXT NOT NULL,
|
kind TEXT NOT NULL,
|
||||||
task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
|
task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
|
||||||
@@ -201,20 +202,35 @@ var migrations = []migration{{
|
|||||||
payload_json TEXT NOT NULL,
|
payload_json TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
) STRICT`,
|
) STRICT`,
|
||||||
`CREATE INDEX IF NOT EXISTS states_task_created_idx ON states(task_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_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 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_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 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_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 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_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 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_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_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 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 {
|
func (s *Store) migrate(ctx context.Context) error {
|
||||||
var current int
|
var current int
|
||||||
@@ -337,6 +353,48 @@ func (s *Store) IsInitialized(ctx context.Context) (bool, error) {
|
|||||||
return err == nil, err
|
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
|
// CreateTaskAttemptPrompt atomically creates all three records. The prompt is
|
||||||
// durable and the attempt head points to it before the transaction becomes
|
// durable and the attempt head points to it before the transaction becomes
|
||||||
// visible, so an orchestrator may safely deliver the prompt after this returns.
|
// visible, so an orchestrator may safely deliver the prompt after this returns.
|
||||||
@@ -360,6 +418,7 @@ func (s *Store) CreateTaskAttemptPrompt(
|
|||||||
if task.Title == "" {
|
if task.Title == "" {
|
||||||
task.Title = prompt.Prompt
|
task.Title = prompt.Prompt
|
||||||
}
|
}
|
||||||
|
task.Title, _ = RedactPromptSecrets(task.Title)
|
||||||
if attempt.ID == "" {
|
if attempt.ID == "" {
|
||||||
attempt.ID = newID("at")
|
attempt.ID = newID("at")
|
||||||
}
|
}
|
||||||
@@ -946,6 +1005,8 @@ func (s *Store) AddCheck(ctx context.Context, check Check) (Check, error) {
|
|||||||
if check.CreatedAt.IsZero() {
|
if check.CreatedAt.IsZero() {
|
||||||
check.CreatedAt = time.Now().UTC()
|
check.CreatedAt = time.Now().UTC()
|
||||||
}
|
}
|
||||||
|
check.Command, _ = redactSecretStrings(check.Command)
|
||||||
|
check.Output, _ = RedactPromptSecrets(check.Output)
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Check{}, fmt.Errorf("begin check creation: %w", err)
|
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() {
|
if state.CreatedAt.IsZero() {
|
||||||
return errors.New("hop: state creation time is required")
|
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,
|
_, err := tx.ExecContext(ctx,
|
||||||
`INSERT INTO states(
|
`INSERT INTO states(
|
||||||
id, kind, task_id, attempt_id, canonical_anchor_id, source_tree, git_commit,
|
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)
|
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,
|
_, err = tx.ExecContext(ctx,
|
||||||
`INSERT INTO events(id, kind, task_id, attempt_id, state_id, payload_json, created_at)
|
`INSERT INTO events(id, kind, task_id, attempt_id, state_id, payload_json, created_at)
|
||||||
VALUES (?, ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), ?, ?)`,
|
VALUES (?, ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), ?, ?)`,
|
||||||
|
|||||||
+18
-6
@@ -86,13 +86,25 @@ type AcceptResult struct {
|
|||||||
Warnings []string `json:"warnings,omitempty"`
|
Warnings []string `json:"warnings,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PromptRedaction struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
type PromptResult struct {
|
type PromptResult struct {
|
||||||
Prompt State `json:"prompt"`
|
Prompt State `json:"prompt"`
|
||||||
Checkpoint *State `json:"checkpoint,omitempty"`
|
Checkpoint *State `json:"checkpoint,omitempty"`
|
||||||
Task Task `json:"task"`
|
Task Task `json:"task"`
|
||||||
Attempt Attempt `json:"attempt"`
|
Attempt Attempt `json:"attempt"`
|
||||||
Workspace string `json:"workspace"`
|
Workspace string `json:"workspace"`
|
||||||
Deliver []string `json:"deliver"`
|
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 {
|
type EnvironmentResult struct {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -113,7 +113,16 @@ func OpenProject(start string) (*Service, error) {
|
|||||||
store.Close()
|
store.Close()
|
||||||
return nil, err
|
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()
|
store.Close()
|
||||||
return nil, fmt.Errorf("Hop database belongs to %s, not %s", recordedRoot, root)
|
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) == "" {
|
if strings.TrimSpace(message) == "" {
|
||||||
return PromptResult{}, fmt.Errorf("prompt text is required")
|
return PromptResult{}, fmt.Errorf("prompt text is required")
|
||||||
}
|
}
|
||||||
|
message, redactions := RedactPromptSecrets(message)
|
||||||
|
agent, _ = RedactPromptSecrets(agent)
|
||||||
|
var result PromptResult
|
||||||
|
var err error
|
||||||
if fromStateID == "" {
|
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) {
|
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 {
|
if removeErr != nil {
|
||||||
return Check{}, removeErr
|
return Check{}, removeErr
|
||||||
}
|
}
|
||||||
|
storedCommand, _ := redactSecretStrings(argv)
|
||||||
|
storedOutput, _ := RedactPromptSecrets(result.Output)
|
||||||
check := Check{
|
check := Check{
|
||||||
ID: checkID,
|
ID: checkID,
|
||||||
AttemptID: attempt.ID,
|
AttemptID: attempt.ID,
|
||||||
StateID: checkpoint.ID,
|
StateID: checkpoint.ID,
|
||||||
TreeHash: checkpoint.SourceTree,
|
TreeHash: checkpoint.SourceTree,
|
||||||
Command: append([]string(nil), argv...),
|
Command: storedCommand,
|
||||||
ExitCode: result.ExitCode,
|
ExitCode: result.ExitCode,
|
||||||
Output: result.Output,
|
Output: storedOutput,
|
||||||
CreatedAt: time.Now().UTC(),
|
CreatedAt: time.Now().UTC(),
|
||||||
}
|
}
|
||||||
check, err = s.Store.AddCheck(ctx, check)
|
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) == "" {
|
if strings.TrimSpace(summary) == "" {
|
||||||
summary = task.Title
|
summary = task.Title
|
||||||
}
|
}
|
||||||
|
summary, _ = RedactPromptSecrets(summary)
|
||||||
parents := canonicalizeParents([]Parent{{StateID: attempt.HeadStateID, Role: "run_parent", Order: 0}})
|
parents := canonicalizeParents([]Parent{{StateID: attempt.HeadStateID, Role: "run_parent", Order: 0}})
|
||||||
proposal := State{
|
proposal := State{
|
||||||
ID: newID("r"),
|
ID: newID("r"),
|
||||||
@@ -531,13 +591,15 @@ func (s *Service) Accept(ctx context.Context, proposalID string, checkCommand []
|
|||||||
if removeErr != nil {
|
if removeErr != nil {
|
||||||
return AcceptResult{}, removeErr
|
return AcceptResult{}, removeErr
|
||||||
}
|
}
|
||||||
|
storedCommand, _ := redactSecretStrings(checkCommand)
|
||||||
|
storedOutput, _ := RedactPromptSecrets(result.Output)
|
||||||
check := Check{
|
check := Check{
|
||||||
ID: checkID,
|
ID: checkID,
|
||||||
AttemptID: proposal.AttemptID,
|
AttemptID: proposal.AttemptID,
|
||||||
TreeHash: finalTree,
|
TreeHash: finalTree,
|
||||||
Command: append([]string(nil), checkCommand...),
|
Command: storedCommand,
|
||||||
ExitCode: result.ExitCode,
|
ExitCode: result.ExitCode,
|
||||||
Output: result.Output,
|
Output: storedOutput,
|
||||||
CreatedAt: time.Now().UTC(),
|
CreatedAt: time.Now().UTC(),
|
||||||
}
|
}
|
||||||
if check.ExitCode != 0 {
|
if check.ExitCode != 0 {
|
||||||
@@ -681,6 +743,7 @@ func (s *Service) recordValidationFailure(
|
|||||||
{StateID: proposal.ID, Role: "run_parent", Order: 0},
|
{StateID: proposal.ID, Role: "run_parent", Order: 0},
|
||||||
{StateID: current.ID, Role: "integration_parent", Order: 1},
|
{StateID: current.ID, Role: "integration_parent", Order: 1},
|
||||||
})
|
})
|
||||||
|
storedCommand, _ := redactSecretStrings(command)
|
||||||
failed := State{
|
failed := State{
|
||||||
ID: newID("f"),
|
ID: newID("f"),
|
||||||
Kind: StateFailed,
|
Kind: StateFailed,
|
||||||
@@ -689,7 +752,7 @@ func (s *Service) recordValidationFailure(
|
|||||||
CanonicalAnchorID: current.ID,
|
CanonicalAnchorID: current.ID,
|
||||||
SourceTree: tree,
|
SourceTree: tree,
|
||||||
GitCommit: commit,
|
GitCommit: commit,
|
||||||
Summary: "Final validation failed: " + shellQuote(command),
|
Summary: "Final validation failed: " + shellQuote(storedCommand),
|
||||||
Agent: "hop",
|
Agent: "hop",
|
||||||
CreatedAt: time.Now().UTC(),
|
CreatedAt: time.Now().UTC(),
|
||||||
Parents: parents,
|
Parents: parents,
|
||||||
|
|||||||
@@ -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) {
|
func TestDoctorRejectsCommitTreeAndDigestMismatch(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
service, initial := newTestProject(t, map[string]string{"base.txt": "base\n"})
|
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
|
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 {
|
func objectField(t *testing.T, object map[string]any, key string) map[string]any {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
value, ok := object[key].(map[string]any)
|
value, ok := object[key].(map[string]any)
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ func TestInstallSkillBundle(t *testing.T) {
|
|||||||
t.Fatalf("installed %s is empty", relative)
|
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") {
|
if _, err := InstallSkill(base, false); err == nil || !strings.Contains(err.Error(), "already exists") {
|
||||||
t.Fatalf("second install error = %v, want existing-skill error", err)
|
t.Fatalf("second install error = %v, want existing-skill error", err)
|
||||||
}
|
}
|
||||||
@@ -57,7 +64,7 @@ func TestSkillCLIWorksOutsideHopProject(t *testing.T) {
|
|||||||
stdout.Reset()
|
stdout.Reset()
|
||||||
stderr.Reset()
|
stderr.Reset()
|
||||||
code = RunCLI([]string{"skill", "print"}, &stdout, &stderr)
|
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())
|
t.Fatalf("skill print exited %d\nstdout: %s\nstderr: %s", code, stdout.String(), stderr.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import (
|
|||||||
|
|
||||||
const maxRecordedOutput = 128 * 1024
|
const maxRecordedOutput = 128 * 1024
|
||||||
|
|
||||||
|
var ErrNotHopProject = errors.New("not inside a Hop project")
|
||||||
|
|
||||||
func FindHopRoot(start string) (string, error) {
|
func FindHopRoot(start string) (string, error) {
|
||||||
if configured := os.Getenv("HOP_ROOT"); configured != "" {
|
if configured := os.Getenv("HOP_ROOT"); configured != "" {
|
||||||
return requireHopRoot(configured)
|
return requireHopRoot(configured)
|
||||||
@@ -48,7 +50,7 @@ func FindHopRoot(start string) (string, error) {
|
|||||||
break
|
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) {
|
func requireHopRoot(root string) (string, error) {
|
||||||
|
|||||||
+77
-77
@@ -1,106 +1,106 @@
|
|||||||
---
|
---
|
||||||
name: hop
|
name: hop
|
||||||
description: Work safely in Hop prompt-native version-control projects. Use whenever HOP_STATE_ID, HOP_TASK_ID, or HOP_ATTEMPT_ID is set; when a repository contains .hop/hop.db; or when the user asks to use Hop to isolate, checkpoint, validate, propose, land, inspect, continue, or undo coding-agent work instead of directly managing Git branches or commits.
|
description: Capture local repository prompts as Hop states and perform agent work in isolated Hop workspaces. Use at the start of every Codex Desktop or CLI repository turn and follow-up, before inspecting files, running project commands, editing, reviewing, delegating, landing, or undoing—even when the user does not mention Hop. Also use whenever HOP_STATE_ID, HOP_TASK_ID, HOP_ATTEMPT_ID, CODEX_THREAD_ID, or .hop/hop.db is present.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Hop
|
# Hop
|
||||||
|
|
||||||
Use Hop as the change-control boundary between agent work and accepted project state.
|
Make prompt capture the first project action, then keep all effects inside the
|
||||||
|
returned Hop workspace.
|
||||||
|
|
||||||
## Enforce the boundary
|
## Capture the current prompt first
|
||||||
|
|
||||||
- Require a durable Hop prompt state before making repository changes.
|
Do not inspect repository files, plan from repository contents, run project
|
||||||
- Work only in the assigned `HOP_WORKSPACE`; never edit the canonical project root.
|
commands, edit, or delegate before capture. Run this from the selected project
|
||||||
- Do not run `git commit`, `git checkout`, `git switch`, `git branch`, `git rebase`, `git reset`, `git stash`, or `git worktree`. Hop owns snapshots and worktrees.
|
directory:
|
||||||
- Do not stage files. Hop captures every nonignored workspace change.
|
|
||||||
- Do not land your own proposal unless the user explicitly requests it. Default to stopping after proposal creation.
|
|
||||||
- Never resolve an overlap by silently merging. Preserve the proposal and request or create a reconciliation prompt.
|
|
||||||
|
|
||||||
## Verify the launch context
|
|
||||||
|
|
||||||
Before planning or editing:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
command -v hop
|
hop begin --agent codex --heredoc <<'HOP_PROMPT_EOF'
|
||||||
test -n "$HOP_ROOT"
|
<copy the current user message verbatim>
|
||||||
test -n "$HOP_STATE_ID"
|
HOP_PROMPT_EOF
|
||||||
test -n "$HOP_TASK_ID"
|
```
|
||||||
test -n "$HOP_ATTEMPT_ID"
|
|
||||||
test -n "$HOP_WORKSPACE"
|
Choose a different quoted delimiter if that exact delimiter appears in the
|
||||||
hop state "$HOP_STATE_ID" --json
|
message. Include visible attachment paths and references. Do not paraphrase,
|
||||||
|
pre-redact, or omit a suspected credential in this one capture stream; Hop must
|
||||||
|
see it to replace it deterministically before persistence. `--heredoc` removes
|
||||||
|
only the shell-added final newline. Never copy the credential anywhere else.
|
||||||
|
|
||||||
|
`hop begin` performs the Desktop bootstrap:
|
||||||
|
|
||||||
|
- Initialize Hop automatically when the project has not used it before.
|
||||||
|
- Use `CODEX_THREAD_ID` to bind this Codex task to one Hop attempt.
|
||||||
|
- Create a prompt state and isolated workspace on the first turn.
|
||||||
|
- Checkpoint prior workspace effects and append a prompt state on follow-ups.
|
||||||
|
- Redact detected API keys, tokens, passwords, private keys, authorization
|
||||||
|
headers, and credential-bearing connection strings before persistence.
|
||||||
|
|
||||||
|
Read the returned `HOP_STATE_ID`, `HOP_TASK_ID`, `HOP_ATTEMPT_ID`, and workspace.
|
||||||
|
If capture fails or `hop` is unavailable, stop without project effects and
|
||||||
|
report the error.
|
||||||
|
|
||||||
|
If Hop reports redactions, never repeat the credential in output, summaries,
|
||||||
|
commands recorded as evidence, or proposal text. Refer to its environment
|
||||||
|
variable or secret-manager name instead.
|
||||||
|
|
||||||
|
## Enforce the workspace boundary
|
||||||
|
|
||||||
|
- Direct every shell command to the returned workspace.
|
||||||
|
- Use absolute paths beneath that workspace for file reads and edits.
|
||||||
|
- Never edit the selected canonical project root.
|
||||||
|
- Do not run `git commit`, `git checkout`, `git switch`, `git branch`,
|
||||||
|
`git rebase`, `git reset`, `git stash`, or `git worktree`.
|
||||||
|
- Do not stage files. Hop captures every nonignored workspace change.
|
||||||
|
- Give a subagent project-changing work only after creating a distinct Hop
|
||||||
|
prompt/attempt for that delegation.
|
||||||
|
- Never silently merge overlapping proposals.
|
||||||
|
|
||||||
|
Verify the captured state before making changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hop state <HOP_STATE_ID> --json
|
||||||
hop status --json
|
hop status --json
|
||||||
```
|
```
|
||||||
|
|
||||||
Confirm the current working directory is `HOP_WORKSPACE` or direct every filesystem operation there.
|
## Execute and submit
|
||||||
|
|
||||||
If the Hop variables are missing, stop before editing. Explain that the controller must first run:
|
1. Inspect and modify only the Hop workspace.
|
||||||
|
2. Keep the change scoped to the captured prompt.
|
||||||
|
3. Bind validation evidence to an immutable checkpoint:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
hop start --agent <agent-name> "<exact prompt>"
|
hop check <HOP_STATE_ID> -- <test-command> [args...]
|
||||||
```
|
```
|
||||||
|
|
||||||
Then relaunch or redirect the agent into the printed workspace with the printed environment. A skill loaded after prompt delivery cannot retroactively guarantee pre-delivery recording.
|
4. Fix failures in the live Hop workspace and rerun checks.
|
||||||
|
5. Freeze project changes as a proposal:
|
||||||
|
|
||||||
## Execute the task
|
```bash
|
||||||
|
hop propose --summary "<behavioral summary>" <HOP_STATE_ID>
|
||||||
|
```
|
||||||
|
|
||||||
1. Read the prompt state and current Hop status.
|
6. Report the prompt state, proposal state, checks, and remaining risks.
|
||||||
2. Inspect and modify only the assigned workspace.
|
|
||||||
3. Keep the change scoped to the recorded instruction.
|
|
||||||
4. Run relevant validation through Hop so evidence is bound to an immutable checkpoint:
|
|
||||||
|
|
||||||
```bash
|
For a read-only or informational turn, the prompt state is sufficient; do not
|
||||||
hop check "$HOP_STATE_ID" -- <test-command> [args...]
|
invent a proposal when the workspace tree is unchanged.
|
||||||
```
|
|
||||||
|
|
||||||
5. Fix failures in the workspace and rerun the check as needed.
|
Do not edit a frozen proposal. A user follow-up triggers this skill again;
|
||||||
6. Freeze the result as a proposal:
|
run `hop begin` again before acting. Session binding selects the existing
|
||||||
|
attempt automatically, so the user never needs to carry state IDs.
|
||||||
```bash
|
|
||||||
hop propose --summary "<behavioral summary>" "$HOP_STATE_ID"
|
|
||||||
```
|
|
||||||
|
|
||||||
7. Report the proposal ID, checks run, remaining risks, and any follow-up needed. Do not continue editing the frozen proposal; later changes require another prompt and proposal.
|
|
||||||
|
|
||||||
## Handle follow-up instructions
|
|
||||||
|
|
||||||
Every follow-up instruction needs a new prompt state before effects.
|
|
||||||
|
|
||||||
- If the controller supplies a new `HOP_STATE_ID`, inspect it and continue.
|
|
||||||
- If no new state was supplied, stop before acting and ask the controller to record the exact follow-up:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
hop prompt --from <current-state> "<exact follow-up>"
|
|
||||||
```
|
|
||||||
|
|
||||||
The command first checkpoints prior effects and then creates the follow-up prompt state. Continue only from the returned prompt state.
|
|
||||||
|
|
||||||
## Land only with explicit authority
|
## Land only with explicit authority
|
||||||
|
|
||||||
When the user explicitly asks to land a proposal, validate the exact final composed tree:
|
Capture the landing request with `hop begin` first. Then, only when the user
|
||||||
|
explicitly authorizes landing, run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
hop land <proposal-state> -- <final-test-command> [args...]
|
hop land <proposal-state> -- <final-test-command> [args...]
|
||||||
```
|
```
|
||||||
|
|
||||||
- On success, report the accepted-state ID.
|
On overlap or validation failure, preserve the proposal and report the block.
|
||||||
- On overlap, do not mutate or discard the proposal. Report the conflicting paths and request a reconciliation prompt based on the latest accepted state.
|
Use `hop undo` only after a separately captured, explicit user request.
|
||||||
- On final validation failure, preserve the failed state and evidence, then request a corrective follow-up.
|
|
||||||
- If no final test command is available, state clearly that landing will be manual and unvalidated.
|
|
||||||
|
|
||||||
## Inspect and recover
|
|
||||||
|
|
||||||
Use these commands as needed:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
hop status
|
|
||||||
hop graph
|
|
||||||
hop state <state-id>
|
|
||||||
hop diff <state-id>
|
|
||||||
hop history
|
|
||||||
hop doctor
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `hop undo` only when the user explicitly asks to undo the latest accepted transition. It creates a new forward state; it does not erase history.
|
|
||||||
|
|
||||||
Read [references/protocol.md](references/protocol.md) when command semantics, state kinds, exit codes, or troubleshooting details are needed.
|
|
||||||
|
|
||||||
|
Read [references/protocol.md](references/protocol.md) for state semantics, exit
|
||||||
|
codes, recovery, and controller-grade pre-delivery capture. Skill-driven
|
||||||
|
Desktop capture is a pre-project-effect boundary; it does not claim the prompt
|
||||||
|
was stored before Codex received it.
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
interface:
|
interface:
|
||||||
display_name: "Hop Version Control"
|
display_name: "Hop Version Control"
|
||||||
short_description: "Safely coordinate and land agent work with Hop"
|
short_description: "Capture every coding prompt before project effects"
|
||||||
default_prompt: "Use $hop to complete this coding task in an isolated Hop state and submit a validated proposal."
|
default_prompt: "Use $hop to capture this prompt first, complete the task in its isolated workspace, and submit a validated proposal."
|
||||||
|
|
||||||
|
policy:
|
||||||
|
allow_implicit_invocation: true
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
A accepted
|
A accepted
|
||||||
├─ P prompt, persisted before effects
|
├─ P prompt, persisted before project effects
|
||||||
│ └─ C checkpoint
|
│ └─ C checkpoint
|
||||||
│ └─ R proposal
|
│ └─ R proposal
|
||||||
└─ P independent prompt
|
└─ P independent prompt
|
||||||
@@ -35,7 +35,9 @@ Prompt, checkpoint, and proposal states may reference identical Git trees while
|
|||||||
| `HOP_ATTEMPT_ID` | Current agent approach/run |
|
| `HOP_ATTEMPT_ID` | Current agent approach/run |
|
||||||
| `HOP_WORKSPACE` | Only directory the agent may modify |
|
| `HOP_WORKSPACE` | Only directory the agent may modify |
|
||||||
|
|
||||||
Treat missing variables as an invalid agent launch. Do not infer an attempt from a nearby worktree when causality matters.
|
Interactive agents may begin without these variables. `hop begin` returns the
|
||||||
|
equivalent IDs and workspace, while `CODEX_THREAD_ID` binds later messages in
|
||||||
|
the same Codex task to the existing attempt.
|
||||||
|
|
||||||
## Command contract
|
## Command contract
|
||||||
|
|
||||||
@@ -57,6 +59,9 @@ hop undo
|
|||||||
### Agent
|
### Agent
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
hop begin --agent codex --heredoc <<'HOP_PROMPT_EOF'
|
||||||
|
<exact current user message>
|
||||||
|
HOP_PROMPT_EOF
|
||||||
hop state "$HOP_STATE_ID" --json
|
hop state "$HOP_STATE_ID" --json
|
||||||
hop status --json
|
hop status --json
|
||||||
hop check "$HOP_STATE_ID" -- <command>
|
hop check "$HOP_STATE_ID" -- <command>
|
||||||
@@ -69,6 +74,17 @@ hop propose --summary "<summary>" "$HOP_STATE_ID"
|
|||||||
|
|
||||||
`hop land` compares paths changed by the proposal with paths accepted since its base. Any shared changed path blocks landing. Disjoint proposals are composed with Git three-tree plumbing and may then be validated on the final tree.
|
`hop land` compares paths changed by the proposal with paths accepted since its base. Any shared changed path blocks landing. Disjoint proposals are composed with Git three-tree plumbing and may then be validated on the final tree.
|
||||||
|
|
||||||
|
`hop begin` is the Codex Desktop entry point. It initializes Hop when necessary,
|
||||||
|
captures the current message before the agent performs project work, and uses
|
||||||
|
`CODEX_THREAD_ID` as the default session key. A later `hop begin` in the same
|
||||||
|
Codex task checkpoints the prior workspace before appending the follow-up
|
||||||
|
prompt state.
|
||||||
|
|
||||||
|
Pass the original message to `hop begin` without model-side redaction. Hop's
|
||||||
|
sanitizer replaces detected credential values before any durable write and
|
||||||
|
returns only typed redaction counts. Do not place the value in any later
|
||||||
|
command, summary, output, or source file.
|
||||||
|
|
||||||
## Exit codes
|
## Exit codes
|
||||||
|
|
||||||
| Code | Meaning |
|
| Code | Meaning |
|
||||||
@@ -82,7 +98,17 @@ hop propose --summary "<summary>" "$HOP_STATE_ID"
|
|||||||
|
|
||||||
A failed `hop check` or final landing check persists its evidence. A blocked or failed landing does not advance accepted state.
|
A failed `hop check` or final landing check persists its evidence. A blocked or failed landing does not advance accepted state.
|
||||||
|
|
||||||
## Human launch sequence
|
## Capture modes
|
||||||
|
|
||||||
|
### Codex Desktop skill
|
||||||
|
|
||||||
|
The user types normally in Codex Desktop. The Hop skill makes `hop begin` its
|
||||||
|
first project action and then directs every operation into the returned
|
||||||
|
workspace. This is a pre-project-effect boundary: Codex has already received
|
||||||
|
the prompt, but no repository inspection, command, or modification may precede
|
||||||
|
the durable prompt state.
|
||||||
|
|
||||||
|
### Controller-grade pre-delivery capture
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
hop init
|
hop init
|
||||||
@@ -96,13 +122,19 @@ eval "$(hop env P_...)"
|
|||||||
<agent-command> "<the same exact prompt>"
|
<agent-command> "<the same exact prompt>"
|
||||||
```
|
```
|
||||||
|
|
||||||
The exact agent command is harness-specific. Until a Hop process adapter intercepts prompts automatically, follow-up prompts must also pass through `hop prompt` before the agent acts.
|
The exact agent command is harness-specific. This stronger mode stores the
|
||||||
|
prompt before the model receives it. A future trusted prompt-submission hook can
|
||||||
|
provide the same boundary inside compatible agent clients.
|
||||||
|
|
||||||
## Failure handling
|
## Failure handling
|
||||||
|
|
||||||
- **Missing Hop environment:** stop before editing and request a Hop-controlled launch.
|
- **Missing Hop environment:** run `hop begin` before project work and use the returned state and workspace.
|
||||||
- **Check failure:** fix the live workspace, checkpoint/check again, then create a new proposal.
|
- **Check failure:** fix the live workspace, checkpoint/check again, then create a new proposal.
|
||||||
- **Frozen proposal needs changes:** record a follow-up prompt; never mutate the stored proposal.
|
- **Frozen proposal needs changes:** record a follow-up prompt; never mutate the stored proposal.
|
||||||
- **Overlap on landing:** retain both lineages and reconcile through a new prompt against current accepted state.
|
- **Overlap on landing:** retain both lineages and reconcile through a new prompt against current accepted state.
|
||||||
- **Ref inconsistency:** run `hop doctor`; use `hop doctor --repair` only outside final validation.
|
- **Ref inconsistency:** run `hop doctor`; use `hop doctor --repair` only outside final validation.
|
||||||
- **Secrets:** prompt text and check output are stored locally without encryption in the alpha. Never place credentials in them.
|
- **Secrets:** Hop redacts high-confidence provider keys plus contextual tokens,
|
||||||
|
passwords, private keys, authorization headers, and credential-bearing URLs
|
||||||
|
before durable storage. It also sanitizes recorded check commands/output and
|
||||||
|
proposal summaries. Detection is defense in depth, not a substitute for
|
||||||
|
environment variables or a secret manager. Never repeat a detected secret.
|
||||||
|
|||||||
Reference in New Issue
Block a user