feat(cli): forge try — instant-gratification onboarding (#350)#353
Conversation
initializ-mk
left a comment
There was a problem hiding this comment.
Review: forge try — instant-gratification onboarding
Reviewed the seven forge try commits (skipped the leading #351 weather commit — already reviewed on #351; it dedupes on rebase as noted). Strong, well-architected PR; the security-critical claims hold up under tracing. Merge-ready — findings are one cross-PR merge-coordination item and four low/nit polish items.
What I verified holds (the parts that matter)
- Genuinely reuses the shared executor, not a fork.
LocalSessionassembles the samecoreruntime.LLMExecutorvia the runner's sub-builders;Storeis nil so history stays in memory and nothing persists. The "no second executor,Run()untouched" claim is accurate. - Egress is actually wired, not just constructed.
buildTryEgressbuilds both the in-process enforced client (installed on ctx viaWithEgressClientinRunTurn) and the subprocess proxy, both emittingegress_allowed/egress_blocked. This is exactly the "works in isolation vs. where it's mounted" trap this line of work keeps hitting — avoided here. - The paste-key secret doesn't leak into skill subprocesses. I chased this:
try.godoesos.Setenvon the pasted key, butSkillCommandExecutor.Runbuilds a minimal allowlisted env (clean slice,PATH+HOME, then only the skill's declared env vars + proxy/trace/OTel). The weather skill declaresenv.required: [], so it gets neither the key nor the ambient env. No leak in the shipped demo. - Fail-closed where it counts: no credential + no TTY → actionable error + exit 1; ephemeral temp dir removed via
defer RemoveAll(base); no secrets on disk for the ephemeral run. CI fully green.
Findings (see inline)
- Medium (cross-PR merge coordination):
buildTryEgressis a fourth copy of the egress-construction pattern, on the pre-#348 signature — compiles today only because #348 isn't merged; when #348 lands this won't compile until updated, and until thenforge trysilently ignoresallowed_private_cidrs. - Low: egress-resolve failure falls back to a fully-unenforced
http.DefaultClient(fail-open). - Low/nit: the paste-key comment claims the key is persisted under
--keep, but the code never writes it to disk (the safe behavior) — reconcile comment + graduation text. - Nit:
--yesis a dead flag (parsed, never read). - Nit (UX): Ctrl-C at the
you ›input prompt doesn't exit —signal.NotifyContextcatches SIGINT butscanner.Scan()isn't ctx-aware, so it hangs until Enter/Ctrl-D. Honestly documented (Ctrl-D //exit), but Ctrl-C is a reflex in a hero demo. Consider reading stdin in a goroutine and selecting onctx.Done().
Nice work — the audit-stream-as-first-run-delight framing is a genuinely good idea, and the reuse discipline (one executor, trimmed bootstrap) is exactly right.
| allowPrivateIPs = true | ||
| } | ||
|
|
||
| enforcer := security.NewEgressEnforcer(nil, egressCfg.Mode, egressCfg.AllDomains, allowPrivateIPs) |
There was a problem hiding this comment.
Medium (cross-PR merge coordination). This block (security.Resolve at L205, NewEgressEnforcer here at L224, NewEgressProxy at L240) is a fourth copy of the egress-construction pattern, and it's on the pre-#348 signature — Resolve 5-arg, NewEgressEnforcer 4-arg, NewEgressProxy 2-arg. It compiles today only because #348 isn't merged yet. The moment #348 lands (which adds allowedPrivateCIDRs to all three), this file won't compile until updated, and until then forge try will silently ignore allowed_private_cidrs where the server path honors it. Since you own both PRs: land #348 first and rebase this onto it (threading allowedPrivateCIDRs through buildTryEgress), or track it as a required follow-up. Also directly reinforces #348's "consolidate the copies" note — this is now the copy most likely to diverge, since its comment says it "mirrors Run()'s egress setup" but it's a hand-maintained duplicate.
| ) | ||
| if err != nil { | ||
| r.logger.Warn("egress resolve failed; using unenforced client", map[string]any{"error": err.Error()}) | ||
| return http.DefaultClient, "", noop |
There was a problem hiding this comment.
Low. On a security.Resolve error this returns http.DefaultClient — a fully unenforced client — with only a Warn. That's a fail-open on a security path. Effectively unreachable for the fixed quickstart preset (its scaffolded egress config resolves fine), but the wrong default direction: a future config change that makes Resolve fail would silently drop egress enforcement entirely. Prefer a deny-all / empty-allowlist enforced client here so the failure mode stays closed.
| } | ||
|
|
||
| // pasteKeyResolution prompts for a provider and a masked API key, held only in | ||
| // memory (written to disk solely under --keep, via the scaffold env path). |
There was a problem hiding this comment.
Low/nit — comment vs. code. This says the pasted key is "written to disk solely under --keep, via the scaffold env path," but it isn't: the key goes into res.EnvOverrides (session-only, consumed by NewLocalSession), while opts.EnvVars is always map[string]string{} — so the key is never persisted, even under --keep. The never-persist behavior is the correct, safe one and matches the PR's "no secrets on disk" promise, so just fix the comment. Worth surfacing the consequence in the graduation text too: forge try --keep + a pasted key writes ./forge-quickstart with no credential, so a later forge serve there needs the env var set by hand.
| f.onceSet = cmd.Flags().Changed("once") | ||
| f.quiet, _ = cmd.Flags().GetBool("quiet") | ||
| f.audit, _ = cmd.Flags().GetBool("audit") | ||
| f.yes, _ = cmd.Flags().GetBool("yes") |
There was a problem hiding this comment.
Nit — dead flag. f.yes is parsed here but never read anywhere in runTry/downstream. It's advertised in --help ("assume yes to prompts") but does nothing. Either wire it (e.g. auto-select the picker's recommended default so --yes is truly non-interactive) or drop the flag.
…, dead flag) - [Low] egress-resolve failure now fails CLOSED: a deny-all allowlist enforcer instead of an unenforced http.DefaultClient, so a future config that makes Resolve error can't silently drop egress enforcement. - [Nit] drop the dead --yes flag (parsed, never read). - [Low/nit] correct the paste-key comment: the key is session-only and never written to disk, even under --keep; note the consequence (a kept agent has no credential on disk). - [Nit/UX] Ctrl-C at the 'you ›' prompt now exits: stdin reads on a goroutine and the REPL selects on ctx.Done() (scanner.Scan isn't ctx-aware). - [Medium/#348 coupling] add an explicit NOTE that buildTryEgress is on the pre-#348 security signatures and must thread allowedPrivateCIDRs when #348 lands (tracked; can't rebase onto an unmerged branch). Build/vet/lint clean; cmd + runtime + tryview suites green.
|
Addressed the review in a16dc14:
Build/vet/lint clean; cmd + runtime + tryview suites green. Thanks for the careful trace on the egress-mount and paste-key-leak paths. |
initializ-mk
left a comment
There was a problem hiding this comment.
Re-review: fix commit a16dc140 — all actionable findings resolved
Verified each finding against the fix diff. CI fully green (all 6 builds, Lint, Test, Integration, Doc-link).
Finding 2 (Low — fail-open egress) — ✅ resolved
On security.Resolve error, buildTryEgress now returns a deny-all enforced client (NewEgressEnforcer(nil, ModeAllowlist, nil, allowPrivateIPs) — allowlist mode with no domains blocks everything) instead of the unenforced http.DefaultClient. Fail-closed, exactly as recommended. The allowPrivateIPs computation was correctly hoisted above the error branch so both paths share it.
Finding 4 (dead --yes) — ✅ resolved
Removed from the struct, init(), and parseTryFlags — no longer advertised in --help.
Finding 3 (paste-key comment) — ✅ resolved
Comment rewritten accurately: the key is session-only (res.EnvOverrides, consumed by NewLocalSession), never written to disk even under --keep, and it now spells out the consequence (a kept agent has no on-disk credential; a later forge serve needs the env var set by hand).
Finding 5 (Ctrl-C at the you › prompt) — ✅ resolved, correctly
stdin now reads on a goroutine feeding a lines channel; the REPL selects on ctx.Done() (Ctrl-C → newline + return), eof (Ctrl-D → return), or a line. The one-read goroutine leak on exit is real but harmless (the process is exiting) and honestly acknowledged in the comment. Mid-turn cancellation and graduation-on-exit both still work.
Finding 1 (#348 coupling) — appropriately deferred with an explicit NOTE
You can't rebase onto an unmerged branch, so the right move is what was done: a prominent in-code NOTE on buildTryEgress documenting that the three constructors are on pre-#348 signatures and must thread allowedPrivateCIDRs when #348 lands, cross-referencing #348's consolidation note. Keeps the landmine visible for whoever merges #348 second.
Verdict: all fixes correct, nothing dangling. Merge-ready (modulo the #348 sequencing the NOTE now tracks).
One optional residual, non-blocking and on an effectively-unreachable branch (the fixed preset always resolves): on the resolve-error path the in-process client is now fail-closed, but it still returns proxyURL="", so skill subprocesses would run unproxied rather than blocked. Fully fail-closing that dead branch would mean refusing to start when egress can't resolve — reasonable to leave as-is behind the NOTE.
initializ-mk
left a comment
There was a problem hiding this comment.
Alignment review: rebase needed against merged main (#354 + #348 + #355)
#354 (keyless weather skill) is now merged, and so are #348 (allowed_private_cidrs) and #355 (SOCKS5 raw-TCP). This PR isn't mergeable as-is — the rebase needs two specific changes.
1. Drop the stale weather commit fe9623130 — it would revert #354
The first commit here is the #351 weather implementation and modifies the exact files #354 just merged (SKILL.md, both weather-*.sh scripts, egress_step.go, scanner_test.go, .claude/skills/forge.md) with a different implementation (| tonumber, hourly[4], explicit UA vs. the merged hourly[0], string values). On rebase it conflicts, and keeping this side would silently revert the merged #354.
Drop fe9623130 entirely. forge try references the skill by name (Skills: ["weather"]), so it doesn't need to carry any weather changes — main's #354 version supplies it. Verified this is a clean drop: the forge try phase commits (c37ba45f…) touch cmd/try.go, local_session.go, tryview/, docs — none touch weather files.
Compatibility with #354's version confirmed:
quickstartPreset→Skills: ["weather"]resolves to main's skill.- jq dependency unchanged — both #351 and #354 declare
bins: [curl, jq], so dropping this copy adds no new requirement. egress_domains: [wttr.in]flows intobuildTryEgress's skill-derived allowlist; the "weather in Tokyo" starter prompt hits wttr.in through the subprocess proxy.- The e2e test uses
datetime_now, not weather, so the #351→#354 script differences don't affect this suite.
2. buildTryEgress is now a hard compile-break (the NOTE came due)
See inline. main now has Resolve(…, allowedPrivateCIDRs, allowedTCP) (7 args), NewEgressEnforcer(…, allowedPrivateCIDRs) (5), NewEgressProxy(…, allowedPrivateCIDRs) (3). buildTryEgress still calls the pre-#348 5/4/2 forms, so after rebase it won't compile at four call sites.
Bottom line: not mergeable as-is. Rebase must (1) drop fe9623130 so it doesn't revert #354, and (2) update buildTryEgress to the merged signatures. After that, forge try uses main's #354 weather skill cleanly. Happy to re-review once the rebased branch is up.
| // `forge try` will silently ignore allowed_private_cidrs where the server | ||
| // path honors it. This is a hand-maintained fourth copy of Run()'s egress | ||
| // setup — see #348's "consolidate the copies" note. | ||
| egressCfg, err := security.Resolve( |
There was a problem hiding this comment.
Compile-break after rebase — the NOTE above came due. Both #348 and #355 are now merged, so main's signatures are:
Resolve(profile, mode, explicitDomains, toolNames, capabilities, allowedPrivateCIDRs, allowedTCP)— 7 argsNewEgressEnforcer(base, mode, domains, allowPrivateIPs, allowedPrivateCIDRs)— 5 argsNewEgressProxy(matcher, allowPrivateIPs, allowedPrivateCIDRs)— 3 args
This block calls the pre-#348 5/4/2 forms, so it won't build at four sites (this Resolve, both NewEgressEnforcer incl. the deny-all path, and NewEgressProxy). Mirror Run():
allowedPrivateCIDRs, _ := security.ParsePrivateCIDRs(egressCfg.AllowedPrivateCIDRs)
egressCfg, err := security.Resolve(
r.cfg.Config.Egress.Profile, r.cfg.Config.Egress.Mode, domains, nil,
r.cfg.Config.Egress.Capabilities,
r.cfg.Config.Egress.AllowedPrivateCIDRs, // #348
r.cfg.Config.Egress.AllowedTCP) // #355
...
denyAll := security.NewEgressEnforcer(nil, security.ModeAllowlist, nil, allowPrivateIPs, nil)
enforcer := security.NewEgressEnforcer(nil, egressCfg.Mode, egressCfg.AllDomains, allowPrivateIPs, allowedPrivateCIDRs)
proxy := security.NewEgressProxy(matcher, allowPrivateIPs, allowedPrivateCIDRs)Thread the CIDRs (not just nil to compile) so forge try actually honors allowed_private_cidrs, per the NOTE. allowedTCP is empty on the keyless demo, so the SOCKS5 listener stays off and no SetTCPMatcher is needed (add it only if you want raw-TCP parity). Delete the NOTE block once done.
…ffold (#350) New `forge try` command that scaffolds a keyless demo agent (the native forge LLM executor + weather skill + http_request/datetime_now/math_calculate builtins) and prints its summary. Ephemeral temp workspace by default, cleaned on exit; --keep writes ./forge-quickstart. Reuses scaffold() via two additions to initOptions (no duplication): - OutputDir overrides the target dir (temp dir / ./forge-quickstart) - Preset stops scaffold() before its banner + auto-run of `forge run`, so try drives the agent in-process (Phases 2-4). Egress auto-derives from the weather skill (allowlist, wttr.in — keyless; depends on #351). No channels, no secrets on disk. No LLM call yet. Verified: ephemeral scaffold + summary + cleanup (no cwd leak); --keep writes a valid forge.yaml; init suite green; gofmt/vet/lint clean.
resolveTryProvider layers an ordering policy over the runner's existing resolution: explicit flags -> env key (ANTHROPIC > OPENAI > GEMINI) -> saved OpenAI OAuth -> local Ollama (short-dial probe) -> interactive picker (TTY) -> actionable error (no TTY). Prints a human 'Using ...' label. Picker offers OpenAI sign-in (runOAuthFlow), paste-key (masked, in-memory), or Ollama. No new credential store; SilenceUsage so the no-credential error stays clean. Verified: env-key path silent + correct label with the documented precedence; no-creds/no-TTY yields the actionable error and exit 1; explicit flags still win. gofmt/vet/lint clean.
…350) New forge-cli/runtime/LocalSession assembles the SAME coreruntime.LLMExecutor that forge run uses (builtin tools + vendored skills, egress-enforced client + subprocess proxy, audit + progress hooks, provider client) WITHOUT an HTTP server, scheduler, MCP, or long-term memory. No second executor — a trimmed bootstrap around the shared sub-builders. History rides in task.History; the executor Store is nil so nothing persists. try.go drives it: RunTurn per line, streaming the reply under 'agent ›', /exit + Ctrl-D + Ctrl-C (signal.NotifyContext) exit, --once single turn, an optional positional prompt seeds the first turn. The runner's JSON logger is silenced so stdout stays clean for the chat. Verified: real turn via a mock OpenAI server (llm_call fires, reply returned, history accumulates to 2 then 4 across turns); session construction (egress proxy + skill registration + client build) succeeds live; runtime + cmd suites green; gofmt/vet/lint clean.
New forge-cli/internal/tryview.Renderer implements the audit Sink contract and
maps the agent's own audit events to inline loop lines: tool calls
(▸ tool name(args)), egress checks (▸ egress domain ✓/✗), guardrail blocks,
and tool results (◂ preview), plus a dim compact 'audit {…}' summary after
each reply. lipgloss orange accent, degrades to plain text with NO_COLOR /
non-TTY. --quiet hides the loop; --audit echoes full NDJSON.
LocalSession now builds its audit logger on a discard base (no stderr noise)
and captures tool args/results (redacted) so the renderer can preview them;
try.go attaches the renderer as an extra sink. Reads existing events only —
no new audit constants, no forge-core TTY code.
Verified: 12 renderer unit tests (event→line mapping, truncation, quiet,
--audit echo, no-color degradation, summary reset) + an end-to-end
LocalSession test rendering a real datetime_now tool call. Suites green;
gofmt/lint clean.
Add the intro banner, three starter prompts (all within the keyless egress allowlist so none dead-ends on a blocked domain), and an exit graduation message that points the user up the ladder: keep the demo (--keep writes ./forge-quickstart), run it with forge serve, or deploy with forge package. Orange accent (lipgloss) on command tokens only; degrades to plain text on non-TTY / NO_COLOR. No em-dashes in printed strings (house style). Verified visually: banner + summary + suggestions render for the interactive REPL; graduation prints on exit (keep-aware); --once stays terse. Build + lint clean.
cmd/try_test.go: quickstartPreset scaffolds a valid, keyless forge.yaml (egress wttr.in, never the key-gated hosts); resolveTryProvider ordering (flags win, ANTHROPIC>OPENAI env precedence, Ollama probe via a live listener, no-creds/no-TTY error with HOME isolated so a dev's real OAuth token can't leak in); tryWorkspace lifecycle (--keep targets cwd with no cleanup; ephemeral temp base is removed). Complements local_session_test.go (real turn + tool-loop render) and the tryview renderer suite. All green; vet + lint clean.
…production (#350) Rewrite quick-start.md around a single command (brew install + forge try), the hero transcript, and a 4-rung ladder (try -> keep+skill -> serve+guardrail -> package). Channels/build/deploy are gone from the quick start. New ship-to-production.md carries the full init -> skills -> secrets -> validate -> run -> build -> package pipeline. README Quick Start leads with forge try + transcript and links both pages; forge.md §15 gets a one-line forge try pointer at the top. Verified: broken-link check clean; no channel steps remain in quick-start.
…, dead flag) - [Low] egress-resolve failure now fails CLOSED: a deny-all allowlist enforcer instead of an unenforced http.DefaultClient, so a future config that makes Resolve error can't silently drop egress enforcement. - [Nit] drop the dead --yes flag (parsed, never read). - [Low/nit] correct the paste-key comment: the key is session-only and never written to disk, even under --keep; note the consequence (a kept agent has no credential on disk). - [Nit/UX] Ctrl-C at the 'you ›' prompt now exits: stdin reads on a goroutine and the REPL selects on ctx.Done() (scanner.Scan isn't ctx-aware). - [Medium/#348 coupling] add an explicit NOTE that buildTryEgress is on the pre-#348 security signatures and must thread allowedPrivateCIDRs when #348 lands (tracked; can't rebase onto an unmerged branch). Build/vet/lint clean; cmd + runtime + tryview suites green.
a16dc14 to
a8807bb
Compare
|
Rebased against merged main (#354 + #348 + #355). Both required changes done: 1. Dropped the stale weather commit 2. Updated
|
initializ-mk
left a comment
There was a problem hiding this comment.
Re-review: rebase aligned with merged main ✅
Both alignment problems from the prior review are resolved, and CI green across all 6 builds is the direct proof the signature break is gone.
Problem 1 (weather-commit conflict with #354) — resolved
- The stale
fe9623130(#351 weather implementation) is gone — noweather-*.sh,SKILL.md,scanner_test.go, oregress_step.goin the diff. - The one remaining shared file,
.claude/skills/forge.md, is a clean addition (just theforge try§15 pointer) — it doesn't touch #354's weather example, so nothing reverts the merged skill. forge trynow uses main's #354 weather skill purely by reference (Skills: ["weather"]).
Problem 2 (buildTryEgress compile-break) — resolved correctly
Resolve(...)passes 7 args incl.AllowedPrivateCIDRs(#348) +AllowedTCP(#355).- Both
NewEgressEnforcercalls are 5-arg (deny-all path passesnil; the real one threads the parsedallowedPrivateCIDRs). NewEgressProxy(matcher, allowPrivateIPs, allowedPrivateCIDRs)is 3-arg.ParsePrivateCIDRs(...)wired likeRun(), soforge trynow honorsallowed_private_cidrsrather than just compiling. The#348 couplingNOTE is removed.AllowedTCPpassed toResolve(validation runs); empty on the keyless demo so the SOCKS5 listener stays off — noSetTCPMatcherneeded, as expected.
Mergeable, CI fully green (all 6 builds incl. Windows, Lint, Test, Integration, Doc-link). Fully aligned with merged main — nothing reverts #354, and forge try honors the merged egress features (#348/#355). Merge-ready.
(mergeStateStatus: BLOCKED is branch-protection/required-review, not a conflict — MERGEABLE confirms no conflicts.)
…real LLM error Three fixes for a fresh OpenAI sign-in dead-ending on 'something went wrong': 1. Root cause: the preset scaffolded OPENAI_API_KEY=your-api-key-here into the demo agent's .env. NewLocalSession loads .env over os.Environ, so the placeholder became the resolved key — non-empty, so createProviderClient took the API-key path (api.openai.com) instead of OAuth and 401'd with the placeholder, shadowing both the OAuth token and any real env key. buildEnvVars now writes NO provider key for preset scaffolds; the credential is resolved at runtime (OAuth store / process env / paste-key). 2. The real LLM error was invisible: the executor logs it via the OnError hook then returns a canned 'something went wrong', and the local session discarded the logger and registered no error hook. Capture the OnError cause and return it from RunTurn instead of the canned string. 3. Wire the global -v/--verbose into LocalSessionOptions (was hardcoded false), so the runner log is visible for deeper diagnosis. Tests: preset .env carries no placeholder credential. Build/vet/lint clean.
…350) Add a gradient FORGE block wordmark (heat gradient #fdba74 -> #c2410c) to the interactive header, and restyle the no-credential picker with accent-bracketed numbers, a dimmed (recommended) hint, and a ❯ prompt. Both degrade to plain text on a non-TTY / NO_COLOR (piped/--once output stays clean: compact one-line header, no logo). Threaded a color flag through resolveTryProvider -> tryPicker; new tryDim helper alongside tryAccent. Verified the gradient logo + dim tagline render under a PTY; plain path unchanged. Build/vet/lint clean; cmd suite green.
…e in agent runtime Adds `forge auth logout [provider]` (default openai): deletes the stored OAuth credential from ~/.forge/credentials + the encrypted store via oauth.DeleteCredentials, so the next `forge init` / `forge try` re-prompts the sign-in picker. Fills the gap where logging out meant knowing to rm a file. Guarded as defense-in-depth: refuses to run inside an agent runtime (a container, or when FORGE_PLATFORM_TOKEN is set) via deniedInAgentRuntime(). A deployed agent authenticates with an injected key / platform token, not this OAuth file, so there's nothing there to log out of — and Forge won't be the tool an agent shells out to in order to wipe an operator's credential from a sandbox. Not the primary control (an arbitrary-exec agent could rm the file directly); the real mitigation is denying the agent write access to ~/.forge. Tests: removes an isolated credential; reports nothing-to-do when absent; refuses + leaves the credential intact when FORGE_PLATFORM_TOKEN is set. Docs: cli-reference auth section. Build/vet/lint clean; cmd suite green.
Paste-key flow polish: - Provider prompt shows colored shortname chips: [o] OpenAI (green), [a] Anthropic (clay), [g] Gemini (blue). Accepts the shortname or the full name via providerFromInput. - API-key entry now echoes a • per character (readMaskedLine, raw mode) so a paste is visibly registered — a plain ReadPassword shows nothing, making it hard to tell if anything pasted. Handles backspace + Ctrl-C; falls back to a silent read on a non-raw terminal. Threaded color through pasteKeyResolution. Test: providerFromInput short/full name mapping + rejections. Build/vet/lint clean; cmd suite green.
initializ-mk
left a comment
There was a problem hiding this comment.
Review: latest updates (4 new commits) — three clean, one Low finding
✅ c316de203 — masked paste echo: correct
readMaskedLine echoes • per char (never the actual key characters), handles backspace/Ctrl-C/submit, and falls back to silent term.ReadPassword on non-TTY. Key never hits plaintext/scrollback. providerFromInput now whitelists the paste-key provider (o/a/g or full names) — good hardening.
✅ e549d9014 — no placeholder credential + real error: good
buildEnvVars returns empty for Preset, so forge try no longer scaffolds your-api-key-here into .env where it would shadow the real OAuth/env credential and 401 — correctness fix and consistent with "no secrets on disk." The errBox/OnError capture surfaces the real provider failure (shown in the user's own terminal; endpoint already sanitized per #358, so no key leak).
✅ dbf949d5f — colored logo/picker: cosmetic, no credential-handling change.
⚠️ 820bb083e — forge auth logout: good design, one Low finding
What's right: the agent-runtime guard (FORGE_PLATFORM_TOKEN set OR InContainer() → refuse) is sound, and the doc comment is honest about it being defense-in-depth rather than the primary control (an agent with shell could rm the file; real mitigation is fs perms on ~/.forge). Correct framing.
Finding — Low (path traversal via unvalidated provider arg): see inline. runAuthLogout passes the raw CLI arg to oauth.LoadCredentials/DeleteCredentials, which map it to filepath.Join(dir, provider+".json") unsanitized — so forge auth logout '../../../../tmp/x' deletes /tmp/x.json. Low severity (laptop-only — refuses in container/platform; operator-invoked; .json-suffixed files the user already owns; no priv-esc), but a real input-validation gap and inconsistent with the paste-key path this same PR just hardened. Trivial fix: whitelist provider before it touches the store.
Minor: runAuthLogout ignores the LoadCredentials error (tok, _ :=), so a corrupt cred file reports "nothing to do" and won't be cleared. Edge case, low.
CI fully green. Everything except the traversal gap is solid.
| func runAuthLogout(cmd *cobra.Command, args []string) error { | ||
| provider := "openai" | ||
| if len(args) == 1 && strings.TrimSpace(args[0]) != "" { | ||
| provider = strings.ToLower(strings.TrimSpace(args[0])) |
There was a problem hiding this comment.
Low (path traversal via unvalidated provider). provider is taken raw from args[0] and passed to oauth.LoadCredentials (L55) and oauth.DeleteCredentials (L59), both of which map it to filepath.Join(dir, provider+".json") with no sanitization (store.go:311, removePlaintextFile is always reached). So:
forge auth logout '../../../../tmp/x'
→ os.Remove(~/.forge/credentials/../../../../tmp/x.json) → deletes /tmp/x.json
Severity is Low — laptop/dev-only (the deniedInAgentRuntime guard blocks the container/platform case, so this isn't agent-reachable), operator-invoked, only .json-suffixed files the user already owns, no priv-esc. But it's a real validation gap and inconsistent with the paste-key path this same PR just hardened with providerFromInput. Close it at this entry point:
if _, ok := providerKeyEnv[provider]; !ok {
return fmt.Errorf("unknown provider %q (use openai, anthropic, or gemini)", provider)
}Defense-in-depth, the store.go provider→path helpers should also reject path separators (they're the pre-existing traversal-capable code shared by save/load/remove), but validating here closes the newly-reachable CLI vector. A TestAuthLogout_RejectsTraversalProvider case would pin it.
…view) - [Low] path traversal: runAuthLogout passed the raw provider arg to the credential store, whose provider->path map is filepath.Join(dir, provider+".json") with no sanitization — so `auth logout ../../../../tmp/x` deleted /tmp/x.json. Whitelist against providerKeyEnv (the same set the paste-key path uses) before it touches the store. - [Minor] runAuthLogout ignored the LoadCredentials error, so a corrupt token file reported 'nothing to do' and was never cleared. Only short-circuit on a definitive empty (err==nil && tok==nil); otherwise fall through to delete. Test: TestAuthLogout_RejectsTraversalProvider (traversal, slash, unknown, and non-LLM providers all rejected). Build/vet/lint clean; cmd suite green.
|
Addressed the review in d544a5f: Finding (Low — path traversal via unvalidated Minor (ignored On your defense-in-depth note: I scoped this fix to the CLI entry point (the newly-reachable vector), as you suggested. The deeper hardening — having the The three clean commits (masked paste echo, no-placeholder-credential + real-error surfacing, colored logo/picker) are unchanged. Build/vet/lint clean; |
initializ-mk
left a comment
There was a problem hiding this comment.
Re-review: fix commit d544a5f3 — both items resolved ✅
Finding (path traversal) — resolved at the entry point. runAuthLogout now validates provider against providerKeyEnv (openai/anthropic/gemini — the same whitelist the paste-key path uses) before it reaches the credential store, so ../../../../tmp/x is rejected with "unknown provider" and never reaches filepath.Join(dir, provider+".json"). Reachable CLI traversal vector closed.
Minor (corrupt creds) — resolved. Short-circuit is now err == nil && tok == nil — "nothing to do" only on a definitive empty store; a read error (corrupt token file) falls through to DeleteCredentials so logout actually clears it.
Test — solid. TestAuthLogout_RejectsTraversalProvider pins traversal (../../../../tmp/x), embedded slash (openai/../../etc/x), real-but-unsupported (google), and junk (x) — all rejected. Good coverage, including the embedded-slash case.
CI fully green (all 6 builds incl. Windows, Lint, Test, Integration, Doc-link).
Verdict: both resolved with the right fix, well-tested. Nothing dangling.
Residual (non-blocking, correctly scoped): the store.go provider→path helpers remain traversal-capable in principle, but every current caller now passes a fixed or validated provider, so there's no reachable untrusted input. Rejecting path separators there would be pure defense-in-depth against a future caller — fine to leave as-is or drop a backlog note. Closing the actual vector at the entry point was the right call.
- cli-reference.md: new `forge try` section (flags table, credential resolution order, in-process/no-server note, links to quick-start + auth). - runtime-engine.md: `forge try` — In-Process Demo running mode alongside forge run / forge serve. - forge.md: forge try row in the command table; add `logout` to the forge auth row with the agent-runtime-refusal note. Docs-only; all .md links + anchors resolve.
…, dead flag) - [Low] egress-resolve failure now fails CLOSED: a deny-all allowlist enforcer instead of an unenforced http.DefaultClient, so a future config that makes Resolve error can't silently drop egress enforcement. - [Nit] drop the dead --yes flag (parsed, never read). - [Low/nit] correct the paste-key comment: the key is session-only and never written to disk, even under --keep; note the consequence (a kept agent has no credential on disk). - [Nit/UX] Ctrl-C at the 'you ›' prompt now exits: stdin reads on a goroutine and the REPL selects on ctx.Done() (scanner.Scan isn't ctx-aware). - [Medium/#348 coupling] add an explicit NOTE that buildTryEgress is on the pre-#348 security signatures and must thread allowedPrivateCIDRs when #348 lands (tracked; can't rebase onto an unmerged branch). Build/vet/lint clean; cmd + runtime + tryview suites green.
…view) - [Low] path traversal: runAuthLogout passed the raw provider arg to the credential store, whose provider->path map is filepath.Join(dir, provider+".json") with no sanitization — so `auth logout ../../../../tmp/x` deleted /tmp/x.json. Whitelist against providerKeyEnv (the same set the paste-key path uses) before it touches the store. - [Minor] runAuthLogout ignored the LoadCredentials error, so a corrupt token file reported 'nothing to do' and was never cleared. Only short-circuit on a definitive empty (err==nil && tok==nil); otherwise fall through to delete. Test: TestAuthLogout_RejectsTraversalProvider (traversal, slash, unknown, and non-LLM providers all rejected). Build/vet/lint clean; cmd suite green.
Implements #350: a single
forge trycommand that takes a first-time user from install to talking to a working agent whose loop they can watch, in under 60 seconds, with zero required decisions.What it does
The differentiator: the agent's own audit stream (tool calls, egress checks, guardrail blocks) renders inline — the same signal the enterprise story rests on, surfaced as first-run delight.
Design (per the plan, locked)
runtime.LocalSessionassembles the samecoreruntime.LLMExecutorforge runuses (builtin tools + vendored skills, egress-enforced client + subprocess proxy, audit + progress hooks, provider client) minus the server/scheduler/MCP/memory. No second executor — a trimmed bootstrap around the shared sub-builders;Run()is untouched (zero risk to the server path).--keepwrites./forge-quickstart. No secrets on disk for the ephemeral run.scaffold()via twoinitOptionsfields (OutputDir,Preset) — no forked init logic.forge-cli, degrades to plain text (NO_COLOR / non-TTY).--quiethides it,--auditshows full NDJSON.Phases (one commit each)
forge try; pipeline moved to newship-to-production.mdVerification
LocalSessionmakes the LLM call, history accumulates); end-to-end test renders a realdatetime_nowtool call.--auditecho, no-color);resolveTryProviderordering + workspace-lifecycle tests.go build ./...,go vet ./...,go test ./forge-cli/...all green;gofmt+golangci-lintclean; docs broken-link check clean; no channels in the quick start.Out of scope (named, not built)
Hosted metered demo key,
forge try --ui, a second demo agent — all deferred per the plan.