Skip to content

feat: OTP gate for dangerous commands + install-dev.sh (split from #127) - #129

Draft
Emasoft wants to merge 4 commits into
mlhher:mainfrom
Emasoft:feat/otp-gate
Draft

Emasoft wants to merge 4 commits into
mlhher:mainfrom
Emasoft:feat/otp-gate

Conversation

@Emasoft

@Emasoft Emasoft commented Sep 18, 2026

Copy link
Copy Markdown

Description of Changes

Split out of PR #127 per the owner review (comment 5716775820), so that PR can stay focused on streaming/retry. This PR is independent of #127: it is based on main and shares no commits with it.

Contents:

  • OTP permission mode (-force-revaluate-dangerous-commands): the first attempt at a dangerous bash command is blocked and the agent receives a random single-use 7-digit OTP (crypto/rand) bound to the exact command string; re-running the byte-identical command with otp_code approves it once. Hard refusals (unsafe cwd, search gate, AST blocks) and safe commands behave exactly as before; pending codes clear on conversation reset.
  • Hard blocks stay hard on parse errors: output redirections and cd are never downgraded to "needs confirmation" when the shell parser cannot read the command (fail-closed), in every mode.
  • install-dev.sh: dev installer that symlinks the repo build (brew-managed late, or first writable PATH dir when brew is absent).
  • Multi-source dev installer: install-dev.sh is now an autodetecting, interactive multi-source installer (local dev / pinned / fork main / upstream main / official) with a headless --choice N mode for dev boxes — see the README Development section.
  • Package-manager & podman detection: the installer detects brew/apt/dnf/pacman/zypper/apk/npm, podman (binary + version) and whether late-podman is already installed — all reported in check mode.
  • late-podman parity: local-dev symlinks and pinned/fork/upstream installs copy the late-podman launcher alongside late, matching 'make install' (runtime requires Linux + podman).
  • Uninstall mode: ./install-dev.sh uninstall removes the installed binaries and .bak archives (brew-managed installs get advisory-only handling); --purge additionally removes config/data/skills dirs; --with-deps PRINTS the exact dependency-removal commands via the detected package manager without executing them (deps may be shared).

⚠ Per the review, this mode "might require some proper testing and benchmarking to ensure it works as expected" — hence draft status until that pass is done.

Test plan

  • go test -race ./... green on this branch; go vet ./... clean; gofmt clean on changed files
  • golangci-lint (v2 config) — 0 findings on the whole tree
  • Dedicated OTP testing/benchmarking pass requested in the review
  • Manual smoke: OTP block → re-run with code → execute; hard blocks hold on unparseable commands
  • Note: Plugin tests inherit main's pre-sandbox hygiene: on loaded hosts TestRunHook_ProcessGroupKillsChildrenOnCancel (fixed 250 ms spawn budget) and the HOME/XDG-sandbox-dependent tests (TestHandlePluginRemove_*, TestRegisterPluginSkills_PreservesSameNamedSkills) can fail — all fail at main too; the test-hygiene hunks from feat: resilient LLM streaming & recovery, --continue-project, grouped help #127 (HOME/XDG sandboxing + deadline-based de-flake) can be ported here on request.

Contributor License Agreement (CLA)

To accept your code, we legally need you to agree to our CLA so we can maintain the project's Business Source License (BSL) and future open-source transitions.

  • By checking this box, I confirm that I have read and agree to the terms of the CLA.md in this repository. (To check the box, put an x between the brackets like this: [x])

…hher#127)

Split out of PR mlhher#127 per owner review (5716775820): the OTP permission mode (`-force-revaluate-dangerous-commands`, OTP registry, otp_code bash parameter, parse-fallback hard-block exemption) and install-dev.sh live here so PR mlhher#127 can stay focused on streaming/retry. Requires the dedicated testing/benchmarking pass requested in the review before it is ready.
@mlhher

mlhher commented Sep 18, 2026

Copy link
Copy Markdown
Owner

Thank you a lot for pulling this out! This will make it significantly easier to test this properly and to ensure this does not degrade the LLMs ability. If it does not this can be merged in without issues. I will first try to get #127 ready then will try this one out. Feel free to also test this, if possible not just cloud but if you can also specifically Qwen3.6-35B-A3B and Qwen3.8-27B and (generally) report your findings.

…ream/official)

Rewrite install-dev.sh as a smart multi-source installer and add a
make install-dev target.

Five sources, each idempotent (every run converges to its state):
- local-dev      build current branch -> SYMLINK to <repo>/bin/late
                 (every make build updates the command in place)
- pinned         build current branch -> fixed COPY (dev tracking stops)
- fork-main      build Emasoft/late-cli@main from a codeload tarball -> COPY
- upstream-main  build mlhher/late-cli@main from a codeload tarball -> COPY
- official       run upstream install.sh (latest stable); upstream owns
                 placement, --target is not applicable
- check          detection report only (read-only)

Autodetection report printed on every run: platform -> GOOS/GOARCH,
current install classification + version, repo branch/dirty state,
remote main SHAs, upstream latest release tag, brew formula state,
target dir selection + PATH membership.

Conflict auto-solving: symlink<->copy transitions archive the displaced
binary as <target>.bak-<timestamp> (never overwritten) with a printed
revert hint; brew-managed installs warn and gate behind --yes in
non-interactive mode; post-install verification checks the installed
shape, version, and shadowing with PATH guidance.

Non-interactive flags (any position): --yes, --dry-run (plan only; the
repo build still runs but target changes and downloads are skipped),
--target DIR. Interactive numbered menu annotates each option with
detected state ([CURRENT], SHAs, release tag); invalid input re-prompts,
q quits; piped stdin takes a single choice. Tarball builds use -trimpath
so reinstalls of the same commit are byte-identical.

make install-dev runs ./install-dev.sh.
Dev boxes and CI need a hands-off installer: --choice N selects the menu entry, implies --yes and never prompts (root is refused, targets are validated, previous binaries are archived). README Development section documents the strictly-dev nature and the headless mode.
…install mode

install-dev.sh rev 3:

- Detection additions (check + pre-install report): package managers
  reported ALL found (brew, apt-get, dnf, pacman, zypper, apk, npm —
  comma list), podman binary presence + version (`podman --version`),
  and late-podman installed state (target dir + ~/.local/bin, classified
  as symlink/regular file/brew-managed/our copy).

- late-podman install parity with `make install` (which ships both
  `late` and `late-podman` to ~/.local/bin): local-dev installs a
  SYMLINK to <repo>/late-podman, pinned installs a COPY of
  <repo>/late-podman, fork-main/upstream-main copy it from the
  extracted tarball root (skipped with a warning if the tarball lacks
  it). Same .bak-<timestamp> archiving on mode transitions, plan lines
  in --dry-run, and a printed note that late-podman's runtime requires
  a Linux host + podman.

- New `uninstall` subcommand (name-invoked only, NOT in the --choice
  menu; --choice + uninstall -> usage error):
  ./install-dev.sh uninstall [--purge] [--with-deps] [--yes] [--dry-run]
  [--target DIR]
  - removes the installed `late` (symlink or regular file) in the
    target dir; nothing inside the brew prefix is ever deleted —
    `brew uninstall late` is printed as advice instead; a symlink into
    a different repo is removed while that repo is kept
  - removes `late-podman` in the target dir / ~/.local/bin only when it
    is our own copy (symlink into this repo, sha256 match with
    <repo>/late-podman, or an older launcher recognized by its usage
    header); foreign files are left untouched
  - removes all late.bak-* / late-podman.bak-* archives in the target dir
  - --purge additionally deletes user data (each path printed first,
    guarded against non-"late" basenames): the config dir exactly as the
    Go code computes it (internal/pathutil.LateConfigDir = os.UserConfigDir()/late —
    ~/Library/Application Support/late on darwin, honoring XDG_CONFIG_HOME
    on Linux; contains config.json, mcp_config.json, plugins/ and the
    skills/ dir, which lives inside the config dir) and the data dir
    ~/.local/share/late (internal/pathutil.LateSessionDir parent).
    Interactive runs confirm y/N (default N); headless runs require --yes.
  - --with-deps PRINTS the exact package-manager removal commands for
    late-relevant deps (podman, go; e.g. `brew uninstall podman`,
    `sudo apt remove podman`) after checking PM ownership — never
    executes them (they may be shared with other projects); documented
    in --help
  - idempotent: an already-uninstalled system prints "nothing to
    uninstall" and exits 0; --dry-run prints every planned removal
    without executing

- README Development installer section: uninstall mode, purge semantics,
  deps printed not removed, late-podman parity, podman/Linux runtime note.

Shellcheck note: the fork/upstream tarball build now passes GOOS/GOARCH
to go via env-prefix instead of `export` inside the subshell, so the
script's host GOOS stays unmodified (shellcheck SC2030/SC2031 clean).

Paths: install-dev.sh, README.md.
Emasoft added a commit to Emasoft/late-cli that referenced this pull request Sep 21, 2026
Union of PR mlhher#131 (feat/subagent-control @ 2d96f26, main-based) into the
local/full stack (mlhher#127 resilient streaming + mlhher#129 OTP gate + mlhher#130 bash
timeouts). Both feature sets are kept and both test suites pass.

Per-file union decisions:

- cmd/late/main.go: adopted incoming's budget resolution stack (24h
  DefaultSubagentTimeout, config.json subagent_timeout precedence via
  flag.Visit + ResolveSubagentTimeout, per-spawn timeout override via
  effectiveSubagentBudget) and the nested-spawn busy tracking
  (BeginNestedSpawn/EndNestedSpawn + parent heartbeat around
  child.Execute, SetContext(runCtx), child SetIdlePolicy). Kept
  local/full's transcript writer + cause classification: the
  classification now uses the EFFECTIVE budget and gained an idle-kill
  cause (child IdleKillReason() non-empty) that renders as
  "idle: killed by the harness idle watchdog (<probe summary>)" ahead of
  the user-cancel case. Flags: union --subagent-timeout (default 24h
  from appconfig), --subagent-idle-timeout, --subagent-idle-kill-after
  plus all local/full flags (--bash-timeout, --continue-project, OTP,
  ...); SetIdlePolicy applied to root and children.

- internal/orchestrator/base.go: union of event hardening (non-blocking
  progress sends via trySendProgress + droppedEvents counter, blocking
  terminal/ChildAdded sends) with the activity-aware idle watchdog
  (lastActivity/inFlightTools/nestedSpawns/oldestToolStartAt,
  activityMiddleware outermost, two-stage tool-kill then agent-kill,
  injectable tick, SetIdlePolicy/MarkActivity/IdleKillReason).
  MarkActivity fires in both Execute and run() stream callbacks; the
  idle event is emitted non-blocking (select/default).

- internal/executor/executor.go: harness-note delivery kept; per-call
  toolCtx + SetInFlightToolCancel/Clear and the "tool cancelled by the
  harness idle watchdog" result path added. inFlightKill is captured
  BEFORE the per-call toolCancel() and gates the harness note, so a
  watchdog tool-kill (failure-shaped "Command failed with exit code -1"
  from the shell SIGKILL) never asks the coder to report back; a real
  shell failure or a per-call timeout still gets the note.

- internal/tool/implementations.go: single ShellTool.Execute with
  per-call timeout resolution (resolveShellTimeout: absent=global,
  0/negative=unlimited, invalid=error result), global 10m default wired
  to --bash-timeout via SetShellTimeout, timeout message uses the
  effective bound. Kept local/full's harness-note-free error path (no
  duplicated coder sandwich) and the otp_code parameter; Parameters now
  declare both otp_code and timeout.

- internal/tool/shell_command_unix.go, shell_command_windows.go:
  identical mechanism on both sides (process-group kill + WaitDelay);
  incoming's comments adopted.

- internal/config/config.go: PermissionMode constants + SubagentTimeout
  entry coexist; ResolvePermissionMode and ResolveSubagentTimeout both
  kept.

- internal/common/interfaces.go: RetryEvent + RecoveryEvent (local/full)
  and SubagentIdleEvent + ActivityMarker (incoming) kept together.

- internal/tui/update.go: SubagentIdleEvent status-line case added to
  the hardened update loop.

- internal/tool/shell_timeout_test.go: local/full's de-flaked timeout/
  grandchild/pgrep tests plus incoming's per-call timeout and
  resolveShellTimeout tests.

- docs/quickstart.md: incoming Subagent Control section; Common Flags
  row updated to the 24h default.

Integration test added:
TestExecuteToolCalls_WatchdogToolKillDoesNotAttachNote guards the union
of the in-flight hook with the harness note.

Gates: go build ./... ok, go vet ./... ok, go test ./... -race -count=1
all packages ok, gofmt clean on touched files, ./install-dev.sh check ok.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants