-
Don't loosen the sessionId/branch regexes in
src/git/worktree-manager.ts. They're a defense-in-depth check against path traversal and git argv-flag smuggling (a session id starting with-would be parsed as a flag). The--separator on the git command is the second layer; keep both. -
Hook server is loopback + secret. Any new endpoint added to
src/permissions/hook-server.tsmust validate the secret header — the PWA-facing server (server.ts) is the only thing exposed on the tailnet. -
State lives in
~/.outpost/(override:OUTPOST_RUNTIME_DIR).index.jsonfiles use atomic rename for persistence — preserve that pattern in any new persisted store. -
~/.outpost/.envis sourced at daemon startup (seeenv-file.ts). It exists because launchd strips shell env, so this is how secrets likeGITHUB_TOKENreach the daemon and its sessions without baking them into the plist. Plist > .env > defaults. A value starting withop://is a 1Password reference resolved through theopCLI (resolveOpBin, honoringOUTPOST_OP_BIN) on every load — warnings by default, fatal underOUTPOST_ENV_STRICT=1. Plain values are assigned before references resolve, so a file can configure the loader it's read by; resolved secrets are assigned after, so a failed reference never strands the rawop://string inprocess.env. Oneop readper distinct reference, never logging the value, and the first timeout skips the rest: the reads run synchronously before the server listens, and an unanswered unlock prompt would otherwise cost a full timeout per reference. -
The daemon's GitHub calls don't need
ghwhenGITHUB_TOKENis set. Every one goes throughrunGh(cwd, argv)(src/integrations/gh-cli.ts), which still runs the binary when it's installed (gh reads the same token); only whenghis missing (ENOENT, remembered for the process) does a token send the call toghViaApi(gh-api-shim.ts), which answers the argv from REST/GraphQL and prints the JSONghwould have, so callers and the test fakes keyed on argv never changed. The binary wins because the shim is narrower: an existing install with both keeps gh's fork-upstream, default-repo and GHE resolution. The shim knows only the shapes the daemon uses —api user,api <path> --paginate,pr list --head,pr view --json,pr create,run view,search prs,issue create— and throws on anything else, including an unlisted--jsonfield, rather than guess. So a new GitHub call is a new shim case plus a test ingh-api-shim.test.ts, not just a new argv. Known approximations:pr viewtakes the last 100 reviews and comments (gh pages through all),statusCheckRollupthe first 100 contexts,pr view <branch>takes that head name's newest open PR (else newest in any state) from any fork, and the repo is always origin's (gh may resolve a fork's upstream). Never spawnghdirectly again — that is how five call sites ran blind on a machine without it. -
$OUTPOST_API_URLis exported intoprocess.envat daemon startup, so every spawned session inherits the loopback base URL (claude-proc.tsmergesprocess.envunder its per-session vars). That's how an action step POSTs back to the daemon — it has no other way to learn the port. Actions that use it must allowlist the$OUTPOST_API_URLand127.0.0.1:<port>spellings; the checker sees the command text, not the expanded value. -
A blocker always lands in the action's journal — except a denied write draft.
WorkEngine.onStepFailedappends ablockedentry unless the session already journaled for that step, so the Library's "Self-reported lessons" is never empty for an action that keeps failing (which is the only signalmeta.improve-actionsreads). Pass{ journal: false }for failures the action didn't cause — a daemon bounce, a workspace that wouldn't provision. A user deny of a write draft is the carve-out:denyDraft(src/work/write-draft-runner.ts) journals adeniedoutcome against whoever chose to run the action — the controller, or the job'sorchestratorAction(meta.orchestrate) — never against the write action itself, because the reason is feedback on the decision to run it, not on how it ran. A separategated_deniedoutcome (journalGatedDenialinengine.ts) records a mid-run allowlist/pin miss against the acting action instead; bothdeniedandgated_deniedare deliberately excluded fromonStepFailed's one-entry-per-stepblockedslot so a self-authored lesson is never crowded out by either.A
skippedoutcome is not a deny, and it is filed twice. Skipping every drafted call (settleUnrun) says running the action was right and its payload wasn't, so the lesson goes to the action that drafted the calls. For a briefed draft — acontrollerordispatchraiser — a second copy goes to the briefer, because the drafter didn't choose the content:code.reply-pr-commentspostsboundNoteverbatim and treats anything it adds as unapproved text on a public PR, so blaming it alone files the evidence against the one participant that had no say in what was said. Astepraiser wrote its own payload and keeps the single lesson. The lesson quotes the drafted bodies, not just the call labels — it used to carry comment ids alone, which is how nine recorded skips of PR replies that all opened "You're correct" taught nobody that the replies agreed. Bodies come from the call'sfilesmap (the command only names a/tmppath), rationed againstJournalStore's 400-char cap. Pinned bywrite-draft-skip-lesson.test.ts. The authoring fix for that particular pattern is incode.triage-pr-comments's rubric, not here — a reply disputes or scopes out, never concedes. -
Every catalog action must live in the repo's
actions/, not just~/.outpost/actions/. The runtime dir is what the registry reads, butensureActionsInstalledskips existing real dirs — so a repo-only edit never reaches the daemon, and a runtime-only action doesn't exist on a fresh install. Write both.tests/unit/action-effective-allowlist.test.tspins the resolved grant of the actions whose group defaults alone don't cover their own SKILL.md. -
A rule that grants a network or filesystem write must be a positive whitelist pinned to its destination. Anchor it
^…$, name the exact host/path, and enumerate every flag that may appear — anything unrecognised then denies. A prefix rule grants everything after it:^curl(and^curl -s, and^curl -fsS -X POST) is every method, every body, every URL, plus-o <path>overwriting any local file and--nextstarting a second request with fresh flags. Never write a rule that tries to forbid a flag —-d,-d=x,"-d",-d"",-d$X,-sdall reach argv as the same flag, so quoted spellings must be refused by the whitelist too (gh api "-X" PUT …is a merge)..doesn't match a newline and a\-continuation stays inside one clause, so.*guards leak across lines.tests/unit/permission-group-pull.test.tspins this for thepullgroup. -
A gated write needs an approved pin, not just an allowlist hit. The registry resolves a second allowlist per action,
gated— the subset of its grants that came from aGATED_GROUPSgroup (currently justpush; seeresolvePermissionsinsrc/actions/registry.ts). For an action-bound session, the PreToolUse hook checksgatedfirst: a call matching it is allowed only if it also matches an unconsumedPinnedCallon an approvedWriteDraftfor that session (matchPinnedCallinsrc/work/write-draft.ts— Bash on exact command text past an outer trim; any other tool name matches on itself plus deep-equal args, which in practice means MCP, since that's the only other shape a draft pins) and independently clears the ordinaryallows()check. So an action that "improves" the user's edited payload before running it — reformats the body, adds a flag — gets denied: the pin is for the text the user actually approved, not a paraphrase of it. The pin is consumed at allow time; aPostToolUseFailurereleases it (keyed ontool_use_id, exactly-once) if the call failed, so an identical retry needs no second approval — it does not release on a user interrupt, since it's unknown whether the write landed. Abashcall whose payload is too big for the command line (--input/--body-file/--notes-file, the short-F, and-f key=@path— seeFILE_REFERENCING_FLAGSinsrc/permissions/file-flags.ts) is additionally pinned by a sha256 digest of the referenced file's bytes (PinnedCall.fileDigests, re-verified before the pin is consumed) — command-text matching alone says nothing about whether the file still holds what was approved; the session drafts that body inline asPinnedCall.files(path → content) instead of writing it itself, the user edits it directly in the approval card, andacceptDraft(src/work/write-draft-runner.ts) writes the approved bytes to disk itself before hashing them, so the digest can never drift from what was actually approved.Two ways a write escapes the pin check. (1) The global/project half of this closed with confinement: for an action-bound call,
scopesFor(src/permissions/allowlist.ts) no longer touches global or project scope at all — an action's grants arecore ∪ its declared groups ∪ its colocated allowlist.json(plus session scope), full stop; global/project only apply to a call with noactionName, where there is nogatedset to bypass in the first place.gatedMatch(src/permissions/hook-handler.ts) still consults only the action's staticgatedset, computed once at registry load, while theallows()fallback right below it consults those confined scopes — the colocatedallowlist.json, hot-added per-action overrides in~/.outpost/actions.json, and session scope — none of which ever populategated. A new write-shaped rule can no longer be installed into either remaining scope:assertNotWriteShaped(src/permissions/write-shape.ts) is enforced atAllowlist.addRule,ActionsStore.addRule, and the registry's colocatedallowlist.jsonreader (checked on every load, so this one applies to pre-existing files too — see thepullbullet in permissions.md). What's left: a write-shaped rule already persisted in~/.outpost/actions.jsonbefore this lint shipped still loads and still runs ungated —auditPersistedRules(src/storage/actions-store.ts) only logs a startupconsole.warnnaming it, it does not refuse the load. (2) Anything not shaped like theBash/MCP call the hook can inspect — see theedit-group ceiling in deferred.md. -
Two daemons can't run on the same
~/.outpost/. Use alternate ports for side-by-side testing, but stop the launchd instance first. -
A review comment's
diff_hunkis not the diff around it. GitHub's field runs from the hunk's START to the COMMENTED LINE — so it holds nothing after the comment (often the half that says what the comment is about), and on an added file the hunk starts at line 1, so a comment on line 302 arrives carrying 302 lines of file body. The card rendered it verbatim, uncapped, until this was fixed.pr-file-patches.tsfetches the real per-filepatchon demand soutils/diff-window.jscan anchor the comment inside its hunk and take a window with lines on both sides;diff_hunkis now only the fallback, windowed to its tail. Don't "simplify" the fetch away as redundant with a field that's already in the payload — it isn't the same data. Note also that a collapsed<details>still costs its full markup in every repaint'sinnerHTML, so the expanders render 20 rows, not the rest of the hunk (seeproject_pwa_subagent_repaint_cost— the same shape caused a timeline stutter before). -
PWA modules extracted from
app.jsuse a deps-injection pattern.initX({ callbackA, callbackB })is how the extracted module gets app-side functions it can't cleanly import (would create cycles).app-bridge.jsis the other half of this — a shared reserved-keys object for callbacks multiple components need, installed once viainstallAppBridge()rather than threaded through everyinitX. Follow one of these two patterns when adding more.