Commit Graph
18 Commits
Author SHA1 Message Date
fred 585dac7a5d fix(launch): resolve the runtime binary once and execute the object that was checked
AMD1213-D defect D3. The fleet launch path asked `which` whether a runtime was
reachable and then spawned the bare name, letting the OS resolve it a second
time against an ambient PATH at a later moment. Two independent resolutions of
an attacker-influenced name with a gap in between is not a check.

Measured against the old code before changing it. A world-writable shim named
`codex` prepended to PATH:

    OLD checkRuntime  -> PASSED (which found it)
    OLD execRuntime   -> "SHIM EXECUTED -- this is not the real runtime"

The probe satisfied the check and then supplied the thing that ran.

Three call sites were exposed, not one: `checkRuntime`'s `which`; `execRuntime`
spawning 'codex'/'opencode' by name; and `execLeaseGatedRuntime` spawning
'python3' by name -- the interpreter that starts the lease gate itself, where a
shim does not bypass one check, it replaces the process that enforces all of
them. `minimalLaunchEnv` copies ambient PATH straight through, so the child
inherits the same search.

The fix: `resolveExecutableFromPath` searches only the PATH the child will
actually receive, validates the object the search lands on (regular file,
executable, not group/other-writable, owned by the launching user or root, with
no group/world-writable non-sticky directory and no foreign-owned directory on
its resolved path), and returns that path pinned to its dev/ino. Callers execute
the returned path, never the name again. Rules that are each a hole if dropped:
a relative PATH entry is skipped, since it resolves against wherever the
launcher was started; the first name match decides the outcome and an unsafe
first match is a refusal rather than a reason to keep looking, because falling
through would let a planted binary silently downgrade the search to whatever
came after it; a symlink is followed and the real file is what gets validated
and executed, since validating the link and executing the name repeats the
original bug one level down.

`checkRuntime` is kept unchanged on the operator path. `which` proves
reachability from the operator's own shell, which is the right question there
and the wrong one for a seat. The fleet lease-gate interpreter now comes from
the root-owned `trustedCapability('python3')` the helper already requires.

Two residuals, stated rather than engineered around:

  * `assertUnchangedSinceValidation` re-confirms dev/ino immediately before
    spawn. That narrows the validation-to-exec window; it does not close it.
    Closing it means executing a held descriptor and Node has no portable way to
    exec by descriptor. A same-UID replacement landing inside the remaining
    window is the same accepted boundary already documented for the fleet
    helper.
  * For claude and pi the runtime binary is still re-resolved inside
    launch-runtime.py after the trusted interpreter starts it. This change does
    not cover that path.

Twelve tests in launch.spec.ts, each written against a specific hole: safe
resolution; world-writable binary; safe binary under a world-writable
directory; no fall-through past an unsafe first match; relative PATH entry
ignored; symlink followed and real file validated; symlink to an unsafe target
refused; non-executable refused; directory sharing the name refused; a path
rather than a name refused; no PATH declared; not-found reported as not-found
rather than resolving something else.

One of those tests was written wrong first and is worth recording: creating the
open directory with `mkdirSync(path, { mode: 0o777 })` gets masked by the umask
to 0o755, so the case passed while testing nothing. It creates at 0o755 and
chmods after.

Verification: typecheck RC=0. Full package suite 1615 passed / 4 failed / 1619.
The four failures are the pre-existing host lease-identity leak into spawned
hooks, not this change -- the same spec re-run with only the five MOSAIC_LEASE_*
and MOSAIC_RUNTIME_GENERATION variables stripped from the environment, with no
code change, is 20/20.

Scope note: this commit carries the uncommitted D1/D4/D6 work already present in
the tree alongside D3, because it is interleaved in the same files and is one
amend package. D2 and D5 are not yet assessed.

Commit-only per scrappy's controlling packet (comms 20260813T212447Z dc43de):
not pushed, PR #1213 not updated, nothing re-authored.
2026-08-15 14:07:57 -05:00
terra 326a1a58b5 fix(fleet): harden managed launch composition
AMD1213-C: repair stale array consumer, fail closed on foreign link provenance, validate manifests before mutation, and exercise the fleet MCP preflight call path.
2026-08-13 15:37:32 -05:00
terra 2755f86f7b fix(fleet): seed seat MCP preflight config
AMD1213-B5: derive Claude seat MCP configuration from the active installed runtime base and inspect the isolated seat during fleet launch.
2026-08-13 14:38:25 -05:00
Jason Woltje 709a23d08c feat(mosaic): mechanically authorize lease promotion 2026-08-11 20:51:03 -05:00
Jason Woltje 8109f72cf7 fix(sync-skills): guard the pre-existing prune against an empty prefix (#1087)
prune_stale_links_in_target compared "$resolved" == "$canonical_real/"* while
length-checking only $resolved. If $canonical_real were ever empty the pattern
collapses to == "/"* and matches every absolute path.

The failure is precisely inverted, which is what makes it worth fixing rather
than noting: is_mosaic_skill_name already `continue`s for names that ARE current
mosaic skills, so an empty prefix would delete exactly the FOREIGN symlinks in
every target directory and preserve the mosaic ones. On this host that is 4 base
installs, including codex's own .system entry.

Reported by mos-claude as #1087 after I introduced the same guard in the new
legacy-cleanup path in the previous commit and walked past this instance thirty
lines away. Same defect class, same file, one function apart.

$canonical_real is populated by readlink -f after a mkdir -p, so an empty value
requires readlink to fail — unlikely, but the consequence is deleting operator
symlinks across every harness, which is not a risk worth carrying for one test.
2026-08-11 20:51:03 -05:00
Jason Woltje e4dffb7c18 feat(launch): isolate harness homes and record immutable launch provenance
Mosaic wrote into the operator's harness base installs — ~/.claude,
~/.pi/agent, ~/.codex, ~/.config/opencode — for settings, instructions, and a
102-symlink skill farm per harness. Any experiment with hooks or gating
therefore mutated the operator's own tooling, and a broken framework change
could take out the very harness needed to repair it.

Harness home isolation
----------------------
Each runtime now reads config from a dedicated mosaic-owned home via the
harness's own config-dir variable:

  claude    CLAUDE_CONFIG_DIR     ~/.config/mosaic/.claude
  pi        PI_CODING_AGENT_DIR   ~/.config/mosaic/.pi     (replaces ~/.pi/agent)
  codex     CODEX_HOME            ~/.config/mosaic/.codex
  opencode  XDG_CONFIG_HOME       ~/.config/mosaic/.opencode

These paths are manifest-UNKNOWN, so rule 3 (#791) resolves them to operator
ownership and a keep-mode upgrade can neither overwrite nor prune them.
A bare `claude` / `pi` keeps its own config AND auth, making it a structural
break-glass rather than one depending on restoring a file under pressure.

opencode is blunter than the rest: it has no dedicated variable and follows XDG,
so isolation also relocates XDG lookups for anything it spawns. Documented in
place.

mosaic-sync-skills now links into those homes and cleans the legacy farms it
previously planted in base installs. Ownership is proven by RESOLUTION, not by
name — only symlinks resolving inside the canonical/local skills dirs are
removed, mirroring the refusal already in commands/skill.js. Verified against a
real install: codex's own .system directory survived while its 102 mosaic links
were removed. Both resolution prefixes are length-checked first; an empty prefix
would make "$resolved" == "$prefix/"* match every absolute path and delete
foreign symlinks.

Immutable launch record
-----------------------
Every launch now appends one record to fleet/run/sessions/events.ndjson before
exec. Mandatory, mechanical, no model involvement.

pi rewrites its own argv to a bare `pi`, so /proc/<pid>/cmdline destroys the
launch evidence — that has already produced a confident wrong diagnosis ("this
agent bypassed the launcher"), disproved only by the parent's argv and only
because the parent had not yet exited. A record written before exec is the only
place this survives.

The path is the #797 Runtime Session Ledger, already operator-classified and
already covered by test-upgrade-manifest-guard.sh, which seeds it and proves a
populated ledger survives keep-mode upgrades — but nothing shipped ever wrote
it. This implements it in the shape that guard already asserts (0600 files under
a 0700 dir).

`mosaic` writes session.launch; launch-runtime.py appends lease.register with
the broker session id and activation capability. They correlate by an explicit
MOSAIC_LAUNCH_ID, never by pid: execRuntime uses spawnSync, so the runtime is a
child with a different pid.

Records normative fragment digests (CONSTITUTION/AGENTS/SOUL/USER/STANDARDS/
TOOLS/RUNTIME) — the same set the broker hashes for promotion, so drift is
mechanically detectable rather than a matter of judgement.

Credential-safe: env is captured as PRESENT NAMES ONLY, and argv values over
256 bytes become a sha256 + length rather than being inlined.

Also fixes CLI_VERSION resolution: '@mosaicstack/mosaic/package.json' is not in
the package exports map and always throws ERR_PACKAGE_PATH_NOT_EXPORTED.
resolveTool() uses that same failing specifier, which is why its documented
preference for bundled tools over the deployed ~/.config/mosaic copy has never
once applied — noted in place, not fixed here.

Verified on sb-it-1-dt: isolated homes written and base installs byte-identical
for all four harnesses; 408 legacy symlinks removed with 1 foreign entry
preserved; launch records paired across the spawn boundary. typecheck shows zero
errors in launch.ts (the @mosaicstack/types failures are pre-existing and
reproduce on a pristine origin/main worktree).
2026-08-11 20:51:03 -05:00
Jason Woltje 12677a928d fix(mosaic): prevent init prompt code execution
ci/woodpecker/pr/ci Pipeline was successful
2026-08-09 00:28:57 -05:00
jason.woltjeandMos d351caad36 feat(869-c2): install-ordering enforcement-hook guard (Part of #869)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Part of #869

Mos (id-11) Gate-16 merge: independent APPROVE @b6f36564 (8/8, verified vs real production settings template), author id2 != approver id11, clean mosaic-coder author, CI green wp1988.

Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-23 18:48:33 +00:00
jason.woltje d801d6c4c8 feat(mosaic): add secure skill registration CLI (#826)
ci/woodpecker/push/ci-image Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-17 23:45:35 +00:00
jason.woltje bb7d549080 feat(framework): P4 — upgrade-safe Constitution migration (both installers) (#590)
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-06-21 23:03:48 +00:00
jason.woltje bf24066a49 feat(framework): P1+P2 — public sanitization + blocking CI gate (#572)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-06-21 02:40:11 +00:00
jason.woltje 172bacb30f feat(mosaic): IUV-M02 — CORS/FQDN UX polish + skill installer rework (#444)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-05 23:44:07 +00:00
jason.woltje 732f8a49cf feat: unified first-run flow — merge wizard + gateway install (IUH-M03) (#433)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-05 19:13:02 +00:00
jason.woltje a23c117ea4 fix: auto-migrate customized skills to skills-local/ on sync (#359)
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline failed
2026-04-03 02:11:03 +00:00
jason.woltje 0cf80dab8c fix: stale update banner + skill sync dirty worktree crash (#358)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-03 02:04:05 +00:00
jarvis e83674ac51 fix: mosaic sync — auto-stash dirty worktree before pull --rebase
ci/woodpecker/pr/ci Pipeline failed
ci/woodpecker/push/ci Pipeline was successful
git pull --rebase fails with 'cannot pull with rebase: You have
unstaged changes' when the skills repo has local modifications.

Fix: detect dirty index/worktree, stash before pull, restore after.
Also gracefully handle pull failures (warn and continue with existing
checkout) and stash pop conflicts.
2026-04-02 20:41:11 -05:00
jarvis 361fece023 fix: make mosaic init idempotent — detect existing config files
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/pr/ci Pipeline failed
- mosaic-init bash script: detect existing SOUL.md/USER.md/TOOLS.md and
  prompt user to keep, import (re-use values as defaults), or overwrite.
  Non-interactive mode exits cleanly unless --force is passed.
  Overwrite creates timestamped backups before replacing files.

- launch.ts checkSoul(): prefer 'mosaic wizard' over legacy bash script
  when SOUL.md is missing, with fallback to mosaic-init.

- detect-install.ts: pre-populate wizard state with existing values when
  user chooses 'reconfigure', so they see current settings as defaults.

- soul-setup.ts: show existing agent name and communication style as
  defaults during reconfiguration.

- Added tests for reconfigure pre-population and reset non-population.
2026-04-02 20:20:59 -05:00
jarvis 15830e2f2a feat!: unify mosaic CLI — native launcher, no bin/ directory
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
BREAKING CHANGE: ~/.config/mosaic/bin/ is removed entirely.
The mosaic npm CLI is now the only executable.

## What changed

- **bin/ → deleted**: All scripts moved to tools/_scripts/ (internal)
- **mosaic-launch → deleted**: Launcher logic is native TypeScript
  in packages/cli/src/commands/launch.ts
- **mosaic.ps1 → deleted**: PowerShell launcher removed
- **Framework install.sh**: Complete rewrite with migration system
- **Version tracking**: .framework-version file (schema v2)
- **Migration v1→v2**: Auto-removes bin/, cleans old PATH entries
  from shell profiles

## Native TypeScript launcher (commands/launch.ts)

All runtime launch logic ported from bash:
- Runtime prompt builder (AGENTS.md + RUNTIME.md + USER.md + TOOLS.md)
- Mission context injection (reads .mosaic/orchestrator/mission.json)
- PRD status injection (scans docs/PRD.md)
- Pre-flight checks (MOSAIC_HOME, AGENTS.md, SOUL.md, runtime binary)
- Session lock management with signal cleanup
- Per-runtime launch: Claude, Codex, OpenCode, Pi
- Yolo mode flags per runtime
- Pi skill discovery + extension loading
- Framework management (init, doctor, sync, bootstrap) delegates
  to tools/_scripts/ bash implementations

## Installer

- tools/install.sh: detects framework by .framework-version or AGENTS.md
- Framework install.sh: migration system with schema versioning
- Forward-compatible: add migrations as numbered blocks
- No PATH manipulation for framework (npm bin is the only PATH entry)
2026-04-02 19:37:13 -05:00