Closes the biggest deferred item from PR-5. metadata.postInstall
scripts now actually run when their hash changes, with audit emission
back to mcpd.
Trust model unchanged from the corporate-appliance design: mcpd is
the source of truth, content is reviewed at publish time, the client
just runs. No sandbox, no signing, no consent prompts. The controls
that matter are already on the publishing side (RBAC, audit, reviewer
queue).
What we DO provide is ops hygiene:
- Hard timeout (default 60 s; per-skill override via
metadata.postInstallTimeoutSec). SIGTERM at the deadline, SIGKILL
after a 2 s grace.
- Hash pinning. The script's sha256 is recorded in
~/.mcpctl/skills-state.json. Re-syncs of an unchanged script are a
cheap no-op. A re-published "same version, fixed script" still
triggers re-execution because its hash changed.
- Curated env. PATH/HOME/USER/SHELL inherited; everything else dropped.
MCPCTL_SKILL_NAME / _VERSION / _DIR / _PROJECT injected so scripts
have stable context.
- Per-skill install log under ~/.mcpctl/skills/<name>/install.log.
Bounded at 5 × 256 KB; old entries truncated from the front.
- Audit event back to mcpd (POST /api/v1/audit-events,
eventKind=skill_postinstall) on every run, including hostname so
admins can see fleet rollout state. Best-effort — failures are
warned but never block the sync.
- Path-escape rejection. metadata.postInstall must resolve inside the
skill bundle; relative paths that try to climb out are refused.
- Auto-chmod 0755 on the script before spawn. Some upstreams ship 0644
+ a shebang and expect a shell to handle it; we always spawn the
path directly so we need +x.
Failure semantics: on timeout or non-zero exit, the recorded
postInstallHash is NOT updated. Next sync retries. Other skills in
the same sync run continue regardless — postInstall errors are
scoped, not fatal.
## Files
### Added
- src/cli/src/utils/postinstall.ts (~200 LOC)
- src/cli/tests/utils/postinstall.test.ts (~190 LOC, 10 tests covering
success, env vars, chmod, non-zero exit, timeout via exec sleep,
path-escape, missing script, log file shape + append-across-runs)
### Edited
- src/cli/src/commands/skills.ts: applyOne now invokes runPostInstall
+ emitPostInstallAudit when metadata.postInstall is set and the
script hash differs from prior state. New SyncResult fields:
postInstallsRan, postInstallsSkipped. Summary line surfaces
"N postInstall ran". --skip-postinstall flag now actually does what
it says.
## Verification
163 test files / 2171 tests green (up from 2161).
End-to-end on a real machine (after this PR ships and a skill with
metadata.postInstall is published):
```
mcpctl skills sync
# → mcpctl: 1 installed, 1 postInstall ran
cat ~/.mcpctl/skills/<name>/install.log # see stdout/stderr
mcpctl skills sync # idempotent — skipped
```
If the same skill is republished with a fixed script:
```
mcpctl skills sync
# → mcpctl: 1 updated, 1 postInstall ran (hash changed → rerun)
```
If the script fails (exit 7):
```
mcpctl skills sync
# → mcpctl: 1 updated, 1 errors
mcpctl skills sync # state.postInstallHash NOT updated → retries
```
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>