Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions cmd/daemon/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ func main() {
showVersion := flag.Bool("version", false, "print version and exit")
logLevel := flag.String("log-level", "info", "log level (debug, info, warn, error)")
logFormat := flag.String("log-format", "text", "log format (text, json)")
sandbox := flag.Bool("sandbox", false, "restrict all file I/O to the sandbox directory (see -sandbox-dir)")
sandboxDir := flag.String("sandbox-dir", "", "confinement root when -sandbox is set (default: ~/.pilot)")
motdFeedURL := flag.String("motd-feed-url", motd.DefaultFeedURL, "message-of-the-day feed URL (empty to disable); overridden by $PILOT_MOTD_URL")
motdInterval := flag.Duration("motd-interval", 0, "message-of-the-day poll interval (default 15m)")
telemetryURL := flag.String("telemetry-url", os.Getenv("PILOT_TELEMETRY_URL"),
Expand Down Expand Up @@ -169,6 +171,39 @@ func main() {

logging.Setup(*logLevel, *logFormat)

// Sandbox: validate all configured file paths are under the confinement
// root before the daemon touches the filesystem. Network paths are unaffected.
if *sandbox {
sbDir := *sandboxDir
if sbDir == "" {
if home, err := os.UserHomeDir(); err == nil {
sbDir = filepath.Join(home, ".pilot")
}
}
abs, err := filepath.Abs(sbDir)
if err != nil {
log.Fatalf("sandbox: resolve sandbox-dir %q: %v", sbDir, err)
}
sbDir = abs
slog.Info("sandbox mode active", "dir", sbDir)
checkSandbox := func(label, path string) {
if path == "" {
return
}
abs, err := filepath.Abs(path)
if err != nil {
log.Fatalf("sandbox: resolve %s path %q: %v", label, path, err)
}
rel, err := filepath.Rel(sbDir, abs)
if err != nil || strings.HasPrefix(rel, "..") {
log.Fatalf("sandbox violation: %s path %q escapes sandbox dir %q", label, path, sbDir)
}
}
checkSandbox("config", *configPath)
checkSandbox("identity", *identityPath)
checkSandbox("socket", *socketPath)
}

if registryFromEnv {
slog.Warn("PILOT_REGISTRY env var overrides compiled default — registry address redirected to " + *registryAddr + ". If this is unexpected, check the daemon's environment for tampering.")
}
Expand Down
9 changes: 7 additions & 2 deletions cmd/pilotctl/appstore.go
Original file line number Diff line number Diff line change
Expand Up @@ -1962,9 +1962,14 @@ func reviewPromptText(appID string) string {
return fmt.Sprintf("consider leaving a review for %s", appID)
}

// maybeInterceptOutput replaces result with a review prompt when the
// appstore.review_prompt feature flag is on and the random roll hits.
// maybeInterceptOutput replaces result with a review prompt when reviews
// consent is on, the appstore.review_prompt feature flag is on, and the
// random roll hits.
func maybeInterceptOutput(result []byte, appID string) ([]byte, bool) {
home, _ := os.UserHomeDir()
if !consent.GetConsent(home, "reviews") {
return result, false
}
if !featureEnabled("appstore.review_prompt") {
return result, false
}
Expand Down
6 changes: 4 additions & 2 deletions cmd/pilotctl/review.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,10 @@ Examples:
pilotctl review io.pilot.cosift --rating 4
pilotctl review io.pilot.cosift --text "Very useful app"

Reviews are sent to the telemetry endpoint (consent-gated — no-op when
reviews consent is off or PILOT_TELEMETRY_URL is unset).
Reviews are sent to the telemetry endpoint and are consent-gated: no-op
when reviews consent is off (set {"consent": {"reviews": false}} in
~/.pilot/config.json). When consent is on, falls back to the default
endpoint if PILOT_TELEMETRY_URL is unset.
`

// cmdReview handles `pilotctl review <pilot|app-id> [--rating N] [--text "..."]`.
Expand Down
75 changes: 63 additions & 12 deletions cmd/pilotctl/skills.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,13 @@ import (
//
// Subcommands:
//
// pilotctl skills — alias for `status`
// pilotctl skills status — show per-tool install paths + state
// pilotctl skills paths — print just the install paths
// pilotctl skills check — run one reconcile pass right now
// pilotctl skills disable <skill|all> — remove every file we wrote + opt out of future ticks
// pilotctl skills enable <skill|all> — opt back in + run one reconcile pass
// pilotctl skills — alias for `status`
// pilotctl skills status — show per-tool install paths + state
// pilotctl skills paths — print just the install paths
// pilotctl skills check — run one reconcile pass right now
// pilotctl skills disable <skill|all> — remove every file we wrote + set mode disabled
// pilotctl skills enable <skill|all> — re-enable (auto mode) + run one reconcile pass
// pilotctl skills set-mode auto|manual|disabled — persist mode to ~/.pilot/config.json
func cmdSkills(args []string) {
sub := "status"
if len(args) > 0 && !strings.HasPrefix(args[0], "--") {
Expand All @@ -44,17 +45,21 @@ func cmdSkills(args []string) {
cmdSkillsDisable(args)
case "enable":
cmdSkillsEnable(args)
case "set-mode":
cmdSkillsSetMode(args)
default:
fatalHint("invalid_argument",
"available: status, paths, check, disable, enable",
"available: status, paths, check, disable, enable, set-mode",
"unknown skills subcommand: %s", sub)
}
}

func runTick() (*skillinject.Report, error) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
return skillinject.Tick(ctx, skillinject.Config{})
// ForceTick bypasses the disabled-mode guard so update and explicit
// check commands work regardless of the current mode setting.
return skillinject.ForceTick(ctx, skillinject.Config{})
}

// cmdSkillsStatus runs one tick (fetching the manifest + entrypoint over
Expand Down Expand Up @@ -89,8 +94,16 @@ func cmdSkillsStatus(args []string) {
return
}

home, _ := os.UserHomeDir()
mode := skillinject.GetMode(home)
modeDesc := map[string]string{
skillinject.ModeAuto: fmt.Sprintf("auto — reconciles every %s + on daemon start", skillinject.DefaultInterval),
skillinject.ModeManual: "manual — installed once, updated only on `pilotctl update` or `pilotctl skills check`",
skillinject.ModeDisabled: "disabled — no skills injected",
}[mode]

fmt.Println(sBold("Pilot Protocol skill — install status"))
fmt.Println(sDim(fmt.Sprintf("Reconcile cadence: every %s (default), plus once on daemon startup · paths are auto-managed — manual edits revert on next tick", skillinject.DefaultInterval)))
fmt.Printf("Mode: %s\n", sDim(modeDesc))
fmt.Println()

if len(report.Outcomes) == 0 {
Expand Down Expand Up @@ -268,7 +281,7 @@ func cmdSkillsDisable(args []string) {
report, uErr := skillinject.Uninstall(ctx, skillinject.Config{})
// Persist the opt-out regardless of partial removal failures —
// the next tick must be a no-op so we don't fight the user.
persistErr := skillinject.SetEnabled(home, false)
persistErr := skillinject.SetMode(home, skillinject.ModeDisabled)

if jsonOutput {
out := map[string]interface{}{
Expand Down Expand Up @@ -358,8 +371,8 @@ func cmdSkillsEnable(args []string) {
if err != nil {
fatalCode("internal", "home dir: %v", err)
}
if err := skillinject.SetEnabled(home, true); err != nil {
fatalCode("internal", "persist enabled flag: %v", err)
if err := skillinject.SetMode(home, skillinject.ModeAuto); err != nil {
fatalCode("internal", "persist mode: %v", err)
}

report, err := runTick()
Expand Down Expand Up @@ -394,6 +407,44 @@ func cmdSkillsEnable(args []string) {
}
}

// cmdSkillsSetMode persists the skillinject mode to ~/.pilot/config.json.
//
// - auto — daemon ticks on its 15-minute cadence (always up to date)
// - manual — ticks only on daemon startup, pilotctl update, or pilotctl skills check
// - disabled — no ticks, no files written; equivalent to pilotctl skills disable all
func cmdSkillsSetMode(args []string) {
if len(args) == 0 {
fatalHint("invalid_argument",
"usage: pilotctl skills set-mode auto|manual|disabled",
"mode required")
}
mode := args[0]
switch mode {
case skillinject.ModeAuto, skillinject.ModeManual, skillinject.ModeDisabled:
default:
fatalHint("invalid_argument",
"valid modes: auto, manual, disabled",
"unknown mode: %s", mode)
}
home, err := os.UserHomeDir()
if err != nil {
fatalCode("internal", "home dir: %v", err)
}
if err := skillinject.SetMode(home, mode); err != nil {
fatalCode("internal", "persist mode: %v", err)
}
if jsonOutput {
outputOK(map[string]interface{}{"mode": mode})
return
}
modeDesc := map[string]string{
skillinject.ModeAuto: "auto — daemon reconciles every 15 minutes and on each startup",
skillinject.ModeManual: "manual — skills installed once; updated only on `pilotctl update` or `pilotctl skills check`",
skillinject.ModeDisabled: "disabled — no skills injected; run `pilotctl skills enable all` to re-enable",
}[mode]
fmt.Printf("Pilot Protocol skill mode set to %s\n%s\n", sBold(mode), sDim(modeDesc))
}

// skillInstallTools returns the agent tools that have the pilot skill
// installed, in detection order. Empty when no agent tools are present
// on the host. Same data source as `pilotctl skills`, collapsed to one
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ require (
github.com/pilot-protocol/policy v0.2.2
github.com/pilot-protocol/rendezvous v0.2.5-0.20260615154750-f09cf1a708b0
github.com/pilot-protocol/runtime v0.3.1
github.com/pilot-protocol/skillinject v0.2.2
github.com/pilot-protocol/skillinject v0.2.3
github.com/pilot-protocol/trustedagents v0.2.3
github.com/pilot-protocol/updater v0.2.2-0.20260616131353-92a3a30a235e
github.com/pilot-protocol/webhook v0.2.0
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ github.com/pilot-protocol/skillinject v0.2.1 h1:r7cwDlRTLHGPhL2+RtGa0GWz/M89yw0M
github.com/pilot-protocol/skillinject v0.2.1/go.mod h1:toizAf7eI2IgsDRGiqF3mRiVpF4ISYwVQeO3ZltZEcM=
github.com/pilot-protocol/skillinject v0.2.2 h1:cQKvafj2hJM7ewhrRuWnb8a3uzdgSPvkNs1F2j1NlUA=
github.com/pilot-protocol/skillinject v0.2.2/go.mod h1:toizAf7eI2IgsDRGiqF3mRiVpF4ISYwVQeO3ZltZEcM=
github.com/pilot-protocol/skillinject v0.2.3 h1:Bf0tqRe7tqYY27X5RGCOf4LGjtWpyQvN/03YumDBDJs=
github.com/pilot-protocol/skillinject v0.2.3/go.mod h1:fCzivA/bjkXRgGjp6yd7nqfaIETtU+lQRocBu0J/O9g=
github.com/pilot-protocol/trustedagents v0.2.2 h1:EpK25654aN+CBeBhZkHUPh3J545pGoxLofLJmDmo1F0=
github.com/pilot-protocol/trustedagents v0.2.2/go.mod h1:r3wYwh5QpFDwG4nXbCA3RH2aA+hqM07KLMFFc3tbvKA=
github.com/pilot-protocol/trustedagents v0.2.3 h1:QQJHYqzPrECJwkCev0xIDBMjd92uhtcxcCMc2aOrRHc=
Expand Down
71 changes: 45 additions & 26 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -419,7 +419,8 @@ cat > "$PILOT_DIR/config.json" <<CONF
"socket": "/tmp/pilot.sock",
"encrypt": true,
"identity": "${PILOT_DIR}/identity.json",
"email": "${EMAIL}"
"email": "${EMAIL}",
"skill_inject": {"mode": "manual"}
}
CONF

Expand Down Expand Up @@ -716,37 +717,55 @@ echo ""
echo "============================================"
echo " CONSENT & PRIVACY"
echo ""
echo " By default, the daemon enables several optional features that"
echo " improve the Pilot Protocol experience. You are in control:"
echo " each can be disabled at any time without affecting core"
echo " messaging or networking functionality."
echo " The following features are ON by default. Each can be disabled"
echo " at any time — disabling does NOT affect core messaging or"
echo " networking functionality."
echo ""
echo " Features ON by default:"
echo " TELEMETRY (on by default)"
echo " When you browse or install apps from the app store, we record"
echo " the app ID and action (view / install). This helps app developers"
echo " understand interest in their apps. No personal data or message"
echo " contents are ever sent."
echo " To disable: set consent.telemetry = false in config.json (below)."
echo ""
echo " telemetry — app-store view/install interest signals"
echo " broadcasts — messages Pilot pushes to agents from"
echo " trusted networks"
echo " reviews — occasional prompt to review installations"
echo " skillinject — auto-inject the Pilot Protocol skill into"
echo " agent toolchains"
echo " BROADCASTS (on by default)"
echo " Pilot Protocol can send messages to your agent through the daemon"
echo " to deliver updates or trigger coordinated actions across a network."
echo " If disabled, broadcast messages are silently dropped and never"
echo " reach your agent."
echo " To disable: set consent.broadcasts = false in config.json (below)."
echo ""
echo " Disable any feature by adding \"false\" entries under the \"consent\""
echo " key in config.json:"
echo " ${PILOT_DIR}/config.json"
echo " REVIEWS (on by default)"
echo " Occasionally, after using Pilot or installing an app, you may be"
echo " prompted to leave a short review. It is entirely optional — press"
echo " Enter to skip, or just use pilot again normally. Your rating and"
echo " optional text are the only data sent."
echo " To disable: set consent.reviews = false in config.json (below)."
echo ""
echo " SKILL INJECTION (on by default, manual mode)"
echo " Automatically installs the Pilot Protocol skill into supported"
echo " agent toolchains (Claude Code, Cursor, OpenHands, etc.) so agents"
echo " on this host can discover and call Pilot services. In MANUAL mode"
echo " (the default), skills are installed once now and refreshed only"
echo " when you run 'pilotctl update'. Switch to AUTO mode for continuous"
echo " background updates, or disable entirely:"
echo " pilotctl skills set-mode auto # always up to date"
echo " pilotctl skills set-mode manual # install once, update on upgrade"
echo " pilotctl skills disable all # remove skills, stop injection"
echo ""
echo " {\"consent\": {"
echo " \"telemetry\": false, // suppress interest signals"
echo " \"broadcasts\": false, // block agent-directed broadcasts"
echo " \"reviews\": false // suppress review prompts"
echo " }}"
echo " To opt out of telemetry, broadcasts, or reviews, edit:"
echo " ${PILOT_DIR}/config.json"
echo ""
echo " Skillinject has its own CLI commands:"
echo " pilotctl skills disable # remove injected skills, stop future ticks"
echo " pilotctl skills enable # re-install and re-enable"
echo " Add or merge the following (valid JSON, no comments):"
echo " {"
echo " \"consent\": {"
echo " \"telemetry\": false,"
echo " \"broadcasts\": false,"
echo " \"reviews\": false"
echo " }"
echo " }"
echo ""
echo " Config changes take effect on daemon restart (or immediately for"
echo " skillinject). Telemetry features are ON by default (opt-out model)."
echo " Set the consent flags above to false to disable them."
echo " Changes to config.json take effect on daemon restart."
echo ""
echo "============================================"
echo ""
6 changes: 6 additions & 0 deletions pkg/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"github.com/TeoSlayer/pilotprotocol/internal/motd"
"github.com/TeoSlayer/pilotprotocol/internal/transport/compat"
"github.com/TeoSlayer/pilotprotocol/internal/validate"
"github.com/pilot-protocol/common/consent"
"github.com/pilot-protocol/common/crypto"
"github.com/pilot-protocol/common/daemonapi"
"github.com/pilot-protocol/common/fsutil"
Expand Down Expand Up @@ -4212,6 +4213,11 @@ func (d *Daemon) SendDatagram(dstAddr protocol.Addr, dstPort uint16, data []byte
// backbone (network 0); membership of the sender is NOT required — admin
// tokens are root-level. Per-recipient outbound port policy still applies.
func (d *Daemon) BroadcastDatagram(netID uint16, dstPort uint16, data []byte, adminToken string) error {
home, _ := os.UserHomeDir()
if !consent.GetConsent(home, "broadcasts") {
slog.Debug("broadcast dropped: broadcasts consent is off")
return nil
}
if d.config.AdminToken == "" {
return fmt.Errorf("broadcast denied: daemon has no admin token configured")
}
Expand Down
Loading