Skip to content

Commit 1bdc6b7

Browse files
teovlclaude
andcommitted
feat(consent+sandbox): broadcasts gate, review consent, skillinject modes, sandbox flag, install disclaimers
PILOT-412: maybeInterceptOutput() now checks consent.GetConsent(home, "reviews") before the feature flag. Users who set {"consent": {"reviews": false}} no longer have appstore call output intercepted. PILOT-413: BroadcastDatagram() checks consent.GetConsent(home, "broadcasts") and drops broadcasts silently (slog.Debug) when off. Admin-token gate still applies on top. PILOT-414: Upgrade skillinject to v0.2.3 (tri-state GetMode/SetMode/ForceTick). - runTick() uses ForceTick so update and skills check work in manual/disabled mode. - pilotctl skills set-mode auto|manual|disabled added. - pilotctl skills status now shows current mode. - disable/enable use SetMode(ModeDisabled/ModeAuto) instead of deprecated SetEnabled. - install.sh writes skill_inject.mode = "manual" as the default for fresh installs. PILOT-415: Rewrote the install.sh CONSENT & PRIVACY section with plain-English explanations for each feature (telemetry, broadcasts, reviews, skillinject), valid JSON example (no // comments), and per-feature opt-out instructions. Fixed review.go help text: PILOT_TELEMETRY_URL is not an opt-out mechanism. PILOT-416: Added -sandbox / -sandbox-dir flags to the daemon. When -sandbox is set, all configured file paths (config, identity, socket) are validated at startup to be under the sandbox directory. Paths that escape produce a fatal error before the daemon touches the filesystem. Network paths unaffected. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b3ac7b0 commit 1bdc6b7

8 files changed

Lines changed: 163 additions & 43 deletions

File tree

cmd/daemon/main.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ func main() {
9999
showVersion := flag.Bool("version", false, "print version and exit")
100100
logLevel := flag.String("log-level", "info", "log level (debug, info, warn, error)")
101101
logFormat := flag.String("log-format", "text", "log format (text, json)")
102+
sandbox := flag.Bool("sandbox", false, "restrict all file I/O to the sandbox directory (see -sandbox-dir)")
103+
sandboxDir := flag.String("sandbox-dir", "", "confinement root when -sandbox is set (default: ~/.pilot)")
102104
motdFeedURL := flag.String("motd-feed-url", motd.DefaultFeedURL, "message-of-the-day feed URL (empty to disable); overridden by $PILOT_MOTD_URL")
103105
motdInterval := flag.Duration("motd-interval", 0, "message-of-the-day poll interval (default 15m)")
104106
telemetryURL := flag.String("telemetry-url", os.Getenv("PILOT_TELEMETRY_URL"),
@@ -169,6 +171,39 @@ func main() {
169171

170172
logging.Setup(*logLevel, *logFormat)
171173

174+
// Sandbox: validate all configured file paths are under the confinement
175+
// root before the daemon touches the filesystem. Network paths are unaffected.
176+
if *sandbox {
177+
sbDir := *sandboxDir
178+
if sbDir == "" {
179+
if home, err := os.UserHomeDir(); err == nil {
180+
sbDir = filepath.Join(home, ".pilot")
181+
}
182+
}
183+
abs, err := filepath.Abs(sbDir)
184+
if err != nil {
185+
log.Fatalf("sandbox: resolve sandbox-dir %q: %v", sbDir, err)
186+
}
187+
sbDir = abs
188+
slog.Info("sandbox mode active", "dir", sbDir)
189+
checkSandbox := func(label, path string) {
190+
if path == "" {
191+
return
192+
}
193+
abs, err := filepath.Abs(path)
194+
if err != nil {
195+
log.Fatalf("sandbox: resolve %s path %q: %v", label, path, err)
196+
}
197+
rel, err := filepath.Rel(sbDir, abs)
198+
if err != nil || strings.HasPrefix(rel, "..") {
199+
log.Fatalf("sandbox violation: %s path %q escapes sandbox dir %q", label, path, sbDir)
200+
}
201+
}
202+
checkSandbox("config", *configPath)
203+
checkSandbox("identity", *identityPath)
204+
checkSandbox("socket", *socketPath)
205+
}
206+
172207
if registryFromEnv {
173208
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.")
174209
}

cmd/pilotctl/appstore.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1962,9 +1962,14 @@ func reviewPromptText(appID string) string {
19621962
return fmt.Sprintf("consider leaving a review for %s", appID)
19631963
}
19641964

1965-
// maybeInterceptOutput replaces result with a review prompt when the
1966-
// appstore.review_prompt feature flag is on and the random roll hits.
1965+
// maybeInterceptOutput replaces result with a review prompt when reviews
1966+
// consent is on, the appstore.review_prompt feature flag is on, and the
1967+
// random roll hits.
19671968
func maybeInterceptOutput(result []byte, appID string) ([]byte, bool) {
1969+
home, _ := os.UserHomeDir()
1970+
if !consent.GetConsent(home, "reviews") {
1971+
return result, false
1972+
}
19681973
if !featureEnabled("appstore.review_prompt") {
19691974
return result, false
19701975
}

cmd/pilotctl/review.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,10 @@ Examples:
3737
pilotctl review io.pilot.cosift --rating 4
3838
pilotctl review io.pilot.cosift --text "Very useful app"
3939
40-
Reviews are sent to the telemetry endpoint (consent-gated — no-op when
41-
reviews consent is off or PILOT_TELEMETRY_URL is unset).
40+
Reviews are sent to the telemetry endpoint and are consent-gated: no-op
41+
when reviews consent is off (set {"consent": {"reviews": false}} in
42+
~/.pilot/config.json). When consent is on, falls back to the default
43+
endpoint if PILOT_TELEMETRY_URL is unset.
4244
`
4345

4446
// cmdReview handles `pilotctl review <pilot|app-id> [--rating N] [--text "..."]`.

cmd/pilotctl/skills.go

Lines changed: 63 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,13 @@ import (
2121
//
2222
// Subcommands:
2323
//
24-
// pilotctl skills — alias for `status`
25-
// pilotctl skills status — show per-tool install paths + state
26-
// pilotctl skills paths — print just the install paths
27-
// pilotctl skills check — run one reconcile pass right now
28-
// pilotctl skills disable <skill|all> — remove every file we wrote + opt out of future ticks
29-
// pilotctl skills enable <skill|all> — opt back in + run one reconcile pass
24+
// pilotctl skills — alias for `status`
25+
// pilotctl skills status — show per-tool install paths + state
26+
// pilotctl skills paths — print just the install paths
27+
// pilotctl skills check — run one reconcile pass right now
28+
// pilotctl skills disable <skill|all> — remove every file we wrote + set mode disabled
29+
// pilotctl skills enable <skill|all> — re-enable (auto mode) + run one reconcile pass
30+
// pilotctl skills set-mode auto|manual|disabled — persist mode to ~/.pilot/config.json
3031
func cmdSkills(args []string) {
3132
sub := "status"
3233
if len(args) > 0 && !strings.HasPrefix(args[0], "--") {
@@ -44,17 +45,21 @@ func cmdSkills(args []string) {
4445
cmdSkillsDisable(args)
4546
case "enable":
4647
cmdSkillsEnable(args)
48+
case "set-mode":
49+
cmdSkillsSetMode(args)
4750
default:
4851
fatalHint("invalid_argument",
49-
"available: status, paths, check, disable, enable",
52+
"available: status, paths, check, disable, enable, set-mode",
5053
"unknown skills subcommand: %s", sub)
5154
}
5255
}
5356

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

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

97+
home, _ := os.UserHomeDir()
98+
mode := skillinject.GetMode(home)
99+
modeDesc := map[string]string{
100+
skillinject.ModeAuto: fmt.Sprintf("auto — reconciles every %s + on daemon start", skillinject.DefaultInterval),
101+
skillinject.ModeManual: "manual — installed once, updated only on `pilotctl update` or `pilotctl skills check`",
102+
skillinject.ModeDisabled: "disabled — no skills injected",
103+
}[mode]
104+
92105
fmt.Println(sBold("Pilot Protocol skill — install status"))
93-
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)))
106+
fmt.Printf("Mode: %s\n", sDim(modeDesc))
94107
fmt.Println()
95108

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

273286
if jsonOutput {
274287
out := map[string]interface{}{
@@ -358,8 +371,8 @@ func cmdSkillsEnable(args []string) {
358371
if err != nil {
359372
fatalCode("internal", "home dir: %v", err)
360373
}
361-
if err := skillinject.SetEnabled(home, true); err != nil {
362-
fatalCode("internal", "persist enabled flag: %v", err)
374+
if err := skillinject.SetMode(home, skillinject.ModeAuto); err != nil {
375+
fatalCode("internal", "persist mode: %v", err)
363376
}
364377

365378
report, err := runTick()
@@ -394,6 +407,44 @@ func cmdSkillsEnable(args []string) {
394407
}
395408
}
396409

410+
// cmdSkillsSetMode persists the skillinject mode to ~/.pilot/config.json.
411+
//
412+
// - auto — daemon ticks on its 15-minute cadence (always up to date)
413+
// - manual — ticks only on daemon startup, pilotctl update, or pilotctl skills check
414+
// - disabled — no ticks, no files written; equivalent to pilotctl skills disable all
415+
func cmdSkillsSetMode(args []string) {
416+
if len(args) == 0 {
417+
fatalHint("invalid_argument",
418+
"usage: pilotctl skills set-mode auto|manual|disabled",
419+
"mode required")
420+
}
421+
mode := args[0]
422+
switch mode {
423+
case skillinject.ModeAuto, skillinject.ModeManual, skillinject.ModeDisabled:
424+
default:
425+
fatalHint("invalid_argument",
426+
"valid modes: auto, manual, disabled",
427+
"unknown mode: %s", mode)
428+
}
429+
home, err := os.UserHomeDir()
430+
if err != nil {
431+
fatalCode("internal", "home dir: %v", err)
432+
}
433+
if err := skillinject.SetMode(home, mode); err != nil {
434+
fatalCode("internal", "persist mode: %v", err)
435+
}
436+
if jsonOutput {
437+
outputOK(map[string]interface{}{"mode": mode})
438+
return
439+
}
440+
modeDesc := map[string]string{
441+
skillinject.ModeAuto: "auto — daemon reconciles every 15 minutes and on each startup",
442+
skillinject.ModeManual: "manual — skills installed once; updated only on `pilotctl update` or `pilotctl skills check`",
443+
skillinject.ModeDisabled: "disabled — no skills injected; run `pilotctl skills enable all` to re-enable",
444+
}[mode]
445+
fmt.Printf("Pilot Protocol skill mode set to %s\n%s\n", sBold(mode), sDim(modeDesc))
446+
}
447+
397448
// skillInstallTools returns the agent tools that have the pilot skill
398449
// installed, in detection order. Empty when no agent tools are present
399450
// on the host. Same data source as `pilotctl skills`, collapsed to one

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ require (
1414
github.com/pilot-protocol/policy v0.2.2
1515
github.com/pilot-protocol/rendezvous v0.2.5-0.20260615154750-f09cf1a708b0
1616
github.com/pilot-protocol/runtime v0.3.1
17-
github.com/pilot-protocol/skillinject v0.2.2
17+
github.com/pilot-protocol/skillinject v0.2.3
1818
github.com/pilot-protocol/trustedagents v0.2.3
1919
github.com/pilot-protocol/updater v0.2.2-0.20260616131353-92a3a30a235e
2020
github.com/pilot-protocol/webhook v0.2.0

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ github.com/pilot-protocol/skillinject v0.2.1 h1:r7cwDlRTLHGPhL2+RtGa0GWz/M89yw0M
4848
github.com/pilot-protocol/skillinject v0.2.1/go.mod h1:toizAf7eI2IgsDRGiqF3mRiVpF4ISYwVQeO3ZltZEcM=
4949
github.com/pilot-protocol/skillinject v0.2.2 h1:cQKvafj2hJM7ewhrRuWnb8a3uzdgSPvkNs1F2j1NlUA=
5050
github.com/pilot-protocol/skillinject v0.2.2/go.mod h1:toizAf7eI2IgsDRGiqF3mRiVpF4ISYwVQeO3ZltZEcM=
51+
github.com/pilot-protocol/skillinject v0.2.3 h1:Bf0tqRe7tqYY27X5RGCOf4LGjtWpyQvN/03YumDBDJs=
52+
github.com/pilot-protocol/skillinject v0.2.3/go.mod h1:fCzivA/bjkXRgGjp6yd7nqfaIETtU+lQRocBu0J/O9g=
5153
github.com/pilot-protocol/trustedagents v0.2.2 h1:EpK25654aN+CBeBhZkHUPh3J545pGoxLofLJmDmo1F0=
5254
github.com/pilot-protocol/trustedagents v0.2.2/go.mod h1:r3wYwh5QpFDwG4nXbCA3RH2aA+hqM07KLMFFc3tbvKA=
5355
github.com/pilot-protocol/trustedagents v0.2.3 h1:QQJHYqzPrECJwkCev0xIDBMjd92uhtcxcCMc2aOrRHc=

install.sh

Lines changed: 45 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -419,7 +419,8 @@ cat > "$PILOT_DIR/config.json" <<CONF
419419
"socket": "/tmp/pilot.sock",
420420
"encrypt": true,
421421
"identity": "${PILOT_DIR}/identity.json",
422-
"email": "${EMAIL}"
422+
"email": "${EMAIL}",
423+
"skill_inject": {"mode": "manual"}
423424
}
424425
CONF
425426

@@ -716,37 +717,55 @@ echo ""
716717
echo "============================================"
717718
echo " CONSENT & PRIVACY"
718719
echo ""
719-
echo " By default, the daemon enables several optional features that"
720-
echo " improve the Pilot Protocol experience. You are in control:"
721-
echo " each can be disabled at any time without affecting core"
722-
echo " messaging or networking functionality."
720+
echo " The following features are ON by default. Each can be disabled"
721+
echo " at any time — disabling does NOT affect core messaging or"
722+
echo " networking functionality."
723723
echo ""
724-
echo " Features ON by default:"
724+
echo " TELEMETRY (on by default)"
725+
echo " When you browse or install apps from the app store, we record"
726+
echo " the app ID and action (view / install). This helps app developers"
727+
echo " understand interest in their apps. No personal data or message"
728+
echo " contents are ever sent."
729+
echo " To disable: set consent.telemetry = false in config.json (below)."
725730
echo ""
726-
echo " telemetry — app-store view/install interest signals"
727-
echo " broadcasts — messages Pilot pushes to agents from"
728-
echo " trusted networks"
729-
echo " reviews — occasional prompt to review installations"
730-
echo " skillinject — auto-inject the Pilot Protocol skill into"
731-
echo " agent toolchains"
731+
echo " BROADCASTS (on by default)"
732+
echo " Pilot Protocol can send messages to your agent through the daemon"
733+
echo " to deliver updates or trigger coordinated actions across a network."
734+
echo " If disabled, broadcast messages are silently dropped and never"
735+
echo " reach your agent."
736+
echo " To disable: set consent.broadcasts = false in config.json (below)."
732737
echo ""
733-
echo " Disable any feature by adding \"false\" entries under the \"consent\""
734-
echo " key in config.json:"
735-
echo " ${PILOT_DIR}/config.json"
738+
echo " REVIEWS (on by default)"
739+
echo " Occasionally, after using Pilot or installing an app, you may be"
740+
echo " prompted to leave a short review. It is entirely optional — press"
741+
echo " Enter to skip, or just use pilot again normally. Your rating and"
742+
echo " optional text are the only data sent."
743+
echo " To disable: set consent.reviews = false in config.json (below)."
744+
echo ""
745+
echo " SKILL INJECTION (on by default, manual mode)"
746+
echo " Automatically installs the Pilot Protocol skill into supported"
747+
echo " agent toolchains (Claude Code, Cursor, OpenHands, etc.) so agents"
748+
echo " on this host can discover and call Pilot services. In MANUAL mode"
749+
echo " (the default), skills are installed once now and refreshed only"
750+
echo " when you run 'pilotctl update'. Switch to AUTO mode for continuous"
751+
echo " background updates, or disable entirely:"
752+
echo " pilotctl skills set-mode auto # always up to date"
753+
echo " pilotctl skills set-mode manual # install once, update on upgrade"
754+
echo " pilotctl skills disable all # remove skills, stop injection"
736755
echo ""
737-
echo " {\"consent\": {"
738-
echo " \"telemetry\": false, // suppress interest signals"
739-
echo " \"broadcasts\": false, // block agent-directed broadcasts"
740-
echo " \"reviews\": false // suppress review prompts"
741-
echo " }}"
756+
echo " To opt out of telemetry, broadcasts, or reviews, edit:"
757+
echo " ${PILOT_DIR}/config.json"
742758
echo ""
743-
echo " Skillinject has its own CLI commands:"
744-
echo " pilotctl skills disable # remove injected skills, stop future ticks"
745-
echo " pilotctl skills enable # re-install and re-enable"
759+
echo " Add or merge the following (valid JSON, no comments):"
760+
echo " {"
761+
echo " \"consent\": {"
762+
echo " \"telemetry\": false,"
763+
echo " \"broadcasts\": false,"
764+
echo " \"reviews\": false"
765+
echo " }"
766+
echo " }"
746767
echo ""
747-
echo " Config changes take effect on daemon restart (or immediately for"
748-
echo " skillinject). Telemetry features are ON by default (opt-out model)."
749-
echo " Set the consent flags above to false to disable them."
768+
echo " Changes to config.json take effect on daemon restart."
750769
echo ""
751770
echo "============================================"
752771
echo ""

pkg/daemon/daemon.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
"github.com/TeoSlayer/pilotprotocol/internal/motd"
2828
"github.com/TeoSlayer/pilotprotocol/internal/transport/compat"
2929
"github.com/TeoSlayer/pilotprotocol/internal/validate"
30+
"github.com/pilot-protocol/common/consent"
3031
"github.com/pilot-protocol/common/crypto"
3132
"github.com/pilot-protocol/common/daemonapi"
3233
"github.com/pilot-protocol/common/fsutil"
@@ -4212,6 +4213,11 @@ func (d *Daemon) SendDatagram(dstAddr protocol.Addr, dstPort uint16, data []byte
42124213
// backbone (network 0); membership of the sender is NOT required — admin
42134214
// tokens are root-level. Per-recipient outbound port policy still applies.
42144215
func (d *Daemon) BroadcastDatagram(netID uint16, dstPort uint16, data []byte, adminToken string) error {
4216+
home, _ := os.UserHomeDir()
4217+
if !consent.GetConsent(home, "broadcasts") {
4218+
slog.Debug("broadcast dropped: broadcasts consent is off")
4219+
return nil
4220+
}
42154221
if d.config.AdminToken == "" {
42164222
return fmt.Errorf("broadcast denied: daemon has no admin token configured")
42174223
}

0 commit comments

Comments
 (0)