mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 18:08:31 -10:00
* Prompt for shell network permissions Route network-risk shell calls through the existing permission prompt instead of hard-denying them at sandbox preflight. Approved network prompts apply the current turn/session request-permissions overlay so the effective sandbox policy is widened only after user approval. Move network command detection into the shell AST analyzer for package installers, gh/git network subcommands, and python http.server so quoted process patterns such as pkill -f "python3 -m http.server 8000" are not misclassified as network activity. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestAnalyzeCommand|TestClassifyDoesNotFlagQuotedHttpServerPattern|TestEvaluateExemptsNetworkToolsFromDenyByDefault|TestEngineClassifiesNetworkAndDestructiveShellCommands|TestGrantRequestPermissionsNetworkOverlaysPolicyForTurn' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent -run 'TestRunPersistentCommandPrefixStillPromptsForNetwork|TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tools -run 'TestBash|TestRegistryRunsBashThroughSandboxEngine' Tested: GOCACHE=/tmp/zero-go-cache go test ./... * Auto-allow native-sandboxed shell commands Remove the old hidden bash auto-allow environment/config path. Ordinary shell commands now use the active native sandbox as the permission boundary by default, while network, destructive, and workspace policy checks still run first. Policy-only and disabled sandbox backends continue to require approval for shell commands, so unsandboxed execution is not silently allowed. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestSandboxedBash|TestShellSandboxActive|TestEvaluateExemptsNetworkToolsFromDenyByDefault|TestGrantRequestPermissionsNetworkOverlaysPolicyForTurn|TestAnalyzeCommand|TestClassifyDoesNotFlagQuotedHttpServerPattern' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tools -run 'TestBashAuto|TestBashStillPromptsWithoutActiveSandbox|TestRegistryRunsBashThroughSandboxEngine' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli -run 'TestApplyConfiguredSandboxPolicy|TestApplyConfiguredAutonomyCeiling' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent -run 'TestRunPersistentCommandPrefixStillPromptsForNetwork|TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/daemon -run 'TestScrubWorkerEnvRemovesReentrancyMarkers' Tested: GOCACHE=/tmp/zero-go-cache go test ./... * Prompt for destructive shell approval Remove the separate DenyDestructiveShell policy layer from active sandbox decisions. Destructive shell classification now produces an approval prompt in ask/auto flows instead of a sandbox hard-deny, while no-approval paths remain blocked. Drop the removed destructive-shell policy field from sandbox policy output, backend capabilities, snapshots, and Windows golden data. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent -run 'TestRunPromptsForDestructiveShellInsteadOfSandboxDeny|TestRunPersistentCommandPrefixStillPromptsForNetwork|TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestEngineClassifiesNetworkAndDestructiveShellCommands|TestBackendCapabilitiesReflectDisabledPolicy' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli -run 'TestRunSandboxPolicy|TestEffectiveSandboxPolicy' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/zerocommands -run 'TestSandboxPolicySnapshotFromPolicyFillsEffectiveMode' Tested: GOCACHE=/tmp/zero-go-cache go test ./... * Remove web-tool network policy switch Remove the old EnforceToolNetwork policy option and the NetworkHostAllowed per-tool gate so sandbox network policy only controls sandboxed shell egress. Keep web_search and web_fetch on their own tool permission and URL safety paths, with tests proving deny/scoped shell network policy no longer blocks in-process web tools. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestEvaluateExemptsNetworkToolsFromShellNetworkPolicy|TestEngineAllowsNetworkSideEffectWhenShellPolicyBlocksNetwork|TestGrantRequestPermissionsNetworkOverlaysPolicyForTurn|TestScopedNetworkGateInEvaluate' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tools -run 'TestWebSearchRunWithSandbox|TestWebFetchRunWithSandbox|TestRegistryRoutesWebFetchThroughSandboxPath' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent -run 'TestRunPersistentCommandPrefixStillPromptsForNetwork|TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant' Tested: GOCACHE=/tmp/zero-go-cache go test ./... Tested: git diff --check * Prompt for outside-workspace file access Convert recoverable outside-workspace file path violations into permission prompts instead of hard-denying in ask/auto modes. Approved prompts now grant the requested filesystem scope through the existing request_permissions overlay for the current turn or session, so the follow-up sandbox evaluation and scoped file tools use the same policy path. Keep unsafe mode, apply_patch escapes, symlink traversal, and non-recoverable path cases hard-denied. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestEngineAutoAllowsWorkspaceFileMutationTools|TestEngineDeniesOutOfWorkspacePaths|TestGrantRequestPermissionsReadDoesNotGrantWrite|TestGrantRequestPermissionsSessionPersists' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent -run 'TestRunPromptsAndAllowsOutsideWorkspaceWrite|TestRunSessionAllowsLaterOutsideWorkspaceWrite|TestRunAppliesSandboxEvenInUnsafeMode' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tools -run 'TestRegistryAppliesSandboxBeforeToolExecution|TestRegistrySandboxGatesPathAliasKeys|TestRegistryAllowsPromptToolWithPersistentSandboxGrant' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli -run 'TestRunSandboxCheckJSONDeniesOutOfWorkspaceWrite' Tested: GOCACHE=/tmp/zero-go-cache go test ./... Tested: git diff --check * Fix Windows network prompt test fixture Use a Windows curl.cmd shim and cmd.exe PATH syntax for TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant while keeping the POSIX fake curl fixture for Linux and macOS. This keeps the test focused on network prompt approval and turn network grants instead of relying on a POSIX shell script being executable on Windows. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent -run 'TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant|TestRunPersistentCommandPrefixStillPromptsForNetwork' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestClassifyASTCatchesNetworkProgramsRegexMisses|TestClassifyFlagsPipedInstallerVariants' Tested: GOCACHE=/tmp/zero-go-cache go test ./... Tested: git diff --check * Remove sandbox autonomy ceilings Sandbox permission decisions now match grants by tool and scope only, without MaxAutonomy or request autonomy checks. Persistent/session grants, permission approvals, workspace-write auto-allow, and native-sandbox shell auto-allow no longer route through an autonomy ceiling. Remove the sandbox.maxAutonomy config and CLI surfaces, including grant --auto, policy output, sandbox check autonomy input, and sandbox grant snapshots. MCP permission autonomy is intentionally unchanged because it is a separate MCP permission model. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/agent ./internal/tools ./internal/zerocommands ./internal/config ./internal/cli ./internal/tui Tested: GOCACHE=/tmp/zero-go-cache go test ./... * Fail closed without native sandbox Remove AllowPolicyOnlyRunner from sandbox policy and snapshots. A restricted/managed command plan now errors when native sandbox support is unavailable instead of running through a policy-only direct runner. Policy-only backend data remains as diagnostics for capability/reporting paths, but it no longer authorizes command execution when a platform sandbox is required. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/zerocommands ./internal/cli ./internal/tui Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent ./internal/tools Tested: GOCACHE=/tmp/zero-go-cache go test ./... * Remove scoped shell network policy Drop the domain-scoped shell network mode and the local scoped-egress proxy path from the active sandbox model. Shell network policy now serializes and evaluates only deny or allow. Request-permissions network grants widen to allow without domain lists, and platform profiles consume the binary mode directly. The Linux helper no longer accepts proxy-route flags, Windows network hashes only the binary mode, and web tool tests assert they remain separate from shell network policy. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/tools ./internal/notify Tested: GOCACHE=/tmp/zero-go-cache go test ./... * Align permission grant ordering and prompt text Move persistent and session grant matching ahead of recoverable sandbox prompts while keeping explicit denies and non-recoverable path violations fail-closed. Clean TUI permission transcript/card text so normal prompts show action, reason, scope, and readable block details instead of raw permission/risk/violation key-value metadata. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/tui ./internal/agent ./internal/tools Tested: GOCACHE=/tmp/zero-go-cache go test ./... * Remove policy-only sandbox terminology Replace the unavailable native-backend diagnostic path with explicit unavailable support/backend states, and stop presenting missing native isolation as a policy-only fallback. Rename structured sandbox denial metadata from violation to block across permission events, stream-json, snapshots, TUI rendering, and sandbox-check output so user-facing flow matches blocked action language. Update README, work split docs, smoke test selectors, and Windows sandbox policy golden output for the new backend/support names and block payloads. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/agent ./internal/tools ./internal/tui ./internal/cli ./internal/doctor ./internal/zerocommands ./internal/streamjson Tested: GOCACHE=/tmp/zero-go-cache go test ./... Tested: scripts/sandbox-smoke.sh Tested: git diff --check * Fix sandbox review follow-ups Fail closed for unknown network modes, keep network prompts ahead of bash allow grants, preserve v1 grant-file reads, and classify unparseable commands with obvious network programs as network-risk. Bump stream-json schema to v2 and document the block metadata field after the violation-to-block rename. Tests: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/streamjson ./internal/specialist ./internal/cli -run 'TestExec.*Stream|TestStreamJSON|TestExecProtocol|TestExecReadsStreamJSONImages|TestExecAcceptsImageOnlyStreamJSONMessage|TestExecInputFormatStreamJSON' Tests: GOCACHE=/tmp/zero-go-cache go test ./... Tests: scripts/sandbox-smoke.sh Tests: git diff --check
217 lines
7.5 KiB
Go
217 lines
7.5 KiB
Go
package notify
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Gitlawb/zero/internal/redaction"
|
|
)
|
|
|
|
// defaultWebhookTimeout bounds a single delivery attempt. A notification must
|
|
// never stall a finished run, so an unresponsive endpoint is abandoned quickly.
|
|
const defaultWebhookTimeout = 10 * time.Second
|
|
|
|
// maxWebhookBodyBytes caps how much of an error response we read back for the
|
|
// log line, so a hostile or chatty endpoint cannot flood the audit trail.
|
|
const maxWebhookBodyBytes = 2 << 10 // 2 KiB
|
|
|
|
// WebhookLink is an optional labeled URL attached to a notification (for
|
|
// example a link to the CI run, the opened PR, or the session log).
|
|
type WebhookLink struct {
|
|
Label string `json:"label,omitempty"`
|
|
URL string `json:"url"`
|
|
}
|
|
|
|
// webhookPayload is the JSON body POSTed to the configured webhook. It is
|
|
// shaped to be useful to a generic consumer while remaining renderable by a
|
|
// Slack incoming webhook: Slack reads the top-level "text" field, and the
|
|
// structured fields (type/message/summary/links) carry the machine-readable
|
|
// detail.
|
|
type webhookPayload struct {
|
|
// Text is the human-readable line Slack renders in the channel.
|
|
Text string `json:"text"`
|
|
// Type is the machine-readable event kind ("completion", "awaiting_input").
|
|
Type string `json:"type"`
|
|
// Message is the notification body (already redacted).
|
|
Message string `json:"message"`
|
|
// Summary is an optional one-line run summary (already redacted).
|
|
Summary string `json:"summary,omitempty"`
|
|
// Links are optional labeled URLs (run page, PR, logs).
|
|
Links []WebhookLink `json:"links,omitempty"`
|
|
}
|
|
|
|
// WebhookConfig configures a WebhookSink. URL is a Slack incoming-webhook URL or
|
|
// any generic endpoint that accepts a JSON POST. The zero value (empty URL)
|
|
// yields an inert sink whose Emit is a no-op, so callers can wire a sink
|
|
// unconditionally and let configuration decide whether it fires.
|
|
type WebhookConfig struct {
|
|
// URL is the destination. Empty disables the sink.
|
|
URL string
|
|
// Summary is an optional run summary attached to every emitted payload.
|
|
Summary string
|
|
// Links are optional labeled URLs attached to every emitted payload.
|
|
Links []WebhookLink
|
|
// Client is the HTTP client used for delivery. When nil a client with a
|
|
// conservative timeout is used; its default transport honors HTTP(S)_PROXY
|
|
// when a proxy is configured.
|
|
Client *http.Client
|
|
// Logf records a single line per failed delivery. Lines are passed through
|
|
// the repo redaction before being written, so a token in the URL or message
|
|
// is never logged in the clear. When nil, failures are silent.
|
|
Logf func(format string, args ...any)
|
|
// ExtraSecrets are literal values (for example the resolved API key) that must
|
|
// be masked in the payload and logs in addition to the built-in patterns.
|
|
ExtraSecrets []string
|
|
}
|
|
|
|
// WebhookSink delivers notifications to a webhook/Slack endpoint. It implements
|
|
// Sink. Delivery is best-effort and fails soft: a non-2xx response or a
|
|
// transport error is logged (redacted) and swallowed so it can never disrupt
|
|
// the run.
|
|
type WebhookSink struct {
|
|
url string
|
|
summary string
|
|
links []WebhookLink
|
|
client *http.Client
|
|
logf func(format string, args ...any)
|
|
secrets []string
|
|
}
|
|
|
|
// NewWebhookSink builds a WebhookSink from cfg. A blank URL produces an inert
|
|
// sink (Emit is a no-op).
|
|
func NewWebhookSink(cfg WebhookConfig) *WebhookSink {
|
|
client := cfg.Client
|
|
if client == nil {
|
|
client = &http.Client{Timeout: defaultWebhookTimeout}
|
|
}
|
|
return &WebhookSink{
|
|
url: strings.TrimSpace(cfg.URL),
|
|
summary: cfg.Summary,
|
|
// Copy the caller-owned slices so later mutation of cfg can neither
|
|
// change a future payload nor race with a concurrent Emit.
|
|
links: append([]WebhookLink(nil), cfg.Links...),
|
|
client: client,
|
|
logf: cfg.Logf,
|
|
secrets: append([]string(nil), cfg.ExtraSecrets...),
|
|
}
|
|
}
|
|
|
|
// Emit builds the JSON payload for event/message and POSTs it to the webhook.
|
|
// It is fire-and-forget: every failure path logs (redacted) and returns nil-ish
|
|
// rather than propagating, satisfying the Sink contract that a notification
|
|
// must never crash the run.
|
|
func (s *WebhookSink) Emit(event Event, message string) {
|
|
if s == nil || s.url == "" {
|
|
return
|
|
}
|
|
|
|
// The webhook URL itself can carry a secret token, so treat it as a secret
|
|
// when redacting outbound fields (matching log()), not just s.secrets.
|
|
options := redaction.Options{ExtraSecretValues: append([]string{s.url}, s.secrets...)}
|
|
safeMessage := redaction.RedactString(message, options)
|
|
safeSummary := redaction.RedactString(s.summary, options)
|
|
links := s.redactLinks(options)
|
|
|
|
payload := webhookPayload{
|
|
Text: s.text(event, safeMessage),
|
|
Type: eventType(event),
|
|
Message: safeMessage,
|
|
Summary: safeSummary,
|
|
Links: links,
|
|
}
|
|
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
// A payload built from strings should always marshal; guard anyway.
|
|
s.log("notify: webhook payload encode failed: %v", err)
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), defaultWebhookTimeout)
|
|
defer cancel()
|
|
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodPost, s.url, bytes.NewReader(body))
|
|
if err != nil {
|
|
s.log("notify: webhook request build failed: %v", err)
|
|
return
|
|
}
|
|
request.Header.Set("Content-Type", "application/json")
|
|
|
|
response, err := s.client.Do(request)
|
|
if err != nil {
|
|
s.log("notify: webhook delivery failed: %v", err)
|
|
return
|
|
}
|
|
defer func() { _ = response.Body.Close() }()
|
|
|
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
|
snippet := readSnippet(response.Body)
|
|
s.log("notify: webhook returned %d %s", response.StatusCode, snippet)
|
|
return
|
|
}
|
|
// Drain the success body so the connection can be reused.
|
|
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maxWebhookBodyBytes))
|
|
}
|
|
|
|
// text renders the channel-facing line for an event.
|
|
func (s *WebhookSink) text(event Event, safeMessage string) string {
|
|
if strings.TrimSpace(safeMessage) != "" {
|
|
return safeMessage
|
|
}
|
|
return DefaultMessage(event)
|
|
}
|
|
|
|
// redactLinks returns a copy of the configured links with their URLs and labels
|
|
// passed through redaction (a URL may carry a token in its query/userinfo).
|
|
func (s *WebhookSink) redactLinks(options redaction.Options) []WebhookLink {
|
|
if len(s.links) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]WebhookLink, 0, len(s.links))
|
|
for _, link := range s.links {
|
|
out = append(out, WebhookLink{
|
|
Label: redaction.RedactString(link.Label, options),
|
|
URL: redaction.RedactString(link.URL, options),
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// log writes one redacted line. The fully-formatted message is redacted as a
|
|
// whole so a token appearing in any argument (URL, response body, error) is
|
|
// masked before it reaches the logger.
|
|
func (s *WebhookSink) log(format string, args ...any) {
|
|
if s.logf == nil {
|
|
return
|
|
}
|
|
line := fmt.Sprintf(format, args...)
|
|
options := redaction.Options{ExtraSecretValues: append([]string{s.url}, s.secrets...)}
|
|
s.logf("%s", redaction.RedactString(line, options))
|
|
}
|
|
|
|
// eventType maps an Event to its stable machine-readable string.
|
|
func eventType(event Event) string {
|
|
switch event {
|
|
case AwaitingInput:
|
|
return "awaiting_input"
|
|
default:
|
|
return "completion"
|
|
}
|
|
}
|
|
|
|
// readSnippet reads a bounded, single-line snippet of an error response body for
|
|
// logging. The caller redacts the result.
|
|
func readSnippet(body io.Reader) string {
|
|
data, _ := io.ReadAll(io.LimitReader(body, maxWebhookBodyBytes))
|
|
snippet := strings.TrimSpace(string(data))
|
|
snippet = strings.ReplaceAll(snippet, "\n", " ")
|
|
snippet = strings.ReplaceAll(snippet, "\r", " ")
|
|
return snippet
|
|
}
|