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
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,9 @@ jobs:
id: tag
if: steps.check.outputs.should_tag == 'true'
env:
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
# WO-520@v2: the built-in token suppresses recursive tag-push workflows;
# the following explicit dispatch is the sole automated release trigger.
GH_TOKEN: ${{ github.token }}
run: |
TAG="v${{ steps.check.outputs.version }}"
if git ls-remote --tags origin | grep -q "refs/tags/$TAG$"; then
Expand All @@ -88,9 +90,7 @@ jobs:
exit 0
fi
git tag "$TAG"
if [ -n "$GH_TOKEN" ]; then
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git"
fi
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git"
git push origin "$TAG"
echo "pushed=true" >> "$GITHUB_OUTPUT"

Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ on:
permissions:
contents: write

# WO-520@v2: accidental duplicate attempts for one tag must not race asset and formula writes.
concurrency:
group: release-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
cancel-in-progress: false

jobs:
resolve:
name: Resolve Tag
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.33.2] - 2026-07-23

### Fixed

- Edit and Write hooks now evaluate the proposed mutation instead of blocking unrelated changes solely because an existing file contains an actionable fixture or credential.
- Automated version tagging now starts one serialized release path per tag, preventing concurrent asset and Homebrew checksum publication.
- Mixed `null` and text blocks inside Anthropic tool results retain secret redaction coverage.

## [0.33.1] - 2026-07-20

### Added
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Pastewatch
[![Stable](https://img.shields.io/badge/status-stable-brightgreen)](https://github.com/ppiankov/pastewatch/releases)
[![Version](https://img.shields.io/badge/version-0.33.1-blue)](https://github.com/ppiankov/pastewatch/releases/tag/v0.33.1)
[![Version](https://img.shields.io/badge/version-0.33.2-blue)](https://github.com/ppiankov/pastewatch/releases/tag/v0.33.2)
[![License: MIT](https://img.shields.io/badge/license-MIT-yellow)](LICENSE)
[![CI](https://github.com/ppiankov/pastewatch/actions/workflows/ci.yml/badge.svg)](https://github.com/ppiankov/pastewatch/actions/workflows/ci.yml)
[![ANCC](https://img.shields.io/badge/ANCC-compliant-brightgreen)](https://ancc.dev)
Expand Down Expand Up @@ -790,7 +790,7 @@ Works with any comment style (`#`, `//`, `/* */`).
# .pre-commit-config.yaml
repos:
- repo: https://github.com/ppiankov/pastewatch
rev: v0.33.1
rev: v0.33.2
hooks:
- id: pastewatch
```
Expand Down Expand Up @@ -1005,7 +1005,7 @@ Do not pretend it guarantees compliance or safety.

## Project Status

**Status: Stable, feature-complete** · **v0.33.1** · Accepting compatibility and bug fixes only
**Status: Stable, feature-complete** · **v0.33.2** · Accepting compatibility and bug fixes only

| Milestone | Status |
|-----------|--------|
Expand Down
199 changes: 199 additions & 0 deletions Sources/PastewatchCLI/GuardMutationCommand.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import ArgumentParser
import Foundation
import PastewatchCore

// WO-526@v3: normalized structured input keeps hook-specific JSON parsing out of policy code.
struct GuardMutationInput {
// WO-526@v3: only structured mutation operations reach the evaluator.
enum Operation {
case edit(oldString: String, newString: String, replaceAll: Bool)
case write(content: String)
}

let filePath: String
let operation: Operation

// WO-526@v3: malformed or foreign hook payloads fail closed before file access.
static func parse(_ data: Data) throws -> GuardMutationInput {
guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let tool = root["tool_name"] as? String,
let input = root["tool_input"] as? [String: Any],
let filePath = (input["file_path"] ?? input["filePath"]) as? String,
!filePath.isEmpty else {
throw GuardMutationInputError.invalidPayload
}

switch tool {
case "Edit":
guard let oldString = input["old_string"] as? String,
let newString = input["new_string"] as? String else {
throw GuardMutationInputError.invalidPayload
}
return GuardMutationInput(
filePath: filePath,
operation: .edit(
oldString: oldString,
newString: newString,
replaceAll: input["replace_all"] as? Bool ?? false
)
)
case "Write":
guard let content = input["content"] as? String else {
throw GuardMutationInputError.invalidPayload
}
return GuardMutationInput(filePath: filePath, operation: .write(content: content))
default:
throw GuardMutationInputError.invalidPayload
}
}
}

// WO-526@v3: parsing exposes no payload details in diagnostics.
private enum GuardMutationInputError: Error {
case invalidPayload
}

// WO-526@v3: invalid active configuration is a distinct fail-closed boundary.
private enum GuardMutationConfigurationError: Error {
case invalidConfiguration
}

// WO-526@v3: change-aware Edit/Write guard; guard-write remains the explicit legacy command.
struct GuardMutation: ParsableCommand {
// WO-526@v3: keep the command explicit rather than changing guard-write semantics.
static let configuration = CommandConfiguration(
commandName: "guard-mutation",
abstract: "Check a structured Edit or Write without blocking unrelated findings"
)

@Option(name: .long, help: "Minimum severity to block: critical, high, medium, low")
var failOnSeverity: Severity = .high

// WO-526@v3: stdin content is evaluated without copying secrets into argv.
func run() throws {
if ProcessInfo.processInfo.environment["PW_GUARD"] == "0" { return }

let input: GuardMutationInput
do {
input = try GuardMutationInput.parse(FileHandle.standardInput.readDataToEndOfFile())
} catch {
try deny("invalid structured mutation input")
return
}

let config: PastewatchConfig
do {
config = try validatedConfig()
} catch {
try deny("configuration is invalid")
return
}
guard !config.isPathProtected(input.filePath) else {
try deny("target is inside a protected directory")
return
}

let currentContent: String
if FileManager.default.fileExists(atPath: input.filePath) {
do {
currentContent = try String(contentsOfFile: input.filePath, encoding: .utf8)
} catch {
try deny("target cannot be scanned safely")
return
}
} else {
currentContent = ""
}

switch input.operation {
case .edit where currentContent.isEmpty:
try deny("edit target is unavailable")
return
case .edit(_, let newString, _) where containsPlaceholder(newString, config: config):
try deny("proposed content contains unresolved placeholders")
return
case .write(let content) where containsPlaceholder(content, config: config):
try deny("proposed content contains unresolved placeholders")
return
default:
break
}

let decision: GuardMutationDecision
do {
switch input.operation {
case let .edit(oldString, newString, replaceAll):
decision = try GuardMutationEvaluator.evaluateEdit(
currentContent: currentContent,
oldString: oldString,
newString: newString,
replaceAll: replaceAll,
filePath: input.filePath,
config: config,
minimumSeverity: failOnSeverity
)
case let .write(content):
decision = try GuardMutationEvaluator.evaluateWrite(
currentContent: currentContent,
proposedContent: content,
filePath: input.filePath,
config: config,
minimumSeverity: failOnSeverity
)
}
} catch {
try deny("mutation scan failed")
return
}

guard case .block(let reason) = decision else { return }
try deny(reason == .touchesExistingFinding
? "proposed edit overlaps protected content"
: "proposed mutation changes protected content")
}

// WO-526@v3: resolve only after the highest-priority active config validates.
private func validatedConfig() throws -> PastewatchConfig {
let fileManager = FileManager.default
let projectPath = fileManager.currentDirectoryPath + "/.pastewatch.json"
let activePath: String?
if fileManager.fileExists(atPath: PastewatchConfig.systemConfigPath) {
activePath = PastewatchConfig.systemConfigPath
} else if fileManager.fileExists(atPath: projectPath) {
activePath = projectPath
} else if fileManager.fileExists(atPath: PastewatchConfig.configPath.path) {
activePath = PastewatchConfig.configPath.path
} else {
activePath = nil
}
guard ConfigValidator.validate(path: activePath).isValid else {
throw GuardMutationConfigurationError.invalidConfiguration
}
return PastewatchConfig.resolve()
}

// WO-526@v3: unresolved MCP placeholders still require the restorative write path.
private func containsPlaceholder(_ content: String, config: PastewatchConfig) -> Bool {
let fullRange = NSRange(content.startIndex..<content.endIndex, in: content)
guard let structuredRegex = try? NSRegularExpression(pattern: Obfuscator.mcpPlaceholderPattern) else {
return true
}
if structuredRegex.firstMatch(in: content, range: fullRange) != nil {
return true
}
guard let prefix = config.placeholderPrefix else { return false }
guard let customRegex = try? NSRegularExpression(
pattern: Obfuscator.customPlaceholderPattern(prefix: prefix)
) else {
return true
}
return customRegex.firstMatch(in: content, range: fullRange) != nil
}

// WO-526@v3: denial messages disclose policy class, never matched values.
private func deny(_ reason: String) throws {
FileHandle.standardError.write(Data("BLOCKED: \(reason)\n".utf8))
print("Use pastewatch_read_file and pastewatch_write_file for protected mutations.")
throw ExitCode(rawValue: 2)
}
}
3 changes: 2 additions & 1 deletion Sources/PastewatchCLI/PastewatchCLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ import PastewatchCore

@main
struct PastewatchCLI: ParsableCommand {
// WO-526@v3: expose the structured mutation guard without changing legacy guards.
static let configuration = CommandConfiguration(
commandName: "pastewatch-cli",
abstract: "Scan text for sensitive data patterns",
version: AppVersion.current,
subcommands: [Scan.self, Fix.self, Version.self, Init.self, BaselineGroup.self, HookGroup.self, MCP.self, Explain.self, ConfigGroup.self, Guard.self, GuardRead.self, GuardWrite.self, Inventory.self, Doctor.self, Setup.self, Report.self, CanaryGroup.self, VaultGroup.self, Posture.self, Watch.self, DashboardCommand.self, Proxy.self, Launch.self],
subcommands: [Scan.self, Fix.self, Version.self, Init.self, BaselineGroup.self, HookGroup.self, MCP.self, Explain.self, ConfigGroup.self, Guard.self, GuardRead.self, GuardWrite.self, GuardMutation.self, Inventory.self, Doctor.self, Setup.self, Report.self, CanaryGroup.self, VaultGroup.self, Posture.self, Watch.self, DashboardCommand.self, Proxy.self, Launch.self],
defaultSubcommand: Scan.self
)
}
48 changes: 29 additions & 19 deletions Sources/PastewatchCore/AgentSetup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,7 @@ public enum AgentSetup {

// MARK: - Embedded Templates

// WO-526@v3: generated Claude hooks default structured mutations to change-aware policy.
/// Generate Claude Code guard script with configured severity.
public static func claudeCodeGuardScript(severity: String) -> String {
return """
Expand Down Expand Up @@ -441,32 +442,30 @@ public enum AgentSetup {
fi
fi

# --- READ/WRITE/EDIT: Scan the file on disk for secrets ---
# Only scan existing files (new files won't have secrets on disk)
[ ! -f "$file_path" ] && exit 0

# Fail-open if pastewatch-cli not installed
command -v pastewatch-cli &>/dev/null || exit 0

# WO-526@v3: Edit/Write decisions compare the proposed content with findings on disk.
if [ "$tool" = "Edit" ] || [ "$tool" = "Write" ]; then
printf '%s' "$input" | pastewatch-cli guard-mutation --fail-on-severity "$PW_SEVERITY" >/dev/null
if [ $? -ne 0 ]; then
echo "BLOCKED: proposed mutation changes protected content. Use pastewatch_read_file and pastewatch_write_file."
echo "Blocked: protected content in mutation" >&2
exit 2
fi
exit 0
fi

# Read remains a whole-file decision. Only scan existing files.
[ ! -f "$file_path" ] && exit 0

# Scan file at configured severity threshold
pastewatch-cli scan --check --fail-on-severity "$PW_SEVERITY" --file "$file_path" >/dev/null 2>&1
scan_exit=$?

if [ "$scan_exit" -eq 6 ]; then
case "$tool" in
Read)
echo "BLOCKED: $file_path contains secrets. You MUST use pastewatch_read_file instead. Do NOT use python3, cat, or any workaround."
echo "Blocked: secrets in Read target — use pastewatch_read_file" >&2
;;
Write)
echo "BLOCKED: $file_path contains secrets on disk. You MUST use pastewatch_write_file instead. Do NOT delete the file or use python3 as a workaround."
echo "Blocked: secrets in Write target — use pastewatch_write_file" >&2
;;
Edit)
echo "BLOCKED: $file_path contains secrets. You MUST use pastewatch_read_file to read, then pastewatch_write_file to write back. Do NOT use any workaround."
echo "Blocked: secrets in Edit target — use pastewatch_read_file + pastewatch_write_file" >&2
;;
esac
echo "BLOCKED: $file_path contains secrets. You MUST use pastewatch_read_file instead. Do NOT use python3, cat, or any workaround."
echo "Blocked: secrets in Read target — use pastewatch_read_file" >&2
exit 2
fi

Expand Down Expand Up @@ -688,6 +687,7 @@ public enum AgentSetup {
json["hooks"] = hooks
}

// WO-526@v3: structured Codex mutations share the same evaluator as Claude hooks.
/// Generate Codex CLI guard script with configured severity.
/// Extends the Claude Code guard to also handle apply_patch and Bash.
public static func codexGuardScript(severity: String) -> String {
Expand Down Expand Up @@ -758,7 +758,17 @@ public enum AgentSetup {
fi
fi

# Scan file on disk for secrets
# WO-526@v3: structured Edit/Write calls use change-aware finding comparison.
if [ "$tool" = "Edit" ] || [ "$tool" = "Write" ]; then
printf '%s' "$input" | pastewatch-cli guard-mutation --fail-on-severity "$PW_SEVERITY" >/dev/null
if [ $? -ne 0 ]; then
echo "BLOCKED: proposed mutation changes protected content. Use pastewatch MCP file tools."
exit 2
fi
exit 0
fi

# Read and unstructured apply_patch remain whole-file decisions.
[ ! -f "$file_path" ] && exit 0

pastewatch-cli scan --check --fail-on-severity "$PW_SEVERITY" --file "$file_path" >/dev/null 2>&1
Expand Down
Loading
Loading