Skip to content
Closed
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
1,259 changes: 1,259 additions & 0 deletions docs/superpowers/plans/2026-08-17-sanitizer-policy-hardening.md

Large diffs are not rendered by default.

Large diffs are not rendered by default.

87 changes: 77 additions & 10 deletions src/classifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,31 +29,98 @@ export interface HttpClassifierConfig {
readonly fetchImpl?: typeof fetch
}

const CONTENT_KEYS = /^(?:content|body|payload|data|text|old_string|new_string|description|justification)$/i
const SECRET_KEYS = /(?:api|auth|access|secret|private|credential|password|token|cookie|authorization).*?(?:key|value|token)?$/i

/** Redact likely secrets and bound one classifier-visible text value. */
interface CredentialPattern {
readonly name: string
readonly pattern: RegExp
}

const CREDENTIAL_PATTERNS: readonly CredentialPattern[] = [
// Order is most-specific-first so a broader pattern does not consume a
// token-shaped substring that a more specific pattern below should own.
// e.g. `token-suffix` matches the body of `sk-ant-api\d{2}-...`, so the
// Anthropic/GitHub/LLM patterns must run first to claim their tokens.

// Anthropic keys are `sk-ant-apiNN-<body>` where NN is exactly 2 digits
// and <body> is 32+ base64url-ish chars. Anchoring `api\d{2}-` rejects
// documentation/strings of the shape `sk-ant-<anything20plus>` and only
// matches real Anthropic key prefixes per vendor docs.
{ name: 'anthropic-key', pattern: /\bsk-ant-api\d{2}-[A-Za-z0-9_-]{32,}\b/g },

// GitHub token formats (gho_ is 40; gh[pus]_ is 36; github_pat_ has 22+).
{ name: 'github-oauth', pattern: /\bgho_[A-Za-z0-9]{40}\b/g },
{ name: 'github-classic', pattern: /\bgh[pus]_[A-Za-z0-9]{36}\b/g },
{ name: 'github-fine-pat', pattern: /\bgithub_pat_[A-Za-z0-9_]{22,}\b/g },

// AWS access-key IDs (16 chars after the prefix).
{ name: 'aws-access-key', pattern: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g },

// LLM and tool vendor keys.
{ name: 'llm-key', pattern: /\bsk-(?:proj-)?[A-Za-z0-9_-]{16,}\b/g },

// Broad fallback: any `sk|ghp|github_pat|xox[baprs]` followed by 8+ chars.
// Must come after the vendor-specific patterns above so they own their tokens.
{ name: 'token-suffix', pattern: /\b(?:sk|ghp|github_pat|xox[baprs])[-_][A-Za-z0-9_-]{8,}\b/g },

{ name: 'bearer', pattern: /\bBearer\s+[A-Za-z0-9._~+\/-]{8,}/gi },
{ name: 'key-value', pattern: /((?:api[_-]?key|token|secret|password)=)[^&\s]+/gi },

// PEM private-key blocks (RSA, OPENSSH, EC, ECDSA, DSA, ENCRYPTED).
// Matches the BEGIN line through the next matching END line, inclusive.
{ name: 'pem-private-key',
pattern: /-----BEGIN (?:RSA |OPENSSH |EC |ECDSA |DSA |ENCRYPTED )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |OPENSSH |EC |ECDSA |DSA |ENCRYPTED )?PRIVATE KEY-----/g },
]

/** Redact pattern-matched credential content and surface which patterns fired. */
export interface ClassifierRedaction {
readonly value: string
readonly redactedNames: readonly string[]
}

/**
* Run every entry in {@link CREDENTIAL_PATTERNS} against the input. Each match
* is replaced with `[redacted-<name>]`; each unique name that fires is appended
* to `redactedNames` in the order it appears in the patterns table. Truncated
* to `maxLength` when the redacted value would otherwise exceed it.
*
* This is content-first: a credential-shaped substring anywhere in the input is
* redacted regardless of the field name it sits under. Key-name matching is
* still applied one layer up in {@link sanitizeClassifierArguments} for
* defense-in-depth on `SECRET_KEYS`.
*/
export function redactClassifierText(value: string, maxLength = 1_000): ClassifierRedaction {
let current = value
const redactedNames: string[] = []
for (const { name, pattern } of CREDENTIAL_PATTERNS) {
const before = current
current = current.replace(pattern, `[redacted-${name}]`)
if (current !== before && !redactedNames.includes(name)) redactedNames.push(name)
}
if (current.length > maxLength) current = current.slice(0, maxLength)
return { value: current, redactedNames }
}

/**
* Public thin wrapper over {@link redactClassifierText}. The kept signature
* preserves consumer call sites in `src/index.ts` (`trustedUserMessages`,
* `sandboxRequest.justification`) — see spec §1.4.
*/
export function sanitizeClassifierText(value: string): string {
return value
.replace(/\b(?:sk|ghp|github_pat|xox[baprs])[-_][A-Za-z0-9_-]{8,}\b/g, '[redacted-secret]')
.replace(/\bBearer\s+[A-Za-z0-9._~+\/-]{8,}/gi, 'Bearer [redacted-secret]')
.replace(/((?:api[_-]?key|token|secret|password)=)[^&\s]+/gi, '$1[redacted-secret]')
.slice(0, 1_000)
return redactClassifierText(value).value
}

/** Remove bulk content and likely secrets before crossing the classifier network boundary. */
export function sanitizeClassifierArguments(value: unknown, depth = 0): unknown {
if (depth > 3) return '[truncated-depth]'
if (typeof value === 'string') return sanitizeClassifierText(value)
if (typeof value === 'string') return redactClassifierText(value).value
if (typeof value === 'number' || typeof value === 'boolean' || value === null) return value
if (Array.isArray(value)) return value.slice(0, 25).map(item => sanitizeClassifierArguments(item, depth + 1))
if (typeof value !== 'object') return `[${typeof value}]`
const output: Record<string, unknown> = {}
for (const [key, entry] of Object.entries(value).slice(0, 50)) {
if (SECRET_KEYS.test(key)) {
output[key] = '[redacted-secret-field]'
} else if (CONTENT_KEYS.test(key) && typeof entry === 'string') {
output[key] = `[redacted-${key}:${entry.length}-chars]`
} else {
output[key] = sanitizeClassifierArguments(entry, depth + 1)
}
Expand Down
75 changes: 75 additions & 0 deletions src/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,3 +177,78 @@ export function isArtifactArea(target: string, roots: PolicyRoots): boolean {
const normalized = normalizePath(target, roots.workspace, roots.home)
return isWithin(roots.workspace, normalized) || roots.tempRoots.some(root => isWithin(root, normalized))
}

/**
* Extract filesystem targets from a unified-diff style `apply_patch` payload.
*
* Walks the patch text once on a per-line basis and records:
* - the destination path of every `--- a/<path>` + `+++ b/<path>` pair
* (modify),
* - `+++ b/<path>` when paired with `--- /dev/null` (new file),
* - `--- a/<path>` when paired with `+++ /dev/null` (delete),
* - the destination path of every `rename from` / `rename to` pair.
*
* Returns deduplicated, order-preserved paths with any leading `a/` or `b/`
* stripped. Paths are NOT normalized here — every caller passes the result
* through `normalizePath` so workspace-relative and absolute paths resolve
* the same way.
*
* Returns `[]` for empty input or any patch text where no `---` header can
* be paired with a `+++` header. Callers treat that as fail-closed
* (manual approval) rather than as a successful empty parse.
*/
export function extractApplyPatchPaths(patch: string): string[] {
if (typeof patch !== 'string' || patch === '') return []
const lines = patch.split(/\r?\n/)
const targets: string[] = []
const seen = new Set<string>()

const record = (raw: string | undefined): void => {
if (raw === undefined) return
if (raw === '' || raw === '/dev/null') return
const stripped = raw.startsWith('a/') || raw.startsWith('b/') ? raw.slice(2) : raw
if (!seen.has(stripped)) {
seen.add(stripped)
targets.push(stripped)
}
}

let i = 0
while (i < lines.length) {
const line = lines[i] ?? ''
if (line.startsWith('--- ')) {
const minusRaw = line.slice(4)
const minusPath = minusRaw === '/dev/null'
? undefined
: minusRaw.startsWith('a/') ? minusRaw.slice(2) : minusRaw

// Walk forward until we find the matching +++ header.
let j = i + 1
while (j < lines.length && !(lines[j] ?? '').startsWith('+++ ')) j++
if (j < lines.length) {
const plusRaw = (lines[j] ?? '').slice(4)
const plusPath = plusRaw === '/dev/null'
? undefined
: plusRaw.startsWith('b/') ? plusRaw.slice(2) : plusRaw

if (minusPath === undefined && plusPath !== undefined) {
// New file: --- /dev/null + +++ b/<path> ⇒ record plus.
record(plusPath)
} else if (plusPath === undefined && minusPath !== undefined) {
// Delete: --- a/<path> + +++ /dev/null ⇒ record minus.
record(minusPath)
} else if (plusPath !== undefined) {
// Modify: record the destination path.
record(plusPath)
}
i = j + 1
continue
}
} else if (line.startsWith('rename to ')) {
record(line.slice('rename to '.length))
}
i++
}

return targets
}
94 changes: 94 additions & 0 deletions src/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { lstatSync } from 'node:fs'
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
import type { ArtifactRegistry } from './artifacts.js'
import {
extractApplyPatchPaths,
hardDestructiveTargetReason,
isProtectedProjectPath,
isWithin,
Expand Down Expand Up @@ -45,6 +46,36 @@ function containsCredentialMaterial(argumentsValue: unknown): boolean {
.test(serializedArguments(argumentsValue))
}

/** Parameter names that are commonly used to carry credentials in URLs. */
const URL_CREDENTIAL_KEYS = /(?:token|access_token|api[_-]?key|sig|signature|auth|authorization)/i

/**
* Value-shape heuristic for credential-looking query-string values. Matches
* either a base64url-ish substring ≥ 8 chars (alphanumerics, `.`, `_`, `~`,
* `+`, `/`, `-`, `=`) or a hex digest ≥ 16 chars.
*/
const URL_CREDENTIAL_VALUE = /^(?:[A-Za-z0-9._~+\/=-]{8,}|[A-Fa-f0-9]{16,})$/

/**
* Returns true when the URL contains a query parameter whose name matches
* {@link URL_CREDENTIAL_KEYS} and whose value matches
* {@link URL_CREDENTIAL_VALUE}. When the URL cannot be parsed as absolute,
* a regex fallback over the raw text catches the same shape.
*/
function urlContainsCredential(url: string): boolean {
try {
const parsed = new URL(url)
for (const [key, value] of parsed.searchParams) {
if (!URL_CREDENTIAL_KEYS.test(key)) continue
if (URL_CREDENTIAL_VALUE.test(value)) return true
}
return false
} catch {
// Relative or malformed URL; fall through to the regex.
}
return /[?&](?:token|access_token|api[_-]?key|sig|signature|auth|authorization)=[^&\s"']{8,}/i.test(url)
}

/** One model-requested, tool-native widening of the standing workspace sandbox. */
export interface SandboxEscalationRequest {
readonly requestedMode: string
Expand Down Expand Up @@ -136,6 +167,13 @@ export function hardDenyReason(exec: Readonly<ToolExecution>, roots: PolicyRoots
if ((/^(?:web_fetch|curl|wget)/i.test(exec.name) || EXTERNAL_WRITE_TOOL.test(exec.name)) && containsCredentialMaterial(exec.arguments)) {
return 'external call contains credential or private-key material'
}
const argsForUrl = record(exec.arguments)
const argsUrl = typeof argsForUrl?.url === 'string' ? argsForUrl.url : undefined
if (argsUrl !== undefined
&& (/^(?:web_fetch|curl|wget)/i.test(exec.name) || EXTERNAL_WRITE_TOOL.test(exec.name))
&& urlContainsCredential(argsUrl)) {
return 'external URL contains credential-shaped query parameter'
}
if ((exec.name === 'bash' || exec.name === 'pwsh') && typeof args?.command === 'string') {
return hardDenyShellReason(args.command, exec.name, roots)
}
Expand All @@ -147,6 +185,20 @@ export function hardDenyReason(exec: Readonly<ToolExecution>, roots: PolicyRoots
if (reason !== undefined) return `mutation targets ${reason}`
}
}
if (exec.name === 'apply_patch') {
const argsForPatch = record(exec.arguments)
const patch = typeof argsForPatch?.patch === 'string'
? argsForPatch.patch
: typeof argsForPatch?.input === 'string' ? argsForPatch.input : undefined
if (patch !== undefined) {
const targets = extractApplyPatchPaths(patch)
for (const raw of targets) {
const normalized = normalizePath(raw, roots.workspace, roots.home)
const reason = hardDestructiveTargetReason(normalized, roots)
if (reason !== undefined) return `apply_patch targets ${reason}`
}
}
}
if (DESTRUCTIVE_TOOL.test(exec.name)) {
const path = pathArgument(args)
if (path !== undefined) {
Expand Down Expand Up @@ -203,6 +255,48 @@ export function assessTool(exec: Readonly<ToolExecution>, roots: PolicyRoots, ar
}
}

if (exec.name === 'apply_patch') {
const argsForPatch = record(exec.arguments)
const patch = typeof argsForPatch?.patch === 'string'
? argsForPatch.patch
: typeof argsForPatch?.input === 'string' ? argsForPatch.input : undefined
if (patch === undefined) {
return { decision: 'ask', reason: 'apply_patch payload is missing', classifierEligible: false }
}
const targets = extractApplyPatchPaths(patch)
if (targets.length === 0) {
return {
decision: 'ask',
reason: 'apply_patch text cannot be parsed for target paths; manual approval required',
classifierEligible: false,
}
}
for (const raw of targets) {
const normalized = normalizePath(raw, roots.workspace, roots.home)
if (isProtectedProjectPath(normalized, roots)) {
return {
decision: 'ask',
reason: `apply_patch targets protected project metadata: ${normalized}`,
classifierEligible: true,
filesystemEffects: targets.map(p => ({
kind: 'create-or-overwrite' as const,
path: normalizePath(p, roots.workspace, roots.home),
existedBefore: existedBefore(normalizePath(p, roots.workspace, roots.home)),
})),
}
}
}
const effects = targets.map(p => {
const n = normalizePath(p, roots.workspace, roots.home)
return { kind: 'create-or-overwrite' as const, path: n, existedBefore: existedBefore(n) }
})
return {
decision: 'allow',
reason: 'apply_patch inside workspace is delegated to the filesystem sandbox',
classifierEligible: false,
filesystemEffects: effects,
}
}

if (exec.name === 'str_replace_editor') {
const command = args?.command
Expand Down
Loading