# Security Audit — 2026-04-15

**Scope:** prompt-language plugin at `D:\Visual Studio Projects\prompt-language`
**Methodology:** Static analysis of source files + `npm audit`. Manual inspection of all shell execution paths, variable filtering, capture integrity, path traversal guards, and output truncation.
**Auditor:** Automated agent (claude-sonnet-4-6)

---

## Summary

| #   | Control                   | Status | Severity               |
| --- | ------------------------- | ------ | ---------------------- |
| 1   | npm audit                 | WARN   | Moderate (1 remaining) |
| 2   | shellInterpolate() usage  | PASS   | —                      |
| 3   | filterSpawnVariables()    | PASS   | —                      |
| 4   | Path traversal regex      | PASS   | —                      |
| 5   | Capture nonce             | WARN   | Low                    |
| 6   | New shell execution paths | PASS   | —                      |
| 7   | Output truncation         | PASS   | —                      |
| 8   | file_exists gate          | PASS   | —                      |

**Overall Verdict: PASS with recommendations**

All critical security controls are intact. One npm vulnerability requires manual intervention (breaking SDK upgrade). Capture nonce deprecation is noted as a design change worth documenting.

---

## 1. npm audit

### Pre-fix state (7 vulnerabilities)

| Package                       | Severity | Advisory                                                      | Impact                              |
| ----------------------------- | -------- | ------------------------------------------------------------- | ----------------------------------- |
| `@anthropic-ai/sdk` 0.79–0.80 | Moderate | GHSA-5474-4w2j-mq4c                                           | Memory tool sandbox escape          |
| `brace-expansion` <1.1.13     | Moderate | GHSA-f886-m6hf-6m8v                                           | DoS via zero-step sequence          |
| `picomatch` ≤2.3.1            | High     | GHSA-3v7f-55p6-f55p, GHSA-c2c7-rcm5-vvqj                      | Glob injection + ReDoS              |
| `smol-toml` <1.6.1            | Moderate | GHSA-v3rj-xjv7-4jmq                                           | DoS via commented lines             |
| `vite` 8.0.0–8.0.4            | High     | GHSA-4w7w-66w2-5vf9, GHSA-v2wj-q39q-566r, GHSA-p9ff-h696-f583 | Path traversal, arbitrary file read |
| `yaml` 2.0–2.8.2              | Moderate | GHSA-48c2-rrv3-qjmp                                           | Stack overflow via deep nesting     |

### Action taken

Ran `npm audit fix`. Fixed 5 of 6 packages (removed 1, changed 20). Post-fix state: **1 moderate vulnerability**.

### Remaining: `@anthropic-ai/sdk` 0.79.x–0.80.x (GHSA-5474-4w2j-mq4c)

**Status: WARN**
**Advisory:** Memory Tool Path Validation Allows Sandbox Escape to Sibling Directories
**Fix:** `npm audit fix --force` upgrades to 0.89.0 (breaking change)
**Impact assessment:** The `@anthropic-ai/sdk` package is listed as a dependency but is **not imported in any TypeScript source file** (`grep` across all `src/**/*.ts` found zero references). The vulnerability affects the SDK's built-in memory server — functionality not exercised by this project. Risk is low but the unused dependency should be reviewed.

**Recommendation:** Either upgrade to 0.89.0 (verify no breaking changes in `bin/` scripts or eval runners), or remove the dependency if unused.

---

## 2. shellInterpolate() Usage

**Status: PASS**

**Evidence:**

- `src/domain/interpolate.ts:88–119` — `shellInterpolate()` shell-encodes all substituted values via `shellEscapeValue()` (single-quotes + `'\''` escaping). Plain values are left unquoted only if they match `SAFE_UNQUOTED_SHELL_VALUE_RE = /^[A-Za-z0-9_./:-]+$/`.
- `src/application/advance-flow.ts:474–484` — `prepareShellCommand()` calls `shellInterpolate()` on POSIX and uses the env-variable placeholder strategy on Windows (no shell interpolation at all on Windows — values are placed in environment variables named `PROMPT_LANGUAGE_VAR_N`).
- Windows path: `prepareWindowsCommand()` (advance-flow.ts:453–472) substitutes each interpolated token into a numbered env var and emits `%PROMPT_LANGUAGE_VAR_N%` in the command string. The `ShellCommandRunner` then calls `hardenWindowsShellEnv()` which applies `escapeWindowsCmdLiteral()` to these env vars (`["&()<>^|]` characters are `^`-escaped).
- All `run:` node commands flow through `prepareShellCommand()` before execution. No raw `interpolate()` is used on shell commands.

**No issues found.**

---

## 3. filterSpawnVariables()

**Status: PASS**

**Evidence (`src/application/advance-flow.ts:2258–2300`):**

```typescript
const SENSITIVE_VAR_SUFFIXES = ['_key', '_token', '_secret', '_password'];
const SPAWN_EPHEMERAL_VARIABLES = new Set([
  'last_exit_code',
  'command_failed',
  'command_succeeded',
  'last_stdout',
  'last_stderr',
]);

function shouldExcludeSpawnVariable(key: string): boolean {
  if (SPAWN_EPHEMERAL_VARIABLES.has(key)) return true;
  if (key.startsWith('_runtime_diagnostic.')) return true;
  const lower = key.toLowerCase();
  return SENSITIVE_VAR_SUFFIXES.some((suffix) => lower.endsWith(suffix));
}
```

- Suffix match is case-insensitive (`lower.endsWith(suffix)`), preventing bypass via `API_KEY` vs `api_key`.
- Runtime diagnostics (`_runtime_diagnostic.*`) are excluded to prevent leaking internal state.
- Ephemeral run-state variables (`last_stdout`, `last_stderr`) are excluded to prevent unintentional propagation of command outputs.
- All three `filterSpawnVariables()` call sites (spawn node, race node, foreach-spawn) use this function.

**No issues found.**

---

## 4. Path Traversal Regex

**Status: PASS**

**Evidence (`src/application/evaluate-completion.ts:70–90`):**

```typescript
const SAFE_PATH_RE = /^(?!\/)(?!.*\.\.)[\w ./-]+$/;

function isAbsoluteOrUnsafePath(p: string): boolean {
  if (p.startsWith('/')) return true;
  if (p.includes('\\')) return true;
  if (/^[A-Za-z]:/.test(p)) return true;
  return false;
}
```

- `SAFE_PATH_RE` uses a character allowlist (`[\w ./-]`) meaning only word chars, spaces, dots, forward-slashes, and hyphens are permitted. Single quotes, backticks, semicolons, shell metacharacters are all excluded.
- The negative lookahead `(?!.*\.\.)` blocks `..` traversal within the path.
- `isAbsoluteOrUnsafePath()` separately blocks Unix absolute paths (starting `/`), Windows absolute paths (`C:\`, etc.), UNC paths (`\\`), and backslash-containing paths.
- `createFileExistsCommand()` embeds the validated path into a Node.js one-liner: `` `node -e "process.exit(require('node:fs').existsSync('${path}') ? 0 : 1)"` `` — single-quote injection is blocked by the allowlist (no `'` in `[\w ./-]`).

**Note:** The regex allows spaces (` `) in paths. This is intentional for filenames with spaces but path components with spaces will still be safe in the Node.js one-liner because they're wrapped in single quotes and the SAFE_PATH_RE already prevents any `'` character from being in the path.

**No issues found.**

---

## 5. Capture Nonce

**Status: WARN (Design Change Noted)**

**Evidence (`src/domain/capture-prompt.ts`):**

The codebase has **deprecated tag-based capture** in favor of **file-based capture** (Write tool). Key observations:

- `buildCapturePrompt()` and `buildCaptureRetryPrompt()` no longer embed or validate nonces (the `_nonce` parameter is accepted but unused: `_nonce?: string`).
- `captureTagName()`, `extractCaptureTag()` are marked `@deprecated` and `CAPTURE_TAG` is a constant (not a per-session UUID).
- The `CAPTURE_VARS_DIR = '.prompt-language/vars'` file channel has no nonce — Claude is instructed to write to a specific path.
- File-based capture is safe against injection because the Write tool path is fixed (`captureFilePath(varName)` returns a predictable path under `.prompt-language/vars/`), and the runtime only reads from that directory.

**The nonce was previously a per-session UUID4 injection guard for tag-based capture.** Since tag capture is deprecated, the nonce is no longer functional. The new file-based capture relies on filesystem path control rather than content nonce validation.

**Recommendation:** Document in memory/notes that the capture security model has changed from nonce-validated tags to filesystem-path-controlled writes. Ensure the `captureFilePath()` function cannot produce paths outside `.prompt-language/vars/` — currently it does not validate `varName` for path traversal characters (dots, slashes). If `varName` can contain user-controlled input, this could write files outside the vars dir.

**Risk:** Low. Variable names are parsed from the DSL by `parse-flow.ts` which restricts them to `\w+` (alphanumeric + underscore) patterns. Verify this invariant is maintained.

---

## 6. New Shell Execution Paths

**Status: PASS**

All `child_process` usages were catalogued:

| File                           | Usage                                                                                                                               | Assessment                                     |
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
| `shell-command-runner.ts`      | `spawn()` with shell — all commands go through `prepareShellCommand()` first                                                        | Safe                                           |
| `shell-command-runner.ts`      | `spawn('taskkill', ['/F', '/T', '/PID', pid])` — PID is `String(number)`                                                            | Safe                                           |
| `claude-process-spawner.ts`    | `spawn('claude', ['-p', '--dangerously-skip-permissions', ...modelArgs, prompt])` — prompt passed as single argv element, not shell | Safe                                           |
| `claude-process-spawner.ts`    | `execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'])` — PID is `String(number)`                                             | Safe                                           |
| `aider-prompt-turn-runner.ts`  | `execFileSync('python', args)` — args array, not shell string                                                                       | Safe                                           |
| `codex-prompt-turn-runner.ts`  | `spawn('opencode', args)` — args array                                                                                              | Safe                                           |
| `ollama-prompt-turn-runner.ts` | `execFile()` — args array                                                                                                           | Safe                                           |
| `post-tool-use.ts`             | `execSync('git diff --quiet')` — hardcoded string, no user input                                                                    | Safe                                           |
| `codex-post-tool-use.ts`       | `execSync('git diff --quiet')` — hardcoded string                                                                                   | Safe                                           |
| `eval-dataset-runner.ts`       | `execSync(caseSpec.verify, ...)` — runs eval verification commands from dataset spec files                                          | Low risk (eval-only path, not production hook) |
| `eval-dataset-runner.ts`       | `execSync('git rev-parse --short HEAD')` — hardcoded string                                                                         | Safe                                           |
| Test files                     | Various `execSync`/`spawnSync` for test infrastructure                                                                              | Not production code                            |

**Notable finding:** `eval-dataset-runner.ts:697` runs `execSync(caseSpec.verify, ...)` where `caseSpec.verify` comes from a dataset specification file. This is in the eval/testing infrastructure, not a production hook. The dataset files are authored by developers, not end-users. Risk is accepted but noted.

**No critical issues found.**

---

## 7. Output Truncation

**Status: PASS**

| Variable/Output              | Limit        | Location                              |
| ---------------------------- | ------------ | ------------------------------------- |
| `last_stdout`, `last_stderr` | 2000 chars   | `advance-flow.ts:583, 669–675`        |
| Gate stderr/stdout           | 2000 chars   | `evaluate-completion.ts:142, 332–334` |
| Audit entry fields           | 500 chars    | `file-audit-logger.ts:12, 14–19`      |
| Review evidence              | 300 chars    | `advance-flow.ts:1215–1220`           |
| Aider runner output          | 12,000 chars | `aider-prompt-turn-runner.ts:13`      |
| Capture variable             | 2000 chars   | `capture-prompt.ts:8`                 |
| Process output buffer        | 4 MB         | `shell-command-runner.ts:22`          |

All truncation paths verified to include a `[truncated]` or `...` marker. The `storeRuntimeOutput()` function (advance-flow.ts:646–749) enforces the 2000-char limit before variables are stored in state.

**No issues found.**

---

## 8. file_exists Gate

**Status: PASS**

Defense-in-depth for `file_exists <path>` gate predicate:

1. **Allowlist regex** (`SAFE_PATH_RE`): `[\w ./-]` — no shell metacharacters, no quotes.
2. **Double-dot check**: negative lookahead `(?!.*\.\.)` blocks traversal.
3. **Absolute path rejection**: `isAbsoluteOrUnsafePath()` blocks `/`, `\`, `C:\`, etc.
4. **Execution**: `node -e "process.exit(require('node:fs').existsSync('${path}') ? 0 : 1)"` — Node.js inline script, not a shell glob. Path is in single quotes; the regex ensures no `'` can appear in path.

**No issues found.**

---

## npm audit fix diff

5 packages fixed (non-breaking):

- `brace-expansion` → patched
- `picomatch` → patched
- `smol-toml` → patched
- `vite` → patched
- `yaml` → patched

Remaining: `@anthropic-ai/sdk` (breaking upgrade required, low impact as not imported in source).

---

## Recommendations

1. **Upgrade `@anthropic-ai/sdk`** to 0.89.0 when safe to do so, or remove it from `package.json` if it is not actually used by any production code. Run `npm run ci` after the change.

2. **Validate `varName` in `captureFilePath()`** — currently trusts that DSL parser only allows `\w+` variable names. Add an explicit assertion or regex check in `captureFilePath()` to enforce this invariant defensively.

3. **Document the capture security model change** — the shift from nonce-tagged response capture to file-based Write tool capture changes the threat model. The old nonce prevented malicious injection into tag content; the new model prevents injection by relying on filesystem path control. Both are sound but the change should be noted in security documentation.

4. **Eval runner `execSync(caseSpec.verify)`** — acceptable for developer tooling but should not be reachable from production hooks. Confirm this path is never exposed to untrusted input in CI environments.
