Skip to content
Open
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ dist
.DS_Store
coverage/
.claude/
docs/BUG_REPORT.md
docs/ISSUES_READY.md
16 changes: 0 additions & 16 deletions extensions/llm/src/factory.test.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,8 @@
import { describe, it, expect } from "vitest";
import type { CompletionRequest } from "@step-cli/protocol";
import { createChatCompletionClient } from "./factory.js";
import { AnthropicMessagesClient } from "./anthropic-client.js";
import { OpenAICompatibleClient } from "./openai-client.js";

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/** Build a minimal CompletionRequest for testing. */
function baseRequest(
overrides: Partial<CompletionRequest> = {},
): CompletionRequest {
return {
model: "test-model",
messages: [{ role: "user", content: "hello" }],
...overrides,
};
}

/** Create a mock HttpTransport that returns a canned JSON response. */
function mockTransport(
body: unknown,
Expand Down
13 changes: 6 additions & 7 deletions extensions/realtime-aec/src/browser-audio-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,23 +150,22 @@ export class BrowserAudioDriver implements AudioDriver {
void this.ensureStarted().catch((err) =>
log.error({ err: String(err) }, "ensureStarted (capture) failed"),
);
const self = this;
const stream: AsyncIterable<Buffer> = {
[Symbol.asyncIterator]() {
[Symbol.asyncIterator]: () => {
return {
next(): Promise<IteratorResult<Buffer>> {
if (self.captureStopped) {
next: (): Promise<IteratorResult<Buffer>> => {
if (this.captureStopped) {
return Promise.resolve({ value: undefined, done: true });
}
const queued = self.captureBuf.shift();
const queued = this.captureBuf.shift();
if (queued) {
return Promise.resolve({ value: queued, done: false });
}
return new Promise<IteratorResult<Buffer>>((resolve) => {
self.capturePending.push({ resolve });
this.capturePending.push({ resolve });
});
},
return(): Promise<IteratorResult<Buffer>> {
return: (): Promise<IteratorResult<Buffer>> => {
return Promise.resolve({ value: undefined, done: true });
},
};
Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/tools/native-impls/file-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,9 +268,16 @@ function resolveWorkspacePath(
workspaceRoot: string,
candidate: string,
): string {
return path.isAbsolute(candidate)
const resolved = path.isAbsolute(candidate)
? candidate
: path.resolve(workspaceRoot, candidate);
const normalized = path.resolve(resolved);
if (!normalized.startsWith(path.resolve(workspaceRoot))) {
throw new ToolArgError(
`Path "${candidate}" resolves outside the workspace root "${workspaceRoot}"`,
);
}
return normalized;
}

const WINDOWS_DRIVE_PATH = /^[A-Za-z]:[\\/]/;
Expand Down
71 changes: 69 additions & 2 deletions packages/core/src/tools/native-impls/shell-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
optionalNumber,
optionalString,
requireString,
ToolArgError,
} from "./parsers.js";

const SKIP_DIRECTORY_NAMES = new Set([
Expand Down Expand Up @@ -164,10 +165,43 @@ function safeParse(rawArgs: string): unknown {
return JSON.parse(rawArgs);
}

const DANGEROUS_COMMAND_PATTERNS = [
/(^|\s|\||;|&&)rm\s+(-rf?|--recursive)\s+\/(\s|$)/,
/(^|\s|\||;|&&)rm\s+(-rf?|--recursive)\s+~(\s|$|\/)/,
/(^|\s|\||;|&&)dd\s+/,
/(^|\s|\||;|&&)mkfs\.\w+/,
/(^|\s|\||;|&&)fdisk\s+/,
/(^|\s|\||;|&&)mkswap\s+/,
/(^|\s|\||;|&&)shutdown\s+/,
/(^|\s|\||;|&&)reboot\s+/,
/(^|\s|\||;|&&)chmod\s+777\s+\//,
/(^|\s|\||;|&&)chown\s+/,
/(^|\s|\||;|&&)>(\s*\/dev\/(sda|sdb|nvme|mmc))/,
];

function validateShellCommand(command: string): ToolExecutionResult | null {
for (const pattern of DANGEROUS_COMMAND_PATTERNS) {
if (pattern.test(command)) {
const summary =
`Bash: command blocked for safety — pattern matched by security guardrail. ` +
`If this is a legitimate operation, consider using a more targeted tool or ` +
`command. Matched pattern: ${pattern}`;
return {
ok: false,
summary,
error: { code: "COMMAND_BLOCKED", message: summary },
};
}
}
return null;
}

async function bashExecute(
args: BashArgs,
ctx: ToolExecutionContext,
): Promise<ToolExecutionResult> {
const blocked = validateShellCommand(args.command);
if (blocked) return blocked;
const timeoutMs = args.timeout ?? ctx.commandTimeoutMs;
const result = await runShell(args.command, {
cwd: ctx.workspaceRoot,
Expand Down Expand Up @@ -334,6 +368,29 @@ async function runRipgrep(
});
}

const REDOS_PATTERNS = [
/\(\S+(?:\+\+|\*+|\+\?|\*\?)+\S*\)\s*[+*]/,
/\(\S*(?:\|.*){2,}\)\s*[+*]/,
/\((?:\w|\|){2,}\)\s*\+/,
/\(.*\)\s*\{\d+,\}/,
];

function hasRedosRisk(pattern: string): boolean {
for (const re of REDOS_PATTERNS) {
if (re.test(pattern)) return true;
}
return false;
}

function createSafeRegex(pattern: string): RegExp | null {
try {
if (hasRedosRisk(pattern)) return null;
return new RegExp(pattern);
} catch {
return null;
}
}

/**
* Pure-JS regex grep used when ripgrep is unavailable. Skips files larger
* than JSGREP_MAX_FILE_BYTES and sniffs the first 8KB for a NUL byte so a
Expand All @@ -344,7 +401,10 @@ async function jsGrep(
base: string,
include?: string,
): Promise<string> {
const regex = new RegExp(pattern);
const regex = createSafeRegex(pattern);
if (!regex) {
return `(pattern skipped — could not compile or ReDoS guardrail triggered)`;
}
const includeRegex = include ? globToRegex(include) : null;
const out: string[] = [];
await walkDir(base, async (file) => {
Expand Down Expand Up @@ -397,7 +457,14 @@ function resolveWorkspacePath(
workspaceRoot: string,
candidate: string,
): string {
return path.isAbsolute(candidate)
const resolved = path.isAbsolute(candidate)
? candidate
: path.resolve(workspaceRoot, candidate);
const normalized = path.resolve(resolved);
if (!normalized.startsWith(path.resolve(workspaceRoot))) {
throw new ToolArgError(
`Path "${candidate}" resolves outside the workspace root "${workspaceRoot}"`,
);
}
return normalized;
}
2 changes: 1 addition & 1 deletion packages/realtime/src/backend/stepfun-stateless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -457,7 +457,7 @@ export class StepfunStatelessAdapter implements BackendAdapter {
let msg: any;
try {
msg = JSON.parse(raw);
} catch (e) {
} catch {
this.log.warn({ raw: raw.slice(0, 200) }, "non-json message");
return;
}
Expand Down
4 changes: 2 additions & 2 deletions packages/realtime/src/vad/cli-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,8 @@ function editDistance(a: string, b: string): number {
bl = b.length;
if (al === 0) return bl;
if (bl === 0) return al;
let prev = new Array(bl + 1),
curr = new Array(bl + 1);
let prev = Array.from<number>({ length: bl + 1 }),
curr = Array.from<number>({ length: bl + 1 });
for (let j = 0; j <= bl; j++) prev[j] = j;
for (let i = 1; i <= al; i++) {
curr[0] = i;
Expand Down
4 changes: 2 additions & 2 deletions packages/realtime/src/vad/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,8 +249,8 @@ function editDistance(a: string, b: string): number {
if (al === 0) return bl;
if (bl === 0) return al;

let prev = new Array(bl + 1);
let curr = new Array(bl + 1);
let prev = Array.from<number>({ length: bl + 1 });
let curr = Array.from<number>({ length: bl + 1 });
for (let j = 0; j <= bl; j++) prev[j] = j;

for (let i = 1; i <= al; i++) {
Expand Down
2 changes: 1 addition & 1 deletion tests/helpers/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export function createMockToolRuntime(
) {
return {
getDefinitions: vi.fn(() => []),
executeTool: vi.fn(async (name: string, args: any) => {
executeTool: vi.fn(async (name: string, _args: any) => {
const result = toolResults.get(name);
if (result) return result;
return { ok: true, summary: `mock result for ${name}` };
Expand Down
Loading