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
44 changes: 39 additions & 5 deletions apps/cli/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,16 +139,50 @@ export const ModeCommand: SlashCommand = {
},
};

// Effort tier UI metadata — surfaced by `/effort` with no args.
const EFFORT_TIERS: Array<{
name: string;
maxTokens: number;
temperature: number;
use: string;
}> = [
{ name: 'low', maxTokens: 1024, temperature: 0.0, use: 'Quick targeted fixes. Cheap.' },
{ name: 'medium', maxTokens: 4096, temperature: 0.3, use: 'Default. Most tasks.' },
{ name: 'high', maxTokens: 8192, temperature: 0.5, use: 'Multi-step refactors.' },
{ name: 'xhigh', maxTokens: 16384, temperature: 0.6, use: 'Plans, architecture decisions.' },
{ name: 'max', maxTokens: 32768, temperature: 0.7, use: 'Open-ended exploration. Burns tokens.' },
];

export const EffortCommand: SlashCommand = {
name: '/effort',
description: 'Set effort tier: /effort low|medium|high|xhigh|max',
description: 'Set effort tier (interactive picker if no arg): /effort [low|medium|high|xhigh|max]',
run(args, ctx) {
const valid = ['low', 'medium', 'high', 'xhigh', 'max'];
if (args.length === 0) return [`Current effort: ${ctx.effort}`];
if (args.length === 0) {
// Selector UI — show the table; user picks via `/effort <name>` next turn.
const lines = [`Current effort: ${ctx.effort}`, ''];
lines.push('Available tiers:');
lines.push(' Tier maxTokens temperature Use case');
for (const t of EFFORT_TIERS) {
const marker = t.name === ctx.effort ? '●' : ' ';
lines.push(
` ${marker} ${t.name.padEnd(7)} ${String(t.maxTokens).padStart(7)} ${t.temperature.toFixed(1).padStart(11)} ${t.use}`,
);
}
lines.push('');
lines.push('Switch with: /effort <tier>');
return lines;
}
const next = args[0]!;
if (!valid.includes(next)) return [`Unknown effort "${next}". Valid: ${valid.join(' | ')}`];
const tier = EFFORT_TIERS.find((t) => t.name === next);
if (!tier) {
return [
`Unknown effort "${next}". Valid: ${EFFORT_TIERS.map((t) => t.name).join(' | ')}`,
];
}
ctx.effort = next;
return [`Effort switched to ${next}.`];
return [
`Effort switched to ${next}. (maxTokens=${tier.maxTokens}, temperature=${tier.temperature}, ${tier.use})`,
];
},
};

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"scripts": {
"build": "tsc -b",
"typecheck": "tsc -b --pretty",
"test": "pnpm -r test",
"test": "pnpm -r test && vitest run --config vitest.scripts.config.ts",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write \"**/*.{ts,tsx,json,md,yml,yaml}\"",
Expand Down
101 changes: 101 additions & 0 deletions packages/core/src/sandbox/dns-proxy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { createSocket } from 'node:dgram';
import { afterEach, describe, expect, it } from 'vitest';
import { buildNxDomain, parseQName, startDnsProxy, type DnsProxyHandle } from './dns-proxy.js';

/** Build a minimal DNS query packet for a single domain. */
function buildQuery(domain: string, txnId = 0x1234): Buffer {
const labels = domain.split('.').map((l) => Buffer.concat([Buffer.from([l.length]), Buffer.from(l, 'utf8')]));
const qname = Buffer.concat([...labels, Buffer.from([0])]);
// Header (12 bytes) + qname + qtype (2) + qclass (2)
const header = Buffer.alloc(12);
header.writeUInt16BE(txnId, 0);
header.writeUInt16BE(0x0100, 2); // flags: RD=1
header.writeUInt16BE(1, 4); // QDCOUNT=1
const qtail = Buffer.from([0, 1, 0, 1]); // QTYPE=A, QCLASS=IN
return Buffer.concat([header, qname, qtail]);
}

describe('parseQName', () => {
it('extracts a multi-label domain', () => {
const q = buildQuery('example.com');
expect(parseQName(q)).toBe('example.com');
});
it('extracts a deep domain', () => {
const q = buildQuery('a.b.c.d.example.com');
expect(parseQName(q)).toBe('a.b.c.d.example.com');
});
it('returns null on too-short packet', () => {
expect(parseQName(Buffer.alloc(5))).toBeNull();
});
it('returns null on invalid label length', () => {
const bad = Buffer.alloc(20);
bad[12] = 200; // > 63 → compression / invalid
expect(parseQName(bad)).toBeNull();
});
});

describe('buildNxDomain', () => {
it('preserves the txn ID and sets RCODE=3', () => {
const q = buildQuery('foo.com', 0x5678);
const resp = buildNxDomain(q);
expect(resp.readUInt16BE(0)).toBe(0x5678);
// Lower nibble of byte 3 is RCODE
expect(resp[3]! & 0x0f).toBe(3);
// High bit of byte 2 is QR (1 = response)
expect(resp[2]! & 0x80).toBe(0x80);
});
it('returns empty buffer on too-short input', () => {
expect(buildNxDomain(Buffer.alloc(5)).length).toBe(0);
});
});

describe('startDnsProxy', () => {
let proxy: DnsProxyHandle | null = null;
afterEach(async () => {
if (proxy) {
await proxy.close();
proxy = null;
}
});

function queryProxy(port: number, domain: string): Promise<Buffer> {
return new Promise((resolve, reject) => {
const sock = createSocket('udp4');
const timer = setTimeout(() => {
sock.close();
reject(new Error('query timed out'));
}, 2000);
sock.once('message', (msg) => {
clearTimeout(timer);
sock.close();
resolve(msg);
});
sock.once('error', (err) => {
clearTimeout(timer);
sock.close();
reject(err);
});
sock.send(buildQuery(domain), port, '127.0.0.1');
});
}

it('returns NXDOMAIN for non-allowed domains', async () => {
proxy = await startDnsProxy({ allowedDomains: ['github.com'], log: () => {} });
const resp = await queryProxy(proxy.port, 'evil.example.com');
// RCODE = NXDOMAIN
expect(resp[3]! & 0x0f).toBe(3);
});

it('binds to a local port and reports it', async () => {
proxy = await startDnsProxy({ allowedDomains: [], log: () => {} });
expect(proxy.port).toBeGreaterThan(0);
expect(proxy.port).toBeLessThan(65536);
});

it('close() is idempotent', async () => {
proxy = await startDnsProxy({ allowedDomains: [] });
await proxy.close();
await proxy.close();
proxy = null;
});
});
145 changes: 145 additions & 0 deletions packages/core/src/sandbox/dns-proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
// DNS proxy for sandbox `network.allowedDomains` enforcement.
// Spec: docs/security-model.md (M3.5-ext)
//
// Without OS-level DNS hooking, we can't truly intercept every connect()
// call from sandboxed processes. What we CAN do: run a local UDP DNS
// resolver that ONLY answers queries for whitelisted domains, and have the
// sandbox's resolv.conf point at us. Anything else returns NXDOMAIN.
//
// This is M3.5-ext scaffold. Full integration with `sandbox-exec` /
// `bwrap` requires writing a resolv.conf into the sandbox + plumbing
// 127.0.0.1:<port> in. The resolver itself is straightforward.

import { createSocket, type Socket } from 'node:dgram';

export interface DnsProxyOpts {
/** Domains that should resolve. Subdomains are NOT included; use explicit entries. */
allowedDomains: string[];
/** Upstream DNS server for allowed lookups (default 1.1.1.1). */
upstream?: string;
/** Bind address; default 127.0.0.1. */
bindAddr?: string;
/** Bind port; default 0 (random). */
bindPort?: number;
/** Optional logger for diagnostics. */
log?: (line: string) => void;
}

export interface DnsProxyHandle {
/** Actual bound port. */
port: number;
/** Stop the proxy. */
close: () => Promise<void>;
}

export async function startDnsProxy(opts: DnsProxyOpts): Promise<DnsProxyHandle> {
const allowed = new Set(opts.allowedDomains.map((d) => d.toLowerCase()));
const upstream = opts.upstream ?? '1.1.1.1';
const log = opts.log ?? (() => {});
const sock = createSocket('udp4');

sock.on('message', (msg, rinfo) => {
const domain = parseQName(msg);
if (!domain) {
sock.send(buildNxDomain(msg), rinfo.port, rinfo.address);
return;
}
const norm = domain.toLowerCase().replace(/\.$/, '');
if (!allowed.has(norm)) {
log(`[dns-proxy] DENY ${norm}`);
sock.send(buildNxDomain(msg), rinfo.port, rinfo.address);
return;
}
log(`[dns-proxy] ALLOW ${norm} → ${upstream}`);
forward(sock, msg, rinfo, upstream).catch((err: Error) => {
log(`[dns-proxy] forward error: ${err.message}`);
sock.send(buildNxDomain(msg), rinfo.port, rinfo.address);
});
});

await new Promise<void>((resolve, reject) => {
sock.once('error', reject);
sock.bind(opts.bindPort ?? 0, opts.bindAddr ?? '127.0.0.1', () => {
sock.removeListener('error', reject);
resolve();
});
});

const port = sock.address().port;
return {
port,
close: () =>
new Promise<void>((resolve) => {
try {
sock.close(() => resolve());
} catch {
resolve();
}
}),
};
}

// ──────────────────────────────────────────────────────────────────────────
// Tiny DNS wire-format helpers
// ──────────────────────────────────────────────────────────────────────────

/** Extract the QNAME from a DNS request packet. Returns null on parse error. */
export function parseQName(buf: Buffer): string | null {
if (buf.length < 13) return null; // header(12) + at least one length byte
let pos = 12; // skip 12-byte header
const parts: string[] = [];
while (pos < buf.length) {
const len = buf[pos];
if (len === undefined) return null;
if (len === 0) {
pos++;
break;
}
if (len > 63) return null; // compression / invalid
pos++;
if (pos + len > buf.length) return null;
parts.push(buf.toString('utf8', pos, pos + len));
pos += len;
}
return parts.join('.');
}

/** Build an NXDOMAIN response that matches the query's transaction ID. */
export function buildNxDomain(query: Buffer): Buffer {
if (query.length < 12) return Buffer.alloc(0);
const resp = Buffer.from(query);
// Set flags: QR=1 (response), Opcode=0, RA=1, RCODE=3 (NXDOMAIN)
resp[2] = 0x81; // QR=1, AA=0, TC=0, RD=1
resp[3] = 0x83; // RA=1, Z=0, RCODE=3
return resp;
}

/** Forward the query to the upstream DNS and pipe the response back. */
function forward(
serverSock: Socket,
query: Buffer,
reply: { address: string; port: number },
upstream: string,
): Promise<void> {
return new Promise((resolve, reject) => {
const upSock = createSocket('udp4');
const timer = setTimeout(() => {
upSock.close();
reject(new Error('upstream timeout'));
}, 5000);
upSock.once('message', (msg) => {
clearTimeout(timer);
serverSock.send(msg, reply.port, reply.address, (err) => {
upSock.close();
if (err) reject(err);
else resolve();
});
});
upSock.once('error', (err) => {
clearTimeout(timer);
upSock.close();
reject(err);
});
upSock.send(query, 53, upstream);
});
}
8 changes: 8 additions & 0 deletions packages/core/src/sandbox/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ export {

export { splitClauses, allClausesExcluded, type Clause } from './pipeline.js';

export {
startDnsProxy,
parseQName,
buildNxDomain,
type DnsProxyOpts,
type DnsProxyHandle,
} from './dns-proxy.js';

export interface SandboxedCommand {
/** Command + args to spawn (the actual sandbox wrapper invocation). */
command: string;
Expand Down
66 changes: 66 additions & 0 deletions scripts/gen-release-notes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
import { bucketCommits, classify, renderMarkdown, strip } from './gen-release-notes.js';

describe('classify', () => {
it.each([
['feat: add X', 'feat'],
['fix(core): bug', 'fix'],
['fix(core)!: breaking', 'fix'],
['feat!: breaking new', 'feat'],
['perf: faster loops', 'perf'],
['refactor: extract', 'refactor'],
['docs: readme', 'docs'],
['test: more coverage', 'test'],
['chore: bump deps', 'chore'],
['Bump foo', 'other'],
['random subject', 'other'],
])('"%s" → %s', (subject, expected) => {
expect(classify({ hash: 'h', subject, body: '' })).toBe(expected);
});
});

describe('strip', () => {
it('drops type(scope) prefix', () => {
expect(strip('feat(core): add tool search')).toBe('add tool search');
expect(strip('fix!: emergency')).toBe('emergency');
});
it('drops Co-Authored-By trailers', () => {
expect(strip('feat: x\nbody\nCo-Authored-By: Claude <x@y>')).toContain('body');
expect(strip('feat: x\nbody\nCo-Authored-By: Claude <x@y>')).not.toContain('Co-Authored');
});
});

describe('bucketCommits + renderMarkdown', () => {
const commits = [
{ hash: 'aaa1111', subject: 'feat: ship A', body: '' },
{ hash: 'bbb2222', subject: 'fix(core): B', body: '' },
{ hash: 'ccc3333', subject: 'chore(ci): C', body: '' },
{ hash: 'ddd4444', subject: 'random commit', body: '' },
];

it('groups commits by type', () => {
const b = bucketCommits(commits);
expect(b.feat!.commits).toHaveLength(1);
expect(b.fix!.commits).toHaveLength(1);
expect(b.chore!.commits).toHaveLength(1);
expect(b.other!.commits).toHaveLength(1);
});

it('renders markdown with stripped subjects + short hashes', () => {
const md = renderMarkdown('v0', 'v1', bucketCommits(commits));
expect(md).toContain('# Release notes (v0…v1)');
expect(md).toContain('## ✨ New');
expect(md).toContain('- ship A (aaa1111)');
expect(md).toContain('## 🐛 Fixes');
expect(md).toContain('- B (bbb2222)');
expect(md).toContain('4 commits');
});

it('omits empty buckets', () => {
const onlyFeat = [{ hash: 'h1', subject: 'feat: x', body: '' }];
const md = renderMarkdown('a', 'b', bucketCommits(onlyFeat));
expect(md).toContain('## ✨ New');
expect(md).not.toContain('## 🐛 Fixes');
expect(md).not.toContain('## 🔧 Chore');
});
});
Loading
Loading