This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@devrunbook/artifacts",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"lint": "eslint src --max-warnings=0",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "24.13.3",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "4.1.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
AgentsSuggestionError,
|
||||
createAgentsSuggestion,
|
||||
} from './agents-suggestion'
|
||||
|
||||
const digest = 'a'.repeat(64)
|
||||
|
||||
function snapshot() {
|
||||
return {
|
||||
contentDigest: digest,
|
||||
profile: {
|
||||
metadata: {
|
||||
name: 'DevRunbook',
|
||||
revision: 4,
|
||||
contentDigest: digest,
|
||||
},
|
||||
spec: {
|
||||
commands: [
|
||||
{
|
||||
role: 'unit-test',
|
||||
command: 'pnpm test',
|
||||
workingDirectory: '.',
|
||||
platform: 'any',
|
||||
shell: 'auto',
|
||||
confirmed: true,
|
||||
safeForAgentSuggestion: true,
|
||||
},
|
||||
{
|
||||
role: 'build',
|
||||
command: 'unconfirmed --must-not-leak',
|
||||
workingDirectory: '.',
|
||||
confirmed: false,
|
||||
safeForAgentSuggestion: true,
|
||||
},
|
||||
],
|
||||
paths: {
|
||||
protected: ['runtime/secrets'],
|
||||
excluded: ['node_modules'],
|
||||
},
|
||||
policies: {
|
||||
preserveBackwardCompatibility: true,
|
||||
newDependencies: 'justify',
|
||||
gitWrite: 'none',
|
||||
migrations: 'reversible-only',
|
||||
documentationRequired: true,
|
||||
networkAccess: 'forbidden',
|
||||
productionDataAccess: 'forbidden',
|
||||
environmentConstraints: ['Use Node.js 24'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('createAgentsSuggestion', () => {
|
||||
it('renders only durable confirmed repository guidance as review-only text', () => {
|
||||
const result = createAgentsSuggestion({
|
||||
id: '4a36b398-5692-4da0-b915-78395579ca68',
|
||||
renderDigest: digest,
|
||||
repositoryProfileSnapshot: snapshot(),
|
||||
})
|
||||
const text = new TextDecoder().decode(result.content)
|
||||
|
||||
expect(result.filename).toBe('AGENTS.md.suggested')
|
||||
expect(text).toContain('Review-only export')
|
||||
expect(text).toContain('pnpm test')
|
||||
expect(text).toContain('Protected: `runtime/secrets`')
|
||||
expect(text).toContain('Git writes: none')
|
||||
expect(text).toContain('Use Node.js 24')
|
||||
expect(text).not.toContain('unconfirmed --must-not-leak')
|
||||
expect(text).not.toContain('normalizedInput')
|
||||
})
|
||||
|
||||
it('fails explicitly without an integrity-bound frozen profile', () => {
|
||||
expect(() =>
|
||||
createAgentsSuggestion({
|
||||
id: '4a36b398-5692-4da0-b915-78395579ca68',
|
||||
renderDigest: digest,
|
||||
repositoryProfileSnapshot: null,
|
||||
}),
|
||||
).toThrowError(AgentsSuggestionError)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,203 @@
|
||||
export interface AgentsSuggestionRun {
|
||||
readonly id: string
|
||||
readonly renderDigest: string
|
||||
readonly repositoryProfileSnapshot: unknown
|
||||
}
|
||||
|
||||
export interface AgentsSuggestion {
|
||||
readonly filename: 'AGENTS.md.suggested'
|
||||
readonly content: Uint8Array
|
||||
}
|
||||
|
||||
export class AgentsSuggestionError extends Error {
|
||||
readonly code = 'agents_suggestion_profile_unavailable'
|
||||
|
||||
constructor() {
|
||||
super(
|
||||
'A frozen repository profile is required for an AGENTS.md recommendation',
|
||||
)
|
||||
this.name = 'AgentsSuggestionError'
|
||||
}
|
||||
}
|
||||
|
||||
type JsonRecord = Readonly<Record<string, unknown>>
|
||||
|
||||
function record(value: unknown): JsonRecord | null {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as JsonRecord)
|
||||
: null
|
||||
}
|
||||
|
||||
function safeText(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null
|
||||
const normalized = value
|
||||
.normalize('NFC')
|
||||
.replace(/\r\n?/gu, '\n')
|
||||
.split('')
|
||||
.filter((character) => {
|
||||
const point = character.codePointAt(0)!
|
||||
return !(
|
||||
point <= 8 ||
|
||||
point === 11 ||
|
||||
point === 12 ||
|
||||
(point >= 14 && point <= 31) ||
|
||||
point === 127 ||
|
||||
(point >= 0x202a && point <= 0x202e) ||
|
||||
(point >= 0x2066 && point <= 0x2069)
|
||||
)
|
||||
})
|
||||
.join('')
|
||||
.trim()
|
||||
return normalized.length > 0 ? normalized : null
|
||||
}
|
||||
|
||||
function strings(value: unknown): readonly string[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.flatMap((item) => {
|
||||
const text = safeText(item)
|
||||
return text === null ? [] : [text]
|
||||
})
|
||||
}
|
||||
|
||||
function indented(value: string): string {
|
||||
return value
|
||||
.split('\n')
|
||||
.map((line) => ` ${line}`)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function profileFromSnapshot(value: unknown): JsonRecord | null {
|
||||
const snapshot = record(value)
|
||||
const profile = record(snapshot?.profile)
|
||||
const metadata = record(profile?.metadata)
|
||||
const spec = record(profile?.spec)
|
||||
if (
|
||||
!snapshot ||
|
||||
!profile ||
|
||||
!metadata ||
|
||||
!spec ||
|
||||
safeText(snapshot.contentDigest) === null ||
|
||||
safeText(metadata.contentDigest) === null
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
function commandSection(spec: JsonRecord): string {
|
||||
if (!Array.isArray(spec.commands))
|
||||
return 'No confirmed agent-safe commands were captured.'
|
||||
const commands = spec.commands.flatMap((candidate) => {
|
||||
const command = record(candidate)
|
||||
const value = safeText(command?.command)
|
||||
if (
|
||||
!command ||
|
||||
value === null ||
|
||||
command.confirmed !== true ||
|
||||
command.safeForAgentSuggestion !== true
|
||||
) {
|
||||
return []
|
||||
}
|
||||
const role = safeText(command.role) ?? 'validation'
|
||||
const directory = safeText(command.workingDirectory) ?? '.'
|
||||
const platform = safeText(command.platform) ?? 'any'
|
||||
const shell = safeText(command.shell) ?? 'auto'
|
||||
return [
|
||||
`- ${role} — working directory: \`${directory.replace(/`/gu, '')}\`; platform: ${platform}; shell: ${shell}\n\n${indented(value)}`,
|
||||
]
|
||||
})
|
||||
return commands.length > 0
|
||||
? commands.join('\n\n')
|
||||
: 'No confirmed agent-safe commands were captured.'
|
||||
}
|
||||
|
||||
function pathSection(spec: JsonRecord): string {
|
||||
const paths = record(spec.paths)
|
||||
const protectedPaths = strings(paths?.protected)
|
||||
const excludedPaths = strings(paths?.excluded)
|
||||
const lines = [
|
||||
...protectedPaths.map(
|
||||
(path) => `- Protected: \`${path.replace(/`/gu, '')}\``,
|
||||
),
|
||||
...excludedPaths.map(
|
||||
(path) => `- Excluded: \`${path.replace(/`/gu, '')}\``,
|
||||
),
|
||||
]
|
||||
return lines.length > 0
|
||||
? lines.join('\n')
|
||||
: 'No protected or excluded paths were captured.'
|
||||
}
|
||||
|
||||
function policySection(spec: JsonRecord): string {
|
||||
const policies = record(spec.policies)
|
||||
if (!policies) return 'No durable repository policies were captured.'
|
||||
const labels: Readonly<Record<string, string>> = {
|
||||
preserveBackwardCompatibility: 'Preserve backward compatibility',
|
||||
newDependencies: 'New dependencies',
|
||||
gitWrite: 'Git writes',
|
||||
migrations: 'Migrations',
|
||||
documentationRequired: 'Documentation required',
|
||||
networkAccess: 'Network access',
|
||||
productionDataAccess: 'Production data access',
|
||||
}
|
||||
const lines = Object.entries(labels).flatMap(([key, label]) => {
|
||||
const value = policies[key]
|
||||
if (typeof value !== 'string' && typeof value !== 'boolean') return []
|
||||
return [`- ${label}: ${String(value)}`]
|
||||
})
|
||||
const constraints = strings(policies.environmentConstraints)
|
||||
lines.push(
|
||||
...constraints.map((value) => `- Environment constraint: ${value}`),
|
||||
)
|
||||
return lines.length > 0
|
||||
? lines.join('\n')
|
||||
: 'No durable repository policies were captured.'
|
||||
}
|
||||
|
||||
export function createAgentsSuggestion(
|
||||
run: AgentsSuggestionRun,
|
||||
): AgentsSuggestion {
|
||||
const profile = profileFromSnapshot(run.repositoryProfileSnapshot)
|
||||
if (!profile) throw new AgentsSuggestionError()
|
||||
const metadata = record(profile.metadata)!
|
||||
const spec = record(profile.spec)!
|
||||
const name = safeText(metadata.name) ?? 'repository'
|
||||
const revision = Number.isSafeInteger(metadata.revision)
|
||||
? String(metadata.revision)
|
||||
: 'unknown'
|
||||
const content = [
|
||||
'# Suggested repository instructions',
|
||||
'',
|
||||
'> Review-only export. Inspect and merge these durable rules manually. DevRunbook does not write or overwrite `AGENTS.md`.',
|
||||
'',
|
||||
'## Scope',
|
||||
'',
|
||||
`These rules apply at the repository root for ${name}. Profile revision: ${revision}. No global or directory-specific instructions are proposed by this export.`,
|
||||
'',
|
||||
'## Confirmed commands',
|
||||
'',
|
||||
commandSection(spec),
|
||||
'',
|
||||
'## Protected and excluded paths',
|
||||
'',
|
||||
pathSection(spec),
|
||||
'',
|
||||
'## Repository policies',
|
||||
'',
|
||||
policySection(spec),
|
||||
'',
|
||||
'## Review checklist',
|
||||
'',
|
||||
'- Confirm each command and working directory still matches the repository.',
|
||||
'- Keep secrets, credentials and one-time task requirements out of persistent instructions.',
|
||||
'- Place narrower rules in a nested `AGENTS.md` only after reviewing their directory scope.',
|
||||
'- Resolve conflicts with existing instruction files before adopting this suggestion.',
|
||||
'',
|
||||
`Evidence: immutable DevRunbook run \`${run.id.replace(/`/gu, '')}\`, prompt digest \`${run.renderDigest.replace(/`/gu, '')}\`.`,
|
||||
'',
|
||||
].join('\n')
|
||||
return Object.freeze({
|
||||
filename: 'AGENTS.md.suggested',
|
||||
content: new TextEncoder().encode(content),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface ArtifactDescriptor {
|
||||
storageKey: string
|
||||
filename: string
|
||||
mediaType: string
|
||||
sizeBytes: number
|
||||
sha256: string
|
||||
}
|
||||
|
||||
export * from './local-artifact-storage'
|
||||
export * from './agents-suggestion'
|
||||
export * from './run-pack'
|
||||
export * from './playbook-package-archive'
|
||||
@@ -0,0 +1,86 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { LocalArtifactStorage } from './local-artifact-storage'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
roots.splice(0).map((root) => rm(root, { recursive: true })),
|
||||
)
|
||||
})
|
||||
|
||||
async function root(): Promise<string> {
|
||||
const value = await mkdtemp(path.join(tmpdir(), 'devrunbook-artifacts-'))
|
||||
roots.push(value)
|
||||
return value
|
||||
}
|
||||
|
||||
function digest(content: Uint8Array): string {
|
||||
return createHash('sha256').update(content).digest('hex')
|
||||
}
|
||||
|
||||
describe('LocalArtifactStorage', () => {
|
||||
it('persists immutable bytes that a restarted adapter can read', async () => {
|
||||
const artifactRoot = await root()
|
||||
const content = new TextEncoder().encode('# deterministic\n')
|
||||
const key = 'a'.repeat(64)
|
||||
const first = new LocalArtifactStorage(artifactRoot)
|
||||
|
||||
await expect(
|
||||
first.putImmutable(key, content, digest(content)),
|
||||
).resolves.toEqual({ created: true })
|
||||
await expect(
|
||||
first.putImmutable(key, content, digest(content)),
|
||||
).resolves.toEqual({ created: false })
|
||||
|
||||
const restarted = new LocalArtifactStorage(artifactRoot)
|
||||
await expect(restarted.read(key)).resolves.toEqual(content)
|
||||
})
|
||||
|
||||
it('rejects traversal-shaped keys and relative roots', async () => {
|
||||
expect(() => new LocalArtifactStorage('relative/artifacts')).toThrowError(
|
||||
expect.objectContaining({ code: 'artifact_storage_root_invalid' }),
|
||||
)
|
||||
const storage = new LocalArtifactStorage(await root())
|
||||
await expect(storage.read('../outside')).rejects.toMatchObject({
|
||||
code: 'artifact_storage_key_invalid',
|
||||
})
|
||||
await expect(storage.read('A'.repeat(64))).rejects.toMatchObject({
|
||||
code: 'artifact_storage_key_invalid',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a digest mismatch and conflicting on-disk bytes', async () => {
|
||||
const artifactRoot = await root()
|
||||
const storage = new LocalArtifactStorage(artifactRoot)
|
||||
const key = 'b'.repeat(64)
|
||||
const content = new TextEncoder().encode('expected')
|
||||
await expect(
|
||||
storage.putImmutable(key, content, 'c'.repeat(64)),
|
||||
).rejects.toMatchObject({ code: 'artifact_storage_integrity_failed' })
|
||||
|
||||
const different = new TextEncoder().encode('different')
|
||||
await storage.putImmutable(key, different, digest(different))
|
||||
await expect(
|
||||
storage.putImmutable(key, content, digest(content)),
|
||||
).rejects.toMatchObject({ code: 'artifact_storage_conflict' })
|
||||
})
|
||||
|
||||
it('deletes only validated storage keys and treats missing bytes idempotently', async () => {
|
||||
const storage = new LocalArtifactStorage(await root())
|
||||
const content = new TextEncoder().encode('expired')
|
||||
const key = digest(content)
|
||||
await storage.putImmutable(key, content, key)
|
||||
|
||||
await expect(storage.deleteIfPresent(key)).resolves.toBe(true)
|
||||
await expect(storage.deleteIfPresent(key)).resolves.toBe(false)
|
||||
await expect(storage.deleteIfPresent('../outside')).rejects.toMatchObject({
|
||||
code: 'artifact_storage_key_invalid',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,155 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdir, open, readFile, unlink } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
const storageKeyPattern = /^[0-9a-f]{64}$/
|
||||
|
||||
export class ArtifactStorageError extends Error {
|
||||
constructor(
|
||||
readonly code:
|
||||
| 'artifact_storage_root_invalid'
|
||||
| 'artifact_storage_key_invalid'
|
||||
| 'artifact_storage_conflict'
|
||||
| 'artifact_storage_not_found'
|
||||
| 'artifact_storage_integrity_failed',
|
||||
message: string,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ArtifactStorageError'
|
||||
}
|
||||
}
|
||||
|
||||
function sha256(content: Uint8Array): string {
|
||||
return createHash('sha256').update(content).digest('hex')
|
||||
}
|
||||
|
||||
function assertDigest(digest: string): void {
|
||||
if (!storageKeyPattern.test(digest)) {
|
||||
throw new ArtifactStorageError(
|
||||
'artifact_storage_integrity_failed',
|
||||
'Artifact digest must be a lowercase SHA-256 value',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Local immutable byte storage. Storage keys are opaque SHA-256-shaped values;
|
||||
* no caller-controlled filename or path segment reaches the filesystem.
|
||||
*/
|
||||
export class LocalArtifactStorage {
|
||||
readonly root: string
|
||||
|
||||
constructor(artifactRoot: string) {
|
||||
if (!path.isAbsolute(artifactRoot)) {
|
||||
throw new ArtifactStorageError(
|
||||
'artifact_storage_root_invalid',
|
||||
'ARTIFACT_ROOT must be an absolute path',
|
||||
)
|
||||
}
|
||||
this.root = path.resolve(artifactRoot)
|
||||
}
|
||||
|
||||
private resolveKey(storageKey: string): string {
|
||||
if (!storageKeyPattern.test(storageKey)) {
|
||||
throw new ArtifactStorageError(
|
||||
'artifact_storage_key_invalid',
|
||||
'Artifact storage key is invalid',
|
||||
)
|
||||
}
|
||||
const target = path.resolve(
|
||||
this.root,
|
||||
storageKey.slice(0, 2),
|
||||
storageKey.slice(2),
|
||||
)
|
||||
const relative = path.relative(this.root, target)
|
||||
if (
|
||||
relative.length === 0 ||
|
||||
relative.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relative)
|
||||
) {
|
||||
throw new ArtifactStorageError(
|
||||
'artifact_storage_key_invalid',
|
||||
'Artifact storage key escapes ARTIFACT_ROOT',
|
||||
)
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
async putImmutable(
|
||||
storageKey: string,
|
||||
content: Uint8Array,
|
||||
expectedSha256: string,
|
||||
): Promise<{ readonly created: boolean }> {
|
||||
assertDigest(expectedSha256)
|
||||
if (sha256(content) !== expectedSha256) {
|
||||
throw new ArtifactStorageError(
|
||||
'artifact_storage_integrity_failed',
|
||||
'Artifact bytes do not match their declared digest',
|
||||
)
|
||||
}
|
||||
const target = this.resolveKey(storageKey)
|
||||
await mkdir(path.dirname(target), { recursive: true, mode: 0o700 })
|
||||
let file
|
||||
try {
|
||||
file = await open(target, 'wx', 0o600)
|
||||
await file.writeFile(content)
|
||||
await file.sync()
|
||||
return Object.freeze({ created: true })
|
||||
} catch (error) {
|
||||
if (!isAlreadyExists(error)) throw error
|
||||
const existing = await readFile(target)
|
||||
if (
|
||||
existing.byteLength !== content.byteLength ||
|
||||
sha256(existing) !== expectedSha256
|
||||
) {
|
||||
throw new ArtifactStorageError(
|
||||
'artifact_storage_conflict',
|
||||
'Artifact storage key already contains different immutable bytes',
|
||||
)
|
||||
}
|
||||
return Object.freeze({ created: false })
|
||||
} finally {
|
||||
await file?.close()
|
||||
}
|
||||
}
|
||||
|
||||
async read(storageKey: string): Promise<Uint8Array> {
|
||||
const target = this.resolveKey(storageKey)
|
||||
try {
|
||||
return new Uint8Array(await readFile(target))
|
||||
} catch (error) {
|
||||
if (isNotFound(error)) {
|
||||
throw new ArtifactStorageError(
|
||||
'artifact_storage_not_found',
|
||||
'Artifact bytes were not found',
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async deleteIfPresent(storageKey: string): Promise<boolean> {
|
||||
const target = this.resolveKey(storageKey)
|
||||
try {
|
||||
await unlink(target)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (isNotFound(error)) return false
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function errorCode(error: unknown): string | undefined {
|
||||
return error && typeof error === 'object' && 'code' in error
|
||||
? String(error.code)
|
||||
: undefined
|
||||
}
|
||||
|
||||
function isAlreadyExists(error: unknown): boolean {
|
||||
return errorCode(error) === 'EEXIST'
|
||||
}
|
||||
|
||||
function isNotFound(error: unknown): boolean {
|
||||
return errorCode(error) === 'ENOENT'
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
encodeDeterministicZip,
|
||||
exportPlaybookPackageArchive,
|
||||
importPlaybookPackageArchive,
|
||||
type PlaybookPackageArchiveFile,
|
||||
} from './index'
|
||||
|
||||
const manifest = Buffer.from(
|
||||
'apiVersion: devrunbook.io/v1alpha1\nkind: Playbook\n',
|
||||
)
|
||||
const prompt = Uint8Array.from([
|
||||
0x23, 0x20, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x0a,
|
||||
])
|
||||
|
||||
function packageFiles(): PlaybookPackageArchiveFile[] {
|
||||
return [
|
||||
{ path: 'prompt.md', role: 'template', content: prompt },
|
||||
{ path: 'playbook.yaml', role: 'manifest', content: manifest },
|
||||
]
|
||||
}
|
||||
|
||||
interface LocatedEntry {
|
||||
readonly centralOffset: number
|
||||
readonly localOffset: number
|
||||
readonly dataOffset: number
|
||||
readonly size: number
|
||||
readonly path: string
|
||||
}
|
||||
|
||||
function locateEntries(bytes: Uint8Array): LocatedEntry[] {
|
||||
const buffer = Buffer.from(bytes)
|
||||
const endOffset = buffer.byteLength - 22
|
||||
const count = buffer.readUInt16LE(endOffset + 10)
|
||||
let cursor = buffer.readUInt32LE(endOffset + 16)
|
||||
const result: LocatedEntry[] = []
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const nameLength = buffer.readUInt16LE(cursor + 28)
|
||||
const extraLength = buffer.readUInt16LE(cursor + 30)
|
||||
const commentLength = buffer.readUInt16LE(cursor + 32)
|
||||
const localOffset = buffer.readUInt32LE(cursor + 42)
|
||||
const localNameLength = buffer.readUInt16LE(localOffset + 26)
|
||||
const localExtraLength = buffer.readUInt16LE(localOffset + 28)
|
||||
result.push({
|
||||
centralOffset: cursor,
|
||||
localOffset,
|
||||
dataOffset: localOffset + 30 + localNameLength + localExtraLength,
|
||||
size: buffer.readUInt32LE(cursor + 24),
|
||||
path: buffer
|
||||
.subarray(cursor + 46, cursor + 46 + nameLength)
|
||||
.toString('utf8'),
|
||||
})
|
||||
cursor += 46 + nameLength + extraLength + commentLength
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function errorCode(action: () => unknown): string | undefined {
|
||||
try {
|
||||
action()
|
||||
} catch (error) {
|
||||
return (error as { code?: string }).code
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
describe('playbook package archive codec', () => {
|
||||
it('exports deterministic bytes and imports exact in-memory contents', () => {
|
||||
const first = exportPlaybookPackageArchive(packageFiles())
|
||||
const second = exportPlaybookPackageArchive([...packageFiles()].reverse())
|
||||
expect(first.bytes).toEqual(second.bytes)
|
||||
expect(first.sha256).toBe(
|
||||
createHash('sha256').update(first.bytes).digest('hex'),
|
||||
)
|
||||
|
||||
const imported = importPlaybookPackageArchive(first.bytes)
|
||||
expect(imported.files.map((file) => file.path)).toEqual([
|
||||
'playbook.yaml',
|
||||
'prompt.md',
|
||||
])
|
||||
expect(Buffer.from(imported.files[0]!.content)).toEqual(manifest)
|
||||
expect(imported.files[1]!.content).toEqual(prompt)
|
||||
|
||||
first.bytes.fill(0)
|
||||
expect(Buffer.from(first.files[0]!.content)).toEqual(manifest)
|
||||
expect(first.files[1]!.content).toEqual(prompt)
|
||||
})
|
||||
|
||||
it('requires exactly one canonical file with the reserved manifest role', () => {
|
||||
expect(() =>
|
||||
exportPlaybookPackageArchive([
|
||||
{ path: 'playbook.yaml', role: 'template', content: manifest },
|
||||
]),
|
||||
).toThrowError(
|
||||
expect.objectContaining({ code: 'playbook_archive_input_invalid' }),
|
||||
)
|
||||
expect(() =>
|
||||
exportPlaybookPackageArchive([
|
||||
{ path: 'other.yaml', role: 'manifest', content: manifest },
|
||||
]),
|
||||
).toThrowError(
|
||||
expect.objectContaining({ code: 'playbook_archive_input_invalid' }),
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['traversal', '../evil.yaml'],
|
||||
['absolute', '/evil.yaml'],
|
||||
['backslash', 'bad\\name.yaml'],
|
||||
])('rejects %s paths on import', (_label, path) => {
|
||||
const hostile = encodeDeterministicZip([
|
||||
{ path: 'playbook.yaml', bytes: manifest },
|
||||
{ path, bytes: prompt },
|
||||
])
|
||||
expect(errorCode(() => importPlaybookPackageArchive(hostile))).toBe(
|
||||
'playbook_archive_path_unsafe',
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects duplicate and portable case-colliding paths', () => {
|
||||
const duplicate = encodeDeterministicZip([
|
||||
{ path: 'playbook.yaml', bytes: manifest },
|
||||
{ path: 'playbook.yaml', bytes: manifest },
|
||||
])
|
||||
const collision = encodeDeterministicZip([
|
||||
{ path: 'playbook.yaml', bytes: manifest },
|
||||
{ path: 'PLAYBOOK.YAML', bytes: manifest },
|
||||
])
|
||||
for (const archive of [duplicate, collision]) {
|
||||
expect(errorCode(() => importPlaybookPackageArchive(archive))).toBe(
|
||||
'playbook_archive_invalid',
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects symlinks, encryption, data descriptors, and compression methods outside store/deflate', () => {
|
||||
const created = exportPlaybookPackageArchive(packageFiles()).bytes
|
||||
const mutations: Buffer[] = []
|
||||
|
||||
const symlink = Buffer.from(created)
|
||||
const symlinkEntry = locateEntries(symlink)[0]!
|
||||
symlink.writeUInt32LE(
|
||||
(0o120777 * 65_536) >>> 0,
|
||||
symlinkEntry.centralOffset + 38,
|
||||
)
|
||||
mutations.push(symlink)
|
||||
|
||||
for (const flag of [0x0001, 0x0008]) {
|
||||
const hostile = Buffer.from(created)
|
||||
const entry = locateEntries(hostile)[0]!
|
||||
hostile.writeUInt16LE(0x0800 | flag, entry.localOffset + 6)
|
||||
hostile.writeUInt16LE(0x0800 | flag, entry.centralOffset + 8)
|
||||
mutations.push(hostile)
|
||||
}
|
||||
|
||||
const unsupported = Buffer.from(created)
|
||||
const unsupportedEntry = locateEntries(unsupported)[0]!
|
||||
unsupported.writeUInt16LE(99, unsupportedEntry.localOffset + 8)
|
||||
unsupported.writeUInt16LE(99, unsupportedEntry.centralOffset + 10)
|
||||
mutations.push(unsupported)
|
||||
|
||||
for (const archive of mutations) {
|
||||
expect(errorCode(() => importPlaybookPackageArchive(archive))).toBe(
|
||||
'playbook_archive_invalid',
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects aliased or overlapping local data regions', () => {
|
||||
const aliased = Buffer.from(
|
||||
exportPlaybookPackageArchive(packageFiles()).bytes,
|
||||
)
|
||||
const aliasedEntries = locateEntries(aliased)
|
||||
aliased.writeUInt32LE(
|
||||
aliasedEntries[0]!.localOffset,
|
||||
aliasedEntries[1]!.centralOffset + 42,
|
||||
)
|
||||
expect(errorCode(() => importPlaybookPackageArchive(aliased))).toBe(
|
||||
'playbook_archive_invalid',
|
||||
)
|
||||
|
||||
const embeddedHeader = Buffer.alloc(80)
|
||||
embeddedHeader.writeUInt32LE(0x04034b50, 0)
|
||||
const overlapping = Buffer.from(
|
||||
encodeDeterministicZip([
|
||||
{ path: 'playbook.yaml', bytes: embeddedHeader },
|
||||
{ path: 'prompt.md', bytes: prompt },
|
||||
]),
|
||||
)
|
||||
const overlappingEntries = locateEntries(overlapping)
|
||||
overlapping.writeUInt32LE(
|
||||
overlappingEntries[0]!.dataOffset,
|
||||
overlappingEntries[1]!.centralOffset + 42,
|
||||
)
|
||||
expect(errorCode(() => importPlaybookPackageArchive(overlapping))).toBe(
|
||||
'playbook_archive_invalid',
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects CRC corruption and compressed, expanded, file, and count limit violations', () => {
|
||||
const created = exportPlaybookPackageArchive(packageFiles()).bytes
|
||||
const corrupted = Buffer.from(created)
|
||||
const corruptAt = locateEntries(corrupted)[0]!.dataOffset
|
||||
corrupted[corruptAt] = corrupted[corruptAt]! ^ 0xff
|
||||
expect(errorCode(() => importPlaybookPackageArchive(corrupted))).toBe(
|
||||
'playbook_archive_invalid',
|
||||
)
|
||||
|
||||
const cases = [
|
||||
{ maxArchiveBytes: created.byteLength - 1 },
|
||||
{ maxExpandedBytes: 4 },
|
||||
{ maxFileBytes: 4 },
|
||||
{ maxFiles: 1 },
|
||||
]
|
||||
for (const limits of cases) {
|
||||
expect(
|
||||
errorCode(() => importPlaybookPackageArchive(created, limits)),
|
||||
).toBe('playbook_archive_limit')
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects declared expansion bombs before allocating their output', () => {
|
||||
const hostile = Buffer.from(
|
||||
exportPlaybookPackageArchive(packageFiles()).bytes,
|
||||
)
|
||||
const entry = locateEntries(hostile)[0]!
|
||||
hostile.writeUInt32LE(2 * 1024 * 1024, entry.localOffset + 22)
|
||||
hostile.writeUInt32LE(2 * 1024 * 1024, entry.centralOffset + 24)
|
||||
expect(errorCode(() => importPlaybookPackageArchive(hostile))).toBe(
|
||||
'playbook_archive_limit',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,296 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
import {
|
||||
encodeDeterministicZip,
|
||||
readBoundedZip,
|
||||
RunPackError,
|
||||
} from './run-pack'
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
const safePathPattern = /^[A-Za-z0-9._/-]+$/u
|
||||
const windowsReservedName = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/iu
|
||||
|
||||
/**
|
||||
* These values intentionally mirror `@devrunbook/content` package validation
|
||||
* limits without introducing an artifacts -> content domain dependency.
|
||||
*/
|
||||
export const defaultPlaybookPackageArchiveLimits = Object.freeze({
|
||||
maxArchiveBytes: 11 * 1024 * 1024,
|
||||
maxExpandedBytes: 10 * 1024 * 1024,
|
||||
maxFiles: 201,
|
||||
maxFileBytes: 1024 * 1024,
|
||||
})
|
||||
|
||||
export interface PlaybookPackageArchiveLimits {
|
||||
readonly maxArchiveBytes: number
|
||||
readonly maxExpandedBytes: number
|
||||
readonly maxFiles: number
|
||||
readonly maxFileBytes: number
|
||||
}
|
||||
|
||||
export interface PlaybookPackageArchiveFile {
|
||||
readonly path: string
|
||||
readonly role: string
|
||||
readonly content: string | Uint8Array
|
||||
}
|
||||
|
||||
export interface ImportedPlaybookPackageFile {
|
||||
readonly path: string
|
||||
readonly content: Uint8Array
|
||||
}
|
||||
|
||||
export interface ExportedPlaybookPackageArchive {
|
||||
readonly bytes: Uint8Array
|
||||
readonly sha256: string
|
||||
readonly files: readonly ImportedPlaybookPackageFile[]
|
||||
}
|
||||
|
||||
export interface ImportedPlaybookPackageArchive {
|
||||
readonly files: readonly ImportedPlaybookPackageFile[]
|
||||
readonly sha256: string
|
||||
}
|
||||
|
||||
export type PlaybookPackageArchiveErrorCode =
|
||||
| 'playbook_archive_input_invalid'
|
||||
| 'playbook_archive_limit'
|
||||
| 'playbook_archive_path_unsafe'
|
||||
| 'playbook_archive_invalid'
|
||||
|
||||
export class PlaybookPackageArchiveError extends Error {
|
||||
constructor(
|
||||
readonly code: PlaybookPackageArchiveErrorCode,
|
||||
readonly path: string,
|
||||
message: string,
|
||||
) {
|
||||
super(`${path}: ${message}`)
|
||||
this.name = 'PlaybookPackageArchiveError'
|
||||
}
|
||||
}
|
||||
|
||||
function mergeLimits(
|
||||
overrides?: Partial<PlaybookPackageArchiveLimits>,
|
||||
): PlaybookPackageArchiveLimits {
|
||||
const limits = { ...defaultPlaybookPackageArchiveLimits, ...overrides }
|
||||
for (const [name, value] of Object.entries(limits)) {
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new PlaybookPackageArchiveError(
|
||||
'playbook_archive_input_invalid',
|
||||
`limits.${name}`,
|
||||
'must be a positive safe integer',
|
||||
)
|
||||
}
|
||||
}
|
||||
return limits
|
||||
}
|
||||
|
||||
function assertSafePath(candidate: string, label: string): void {
|
||||
if (
|
||||
candidate.length === 0 ||
|
||||
candidate.length > 500 ||
|
||||
!safePathPattern.test(candidate) ||
|
||||
candidate.startsWith('/') ||
|
||||
candidate.endsWith('/') ||
|
||||
candidate.includes('//') ||
|
||||
candidate.includes('\\') ||
|
||||
candidate.includes('\0')
|
||||
) {
|
||||
throw new PlaybookPackageArchiveError(
|
||||
'playbook_archive_path_unsafe',
|
||||
label,
|
||||
'must be a normalized relative ASCII file path',
|
||||
)
|
||||
}
|
||||
for (const segment of candidate.split('/')) {
|
||||
if (
|
||||
segment === '.' ||
|
||||
segment === '..' ||
|
||||
segment.endsWith('.') ||
|
||||
segment.endsWith(' ') ||
|
||||
windowsReservedName.test(segment)
|
||||
) {
|
||||
throw new PlaybookPackageArchiveError(
|
||||
'playbook_archive_path_unsafe',
|
||||
label,
|
||||
`contains unsafe path segment ${JSON.stringify(segment)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function bytesFor(content: string | Uint8Array, path: string): Uint8Array {
|
||||
if (typeof content === 'string') return encoder.encode(content)
|
||||
if (!(content instanceof Uint8Array)) {
|
||||
throw new PlaybookPackageArchiveError(
|
||||
'playbook_archive_input_invalid',
|
||||
path,
|
||||
'content must be a string or Uint8Array',
|
||||
)
|
||||
}
|
||||
return content.slice()
|
||||
}
|
||||
|
||||
function compareUtf8(left: string, right: string): number {
|
||||
return Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8'))
|
||||
}
|
||||
|
||||
function archiveDigest(bytes: Uint8Array): string {
|
||||
return createHash('sha256').update(bytes).digest('hex')
|
||||
}
|
||||
|
||||
function mapZipError(error: unknown): never {
|
||||
if (!(error instanceof RunPackError)) throw error
|
||||
const code: PlaybookPackageArchiveErrorCode =
|
||||
error.code === 'run_pack_archive_limit'
|
||||
? 'playbook_archive_limit'
|
||||
: error.code === 'run_pack_path_unsafe'
|
||||
? 'playbook_archive_path_unsafe'
|
||||
: 'playbook_archive_invalid'
|
||||
const prefix = `${error.path}: `
|
||||
const message = error.message.startsWith(prefix)
|
||||
? error.message.slice(prefix.length)
|
||||
: error.message
|
||||
throw new PlaybookPackageArchiveError(code, error.path, message)
|
||||
}
|
||||
|
||||
function validateInventory(
|
||||
files: readonly PlaybookPackageArchiveFile[],
|
||||
limits: PlaybookPackageArchiveLimits,
|
||||
): ImportedPlaybookPackageFile[] {
|
||||
if (files.length === 0 || files.length > limits.maxFiles) {
|
||||
throw new PlaybookPackageArchiveError(
|
||||
'playbook_archive_limit',
|
||||
'files',
|
||||
`must contain between 1 and ${limits.maxFiles} files`,
|
||||
)
|
||||
}
|
||||
const seen = new Set<string>()
|
||||
const result: ImportedPlaybookPackageFile[] = []
|
||||
let totalBytes = 0
|
||||
let manifestCount = 0
|
||||
for (const [index, file] of files.entries()) {
|
||||
const label = `files[${index}]`
|
||||
assertSafePath(file.path, `${label}.path`)
|
||||
const collisionKey = file.path.normalize('NFC').toLowerCase()
|
||||
if (seen.has(collisionKey)) {
|
||||
throw new PlaybookPackageArchiveError(
|
||||
'playbook_archive_input_invalid',
|
||||
`${label}.path`,
|
||||
'duplicates or case-collides with another package path',
|
||||
)
|
||||
}
|
||||
seen.add(collisionKey)
|
||||
if (
|
||||
typeof file.role !== 'string' ||
|
||||
!/^[a-z][a-z0-9-]{0,63}$/u.test(file.role)
|
||||
) {
|
||||
throw new PlaybookPackageArchiveError(
|
||||
'playbook_archive_input_invalid',
|
||||
`${label}.role`,
|
||||
'must be a normalized package role',
|
||||
)
|
||||
}
|
||||
if (file.path === 'playbook.yaml') {
|
||||
manifestCount += 1
|
||||
if (file.role !== 'manifest') {
|
||||
throw new PlaybookPackageArchiveError(
|
||||
'playbook_archive_input_invalid',
|
||||
`${label}.role`,
|
||||
'playbook.yaml must use the reserved manifest role',
|
||||
)
|
||||
}
|
||||
} else if (file.role === 'manifest') {
|
||||
throw new PlaybookPackageArchiveError(
|
||||
'playbook_archive_input_invalid',
|
||||
`${label}.role`,
|
||||
'the manifest role is reserved for playbook.yaml',
|
||||
)
|
||||
}
|
||||
const content = bytesFor(file.content, `${label}.content`)
|
||||
if (content.byteLength > limits.maxFileBytes) {
|
||||
throw new PlaybookPackageArchiveError(
|
||||
'playbook_archive_limit',
|
||||
file.path,
|
||||
'single-file expanded limit exceeded',
|
||||
)
|
||||
}
|
||||
totalBytes += content.byteLength
|
||||
if (totalBytes > limits.maxExpandedBytes) {
|
||||
throw new PlaybookPackageArchiveError(
|
||||
'playbook_archive_limit',
|
||||
'files',
|
||||
'expanded package limit exceeded',
|
||||
)
|
||||
}
|
||||
result.push({ path: file.path, content })
|
||||
}
|
||||
if (manifestCount !== 1) {
|
||||
throw new PlaybookPackageArchiveError(
|
||||
'playbook_archive_input_invalid',
|
||||
'playbook.yaml',
|
||||
'exactly one manifest file is required',
|
||||
)
|
||||
}
|
||||
return result.sort((left, right) => compareUtf8(left.path, right.path))
|
||||
}
|
||||
|
||||
export function exportPlaybookPackageArchive(
|
||||
files: readonly PlaybookPackageArchiveFile[],
|
||||
limitOverrides?: Partial<PlaybookPackageArchiveLimits>,
|
||||
): ExportedPlaybookPackageArchive {
|
||||
const limits = mergeLimits(limitOverrides)
|
||||
const inventory = validateInventory(files, limits)
|
||||
const bytes = encodeDeterministicZip(
|
||||
inventory.map((file) => ({ path: file.path, bytes: file.content })),
|
||||
)
|
||||
if (bytes.byteLength > limits.maxArchiveBytes) {
|
||||
throw new PlaybookPackageArchiveError(
|
||||
'playbook_archive_limit',
|
||||
'archive',
|
||||
'compressed archive limit exceeded',
|
||||
)
|
||||
}
|
||||
return {
|
||||
bytes,
|
||||
sha256: archiveDigest(bytes),
|
||||
files: inventory.map((file) => ({
|
||||
path: file.path,
|
||||
content: file.content.slice(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function importPlaybookPackageArchive(
|
||||
archive: Uint8Array,
|
||||
limitOverrides?: Partial<PlaybookPackageArchiveLimits>,
|
||||
): ImportedPlaybookPackageArchive {
|
||||
if (!(archive instanceof Uint8Array)) {
|
||||
throw new PlaybookPackageArchiveError(
|
||||
'playbook_archive_input_invalid',
|
||||
'archive',
|
||||
'must be a Uint8Array',
|
||||
)
|
||||
}
|
||||
const limits = mergeLimits(limitOverrides)
|
||||
try {
|
||||
const entries = readBoundedZip(archive, limits)
|
||||
if (!entries.some((entry) => entry.path === 'playbook.yaml')) {
|
||||
throw new PlaybookPackageArchiveError(
|
||||
'playbook_archive_invalid',
|
||||
'playbook.yaml',
|
||||
'required manifest file is missing',
|
||||
)
|
||||
}
|
||||
return {
|
||||
files: entries
|
||||
.sort((left, right) => compareUtf8(left.path, right.path))
|
||||
.map((entry) => ({
|
||||
path: entry.path,
|
||||
content: entry.bytes.slice(),
|
||||
})),
|
||||
sha256: archiveDigest(archive),
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof PlaybookPackageArchiveError) throw error
|
||||
mapZipError(error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
RunPackError,
|
||||
createRunPack,
|
||||
createTaskMarkdown,
|
||||
verifyRunPack,
|
||||
type CreateRunPackInput,
|
||||
} from './run-pack'
|
||||
|
||||
const prompt = '# Mission\n\nImplement the bounded change.\n'
|
||||
|
||||
function hash(value: string | Uint8Array): string {
|
||||
return createHash('sha256').update(value).digest('hex')
|
||||
}
|
||||
|
||||
function input(
|
||||
overrides: Partial<CreateRunPackInput> = {},
|
||||
): CreateRunPackInput {
|
||||
return {
|
||||
slug: 'safe-refactor',
|
||||
run: {
|
||||
id: '019fa0c7-e928-7720-b3f5-3c703b8a7ab6',
|
||||
playbookId: 'engineering.safe-refactor',
|
||||
playbookVersion: '1.2.0',
|
||||
playbookDigest: 'a'.repeat(64),
|
||||
repositoryProfileDigest: 'b'.repeat(64),
|
||||
renderDigest: hash(prompt),
|
||||
generatedAt: '2026-07-27T09:30:00.000Z',
|
||||
platformVersion: '1.0.0',
|
||||
},
|
||||
renderedPrompt: prompt,
|
||||
repositoryContext: '# Repository context\n\nProtected: `infra/`.\n',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
interface LocatedEntry {
|
||||
readonly centralOffset: number
|
||||
readonly localOffset: number
|
||||
readonly dataOffset: number
|
||||
readonly size: number
|
||||
readonly path: string
|
||||
}
|
||||
|
||||
function entries(bytes: Uint8Array): LocatedEntry[] {
|
||||
const buffer = Buffer.from(bytes)
|
||||
const end = buffer.byteLength - 22
|
||||
const count = buffer.readUInt16LE(end + 10)
|
||||
let cursor = buffer.readUInt32LE(end + 16)
|
||||
const result: LocatedEntry[] = []
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const nameLength = buffer.readUInt16LE(cursor + 28)
|
||||
const extraLength = buffer.readUInt16LE(cursor + 30)
|
||||
const commentLength = buffer.readUInt16LE(cursor + 32)
|
||||
const localOffset = buffer.readUInt32LE(cursor + 42)
|
||||
const localNameLength = buffer.readUInt16LE(localOffset + 26)
|
||||
const localExtraLength = buffer.readUInt16LE(localOffset + 28)
|
||||
result.push({
|
||||
centralOffset: cursor,
|
||||
localOffset,
|
||||
dataOffset: localOffset + 30 + localNameLength + localExtraLength,
|
||||
size: buffer.readUInt32LE(cursor + 24),
|
||||
path: buffer
|
||||
.subarray(cursor + 46, cursor + 46 + nameLength)
|
||||
.toString('utf8'),
|
||||
})
|
||||
cursor += 46 + nameLength + extraLength + commentLength
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const crcTable = Uint32Array.from({ length: 256 }, (_, index) => {
|
||||
let value = index
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
value = (value & 1) === 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1
|
||||
}
|
||||
return value >>> 0
|
||||
})
|
||||
|
||||
function crc32(bytes: Uint8Array): number {
|
||||
let crc = 0xffffffff
|
||||
for (const byte of bytes) crc = crcTable[(crc ^ byte) & 0xff]! ^ (crc >>> 8)
|
||||
return (crc ^ 0xffffffff) >>> 0
|
||||
}
|
||||
|
||||
function canonicalJson(value: unknown): string {
|
||||
if (value === null || typeof value !== 'object') return JSON.stringify(value)
|
||||
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`
|
||||
const record = value as Record<string, unknown>
|
||||
return `{${Object.keys(record)
|
||||
.sort()
|
||||
.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
|
||||
.join(',')}}`
|
||||
}
|
||||
|
||||
function replaceEntryPath(
|
||||
archive: Uint8Array,
|
||||
currentRelativePath: string,
|
||||
replacementRelativePath: string,
|
||||
): Uint8Array {
|
||||
expect(Buffer.byteLength(replacementRelativePath)).toBe(
|
||||
Buffer.byteLength(currentRelativePath),
|
||||
)
|
||||
const result = Buffer.from(archive)
|
||||
const located = entries(result).find((entry) =>
|
||||
entry.path.endsWith(`/${currentRelativePath}`),
|
||||
)!
|
||||
const original = Buffer.from(located.path, 'utf8')
|
||||
const replacement = Buffer.from(
|
||||
`${located.path.slice(0, -currentRelativePath.length)}${replacementRelativePath}`,
|
||||
'utf8',
|
||||
)
|
||||
expect(replacement.byteLength).toBe(original.byteLength)
|
||||
replacement.copy(result, located.centralOffset + 46)
|
||||
replacement.copy(result, located.localOffset + 30)
|
||||
return result
|
||||
}
|
||||
|
||||
function replaceStoredEntryContent(
|
||||
archive: Uint8Array,
|
||||
relativePath: string,
|
||||
transform: (content: string) => string,
|
||||
): Uint8Array {
|
||||
const result = Buffer.from(archive)
|
||||
const located = entries(result).find((entry) =>
|
||||
entry.path.endsWith(`/${relativePath}`),
|
||||
)!
|
||||
const current = result
|
||||
.subarray(located.dataOffset, located.dataOffset + located.size)
|
||||
.toString('utf8')
|
||||
const replacement = Buffer.from(transform(current), 'utf8')
|
||||
expect(replacement.byteLength).toBe(located.size)
|
||||
replacement.copy(result, located.dataOffset)
|
||||
const crc = crc32(replacement)
|
||||
result.writeUInt32LE(crc, located.localOffset + 14)
|
||||
result.writeUInt32LE(crc, located.centralOffset + 16)
|
||||
return result
|
||||
}
|
||||
|
||||
describe('deterministic Run Pack generation', () => {
|
||||
it('creates byte-identical archives, sorted inventories, and a canonical manifest digest', () => {
|
||||
const first = createRunPack(input())
|
||||
const second = createRunPack(input())
|
||||
|
||||
expect(first.bytes).toEqual(second.bytes)
|
||||
expect(first.sha256).toBe(second.sha256)
|
||||
expect(first.filename).toBe('DevRunbook-safe-refactor-019fa0c7-e92.zip')
|
||||
expect(first.manifest.files.map((file) => file.path)).toEqual([
|
||||
'HANDOFF_TEMPLATE.md',
|
||||
'REPOSITORY_CONTEXT.md',
|
||||
'RUNBOOK.md',
|
||||
'TASK.md',
|
||||
'VALIDATION.md',
|
||||
])
|
||||
expect(entries(first.bytes).map((entry) => entry.path)).toEqual(
|
||||
[...entries(first.bytes).map((entry) => entry.path)].sort(),
|
||||
)
|
||||
expect(first.taskMarkdown).toContain(first.manifest.run.renderDigest)
|
||||
expect(first.taskMarkdown.endsWith('\n')).toBe(true)
|
||||
expect(first.taskMarkdown).not.toContain('\r')
|
||||
|
||||
const verified = verifyRunPack(first.bytes)
|
||||
expect(verified.rootDirectory).toBe(first.rootDirectory)
|
||||
expect(verified.archiveSha256).toBe(first.sha256)
|
||||
expect(verified.manifest).toEqual(first.manifest)
|
||||
expect(verified.files.get('TASK.md')).toEqual(
|
||||
new TextEncoder().encode(first.taskMarkdown),
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizes Markdown deterministically and requires the historical prompt digest', () => {
|
||||
const crlfPrompt = '# Mission\r\n\r\nImplement. \r\n'
|
||||
const normalized = '# Mission\n\nImplement.\n'
|
||||
const configured = input({
|
||||
renderedPrompt: crlfPrompt,
|
||||
run: { ...input().run, renderDigest: hash(normalized) },
|
||||
})
|
||||
const task = createTaskMarkdown(configured.run, configured.renderedPrompt)
|
||||
expect(task).toContain(normalized)
|
||||
expect(task).not.toContain('\r')
|
||||
|
||||
expect(() =>
|
||||
createTaskMarkdown(
|
||||
{ ...configured.run, renderDigest: 'c'.repeat(64) },
|
||||
crlfPrompt,
|
||||
),
|
||||
).toThrowError(
|
||||
expect.objectContaining({
|
||||
code: 'run_pack_input_invalid',
|
||||
path: 'renderedPrompt',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('sanitizes archive metadata names without allowing Windows device names', () => {
|
||||
const created = createRunPack(input({ slug: 'CON' }))
|
||||
expect(created.filename).toMatch(/^DevRunbook-run-CON-/)
|
||||
expect(created.filename).not.toContain('..')
|
||||
})
|
||||
|
||||
it('rejects unsafe, duplicate, reserved, manifest, and oversized additional files', () => {
|
||||
for (const unsafePath of [
|
||||
'../escape.md',
|
||||
'/absolute.md',
|
||||
'nested\\file.md',
|
||||
'CON.txt',
|
||||
'folder/NUL',
|
||||
'trailing.',
|
||||
'manifest.json',
|
||||
'double//slash.md',
|
||||
'unicode-\u202e.md',
|
||||
]) {
|
||||
expect(() =>
|
||||
createRunPack(
|
||||
input({
|
||||
additionalFiles: [
|
||||
{ path: unsafePath, mediaType: 'text/markdown', content: 'safe' },
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toThrowError(RunPackError)
|
||||
}
|
||||
expect(() =>
|
||||
createRunPack(
|
||||
input({
|
||||
additionalFiles: [
|
||||
{ path: 'EXTRA.md', mediaType: 'text/markdown', content: 'one' },
|
||||
{ path: 'extra.md', mediaType: 'text/markdown', content: 'two' },
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toThrowError(expect.objectContaining({ code: 'run_pack_input_invalid' }))
|
||||
expect(() =>
|
||||
createRunPack(
|
||||
input({
|
||||
additionalFiles: [
|
||||
{
|
||||
path: 'BIG.bin',
|
||||
mediaType: 'application/octet-stream',
|
||||
content: new Uint8Array(17),
|
||||
},
|
||||
],
|
||||
limits: { maxFileBytes: 16 },
|
||||
}),
|
||||
),
|
||||
).toThrowError(expect.objectContaining({ code: 'run_pack_archive_limit' }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('hostile Run Pack verification', () => {
|
||||
it.each([
|
||||
['traversal', '../x.md'],
|
||||
['Windows reserved name', 'CON.txt'],
|
||||
['backslash', 'bad\\.md'],
|
||||
])(
|
||||
'rejects a %s archive path before reading the manifest',
|
||||
(_label, replacement) => {
|
||||
const created = createRunPack(input())
|
||||
const hostile = replaceEntryPath(created.bytes, 'TASK.md', replacement)
|
||||
expect(() => verifyRunPack(hostile)).toThrowError(
|
||||
expect.objectContaining({ code: 'run_pack_path_unsafe' }),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
it('rejects duplicate and case-colliding archive entries', () => {
|
||||
const created = createRunPack(
|
||||
input({
|
||||
additionalFiles: [
|
||||
{ path: 'ALPHA.md', mediaType: 'text/markdown', content: 'alpha' },
|
||||
{ path: 'BRAVO.md', mediaType: 'text/markdown', content: 'bravo' },
|
||||
],
|
||||
}),
|
||||
)
|
||||
const duplicate = replaceEntryPath(created.bytes, 'BRAVO.md', 'ALPHA.md')
|
||||
expect(() => verifyRunPack(duplicate)).toThrowError(
|
||||
expect.objectContaining({ code: 'run_pack_archive_invalid' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects symlinks and other non-regular Unix entries', () => {
|
||||
const created = createRunPack(input())
|
||||
const hostile = Buffer.from(created.bytes)
|
||||
const task = entries(hostile).find((entry) =>
|
||||
entry.path.endsWith('/TASK.md'),
|
||||
)!
|
||||
hostile.writeUInt32LE((0o120777 * 65_536) >>> 0, task.centralOffset + 38)
|
||||
expect(() => verifyRunPack(hostile)).toThrowError(
|
||||
expect.objectContaining({
|
||||
code: 'run_pack_archive_invalid',
|
||||
path: task.path,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects local-entry aliases before trusting either payload', () => {
|
||||
const created = createRunPack(input())
|
||||
const hostile = Buffer.from(created.bytes)
|
||||
const located = entries(hostile)
|
||||
const task = located.find((entry) => entry.path.endsWith('/TASK.md'))!
|
||||
const runbook = located.find((entry) => entry.path.endsWith('/RUNBOOK.md'))!
|
||||
hostile.writeUInt32LE(runbook.localOffset, task.centralOffset + 42)
|
||||
expect(() => verifyRunPack(hostile)).toThrowError(
|
||||
expect.objectContaining({
|
||||
code: 'run_pack_archive_invalid',
|
||||
path: task.path,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('enforces compressed, expanded, single-file, and file-count limits before manifest trust', () => {
|
||||
const created = createRunPack(input())
|
||||
expect(() =>
|
||||
verifyRunPack(created.bytes, {
|
||||
maxArchiveBytes: created.bytes.byteLength - 1,
|
||||
}),
|
||||
).toThrowError(
|
||||
expect.objectContaining({
|
||||
code: 'run_pack_archive_limit',
|
||||
path: 'archive',
|
||||
}),
|
||||
)
|
||||
expect(() =>
|
||||
verifyRunPack(created.bytes, { maxExpandedBytes: 64 }),
|
||||
).toThrowError(expect.objectContaining({ code: 'run_pack_archive_limit' }))
|
||||
expect(() =>
|
||||
verifyRunPack(created.bytes, { maxFileBytes: 64 }),
|
||||
).toThrowError(expect.objectContaining({ code: 'run_pack_archive_limit' }))
|
||||
expect(() =>
|
||||
verifyRunPack(created.bytes, { maxFiles: 5, maxManifestFiles: 4 }),
|
||||
).toThrowError(
|
||||
expect.objectContaining({
|
||||
code: 'run_pack_archive_limit',
|
||||
path: 'archive.files',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects undeclared and missing files before checking the manifest digest', () => {
|
||||
const created = createRunPack(
|
||||
input({
|
||||
additionalFiles: [
|
||||
{ path: 'EXTRA.md', mediaType: 'text/markdown', content: 'extra' },
|
||||
],
|
||||
}),
|
||||
)
|
||||
const changed = replaceStoredEntryContent(
|
||||
created.bytes,
|
||||
'manifest.json',
|
||||
(manifest) => manifest.replace('EXTRA.md', 'EXTRB.md'),
|
||||
)
|
||||
expect(() => verifyRunPack(changed)).toThrowError(
|
||||
expect.objectContaining({ code: 'run_pack_inventory_mismatch' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects file size and hash mismatches before the manifest digest', () => {
|
||||
const created = createRunPack(input())
|
||||
const taskChanged = replaceStoredEntryContent(
|
||||
created.bytes,
|
||||
'TASK.md',
|
||||
(task) =>
|
||||
task.replace(
|
||||
'Implement the bounded change.',
|
||||
'Implement the bounded changf.',
|
||||
),
|
||||
)
|
||||
expect(() => verifyRunPack(taskChanged)).toThrowError(
|
||||
expect.objectContaining({
|
||||
code: 'run_pack_file_integrity_failed',
|
||||
path: 'TASK.md',
|
||||
}),
|
||||
)
|
||||
|
||||
const taskSize = created.manifest.files.find(
|
||||
(file) => file.path === 'TASK.md',
|
||||
)!.sizeBytes
|
||||
const replacementSize = taskSize + (taskSize % 10 === 9 ? -1 : 1)
|
||||
const sizeChanged = replaceStoredEntryContent(
|
||||
created.bytes,
|
||||
'manifest.json',
|
||||
(manifest) =>
|
||||
manifest.replace(
|
||||
`"sizeBytes": ${taskSize}`,
|
||||
`"sizeBytes": ${replacementSize}`,
|
||||
),
|
||||
)
|
||||
expect(() => verifyRunPack(sizeChanged)).toThrowError(
|
||||
expect.objectContaining({
|
||||
code: 'run_pack_file_integrity_failed',
|
||||
path: 'TASK.md',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects canonical manifest digest mismatches after file integrity passes', () => {
|
||||
const created = createRunPack(input())
|
||||
const changed = replaceStoredEntryContent(
|
||||
created.bytes,
|
||||
'manifest.json',
|
||||
(manifest) =>
|
||||
manifest.replace(
|
||||
created.manifest.manifestDigest,
|
||||
`${created.manifest.manifestDigest[0] === '0' ? '1' : '0'}${created.manifest.manifestDigest.slice(1)}`,
|
||||
),
|
||||
)
|
||||
expect(() => verifyRunPack(changed)).toThrowError(
|
||||
expect.objectContaining({ code: 'run_pack_manifest_digest_failed' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('hashes the exact embedded TASK.md prompt instead of trusting its metadata digest', () => {
|
||||
const created = createRunPack(input())
|
||||
const taskChanged = replaceStoredEntryContent(
|
||||
created.bytes,
|
||||
'TASK.md',
|
||||
(task) =>
|
||||
task.replace(
|
||||
'Implement the bounded change.',
|
||||
'Implement the bounded changf.',
|
||||
),
|
||||
)
|
||||
const changedTaskBytes = entries(taskChanged).find((entry) =>
|
||||
entry.path.endsWith('/TASK.md'),
|
||||
)!
|
||||
const taskBuffer = Buffer.from(taskChanged).subarray(
|
||||
changedTaskBytes.dataOffset,
|
||||
changedTaskBytes.dataOffset + changedTaskBytes.size,
|
||||
)
|
||||
const changedTaskHash = hash(taskBuffer)
|
||||
const withFileHash = replaceStoredEntryContent(
|
||||
taskChanged,
|
||||
'manifest.json',
|
||||
(source) =>
|
||||
source.replace(
|
||||
created.manifest.files.find((file) => file.path === 'TASK.md')!
|
||||
.sha256,
|
||||
changedTaskHash,
|
||||
),
|
||||
)
|
||||
const withCanonicalManifest = replaceStoredEntryContent(
|
||||
withFileHash,
|
||||
'manifest.json',
|
||||
(source) => {
|
||||
const manifest = JSON.parse(source) as Record<string, unknown>
|
||||
const previous = manifest.manifestDigest as string
|
||||
delete manifest.manifestDigest
|
||||
const next = hash(canonicalJson(manifest))
|
||||
return source.replace(previous, next)
|
||||
},
|
||||
)
|
||||
expect(() => verifyRunPack(withCanonicalManifest)).toThrowError(
|
||||
expect.objectContaining({
|
||||
code: 'run_pack_file_integrity_failed',
|
||||
path: 'TASK.md',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects duplicate JSON mapping keys in manifest objects', () => {
|
||||
const created = createRunPack(input())
|
||||
const changed = replaceStoredEntryContent(
|
||||
created.bytes,
|
||||
'manifest.json',
|
||||
(manifest) => manifest.replace('"mediaType"', '"sizeBytes"'),
|
||||
)
|
||||
expect(() => verifyRunPack(changed)).toThrowError(
|
||||
expect.objectContaining({
|
||||
code: 'run_pack_manifest_invalid',
|
||||
path: 'manifest.json',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects invalid manifest schema before inventory and digest verification', () => {
|
||||
const created = createRunPack(input())
|
||||
const changed = replaceStoredEntryContent(
|
||||
created.bytes,
|
||||
'manifest.json',
|
||||
(manifest) =>
|
||||
manifest.replace('devrunbook.io/v1alpha1', 'devrunbook.io/v9alpha9'),
|
||||
)
|
||||
expect(() => verifyRunPack(changed)).toThrowError(
|
||||
expect.objectContaining({
|
||||
code: 'run_pack_manifest_invalid',
|
||||
path: 'manifest.json',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects trailing bytes, ZIP comments, truncation, and unsupported signatures', () => {
|
||||
const created = createRunPack(input())
|
||||
const trailing = Buffer.concat([created.bytes, Buffer.from([0])])
|
||||
const commented = Buffer.from(created.bytes)
|
||||
commented.writeUInt16LE(1, commented.byteLength - 2)
|
||||
const badSignature = Buffer.from(created.bytes)
|
||||
badSignature.writeUInt32LE(0, badSignature.byteLength - 22)
|
||||
for (const archive of [
|
||||
trailing,
|
||||
commented,
|
||||
badSignature,
|
||||
created.bytes.slice(0, 10),
|
||||
]) {
|
||||
expect(() => verifyRunPack(archive)).toThrowError(
|
||||
expect.objectContaining({ code: 'run_pack_archive_invalid' }),
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user