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
2 changes: 1 addition & 1 deletion layers/artifact-delivery/server/utils/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -569,7 +569,7 @@ async function loadAndCheck(
const loaded = await github.load(source)
if (loaded._tag === 'rejected')
return { _tag: 'rejected', rejection: loaded }
const checked = await checkArtifactSource(source, loaded.value.files)
const checked = await checkArtifactSource(source, loaded.value.files, loaded.value.omitted)
return { _tag: 'loaded', source, files: loaded.value.files, checked }
}

Expand Down
90 changes: 84 additions & 6 deletions layers/artifact-delivery/server/utils/checks.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ArtifactFile, CheckResult, ResolvedSource } from '../schemas/contracts'
import type { ArtifactSourceFile } from './github-source'
import type { ArtifactSourceFile, OmittedArtifactFile } from './github-source'
import { digestHex } from './encoding'
import { ARTIFACT_POLICY_VERSION } from './state'

Expand All @@ -12,8 +12,24 @@ import { ARTIFACT_POLICY_VERSION } from './state'
const AGENT_SKILLS_CHECK_VERSION = '2026-10-07'
const MAX_DESCRIPTION_CHARACTERS = 1024
const PATH_POLICY_VERSION = '1'
const CREDENTIAL_MATERIAL_VERSION = '1'
/**
* Version 2, 2026-10-07: a finding needs a whole key block, not its first
* line. Version 1 blocked nine Skills for a header alone: a secret scanner's
* pattern table, a `"-----BEGIN PRIVATE KEY-----\n..."` placeholder, and a
* test fixture whose body read `TEST-NOT-A-REAL-KEY`. None held a key.
*/
const CREDENTIAL_MATERIAL_VERSION = '2'
const EXECUTABLE_FILES_VERSION = '1'
/**
* Lists the files left out of the Artifact for a size limit. It is a check
* result, not a new attestation field: the released skilld CLI refuses an
* attestation or a Resolution answer with a field it does not know, and it
* accepts any check result that is not required.
*/
const OMITTED_FILES_VERSION = '1'
/** The skilld CLI refuses a check result with more findings, or a longer one. */
const MAX_CHECK_FINDINGS = 100
const MAX_CHECK_FINDING_CHARACTERS = 500

/** The checks a statement under one policy carries, by check name. */
export type ArtifactCheckSet = ReadonlyMap<string, { version: string, required: boolean }>
Expand All @@ -23,6 +39,7 @@ const CURRENT_ARTIFACT_CHECKS: ArtifactCheckSet = new Map([
['agent-skills-spec', { version: AGENT_SKILLS_CHECK_VERSION, required: false }],
['credential-material', { version: CREDENTIAL_MATERIAL_VERSION, required: true }],
['executable-files', { version: EXECUTABLE_FILES_VERSION, required: false }],
['omitted-files', { version: OMITTED_FILES_VERSION, required: false }],
])

/**
Expand All @@ -40,9 +57,9 @@ const CURRENT_ARTIFACT_CHECKS: ArtifactCheckSet = new Map([
*/
export const SIGNABLE_ARTIFACT_POLICIES: ReadonlyMap<string, ArtifactCheckSet> = new Map([
[ARTIFACT_POLICY_VERSION, CURRENT_ARTIFACT_CHECKS],
['2026-08-20.1', new Map([
['2026-10-07.1', new Map([
['path-policy', { version: '1', required: true }],
['agent-skills-spec', { version: '2026-08-20', required: true }],
['agent-skills-spec', { version: '2026-10-07', required: false }],
['credential-material', { version: '1', required: true }],
['executable-files', { version: '1', required: false }],
])],
Expand All @@ -56,6 +73,7 @@ export interface CheckedArtifactSource {
export async function checkArtifactSource(
source: ResolvedSource,
files: ArtifactSourceFile[],
omitted: OmittedArtifactFile[] = [],
): Promise<CheckedArtifactSource> {
const fileInventory = await Promise.all(files.map(async file => ({
path: file.path,
Expand Down Expand Up @@ -136,7 +154,7 @@ export async function checkArtifactSource(
const text = decodeText(file.bytes)
if (!text)
continue
if (/-----BEGIN (?:[A-Z0-9 ]+ )?PRIVATE KEY-----/.test(text))
if (containsPrivateKey(text))
credentialFindings.push(`${file.path} contains private key material.`)
}
const credentialMaterial: CheckResult = credentialFindings.length > 0
Expand Down Expand Up @@ -174,7 +192,30 @@ export async function checkArtifactSource(

return {
files: fileInventory,
checkResults: [pathPolicy, agentSkillsSpec, credentialMaterial, executableFiles],
checkResults: [pathPolicy, agentSkillsSpec, credentialMaterial, executableFiles, omittedFilesResult(omitted)],
}
}

function omittedFilesResult(omitted: OmittedArtifactFile[]): CheckResult {
if (omitted.length === 0)
return { name: 'omitted-files', version: OMITTED_FILES_VERSION, outcome: 'pass', required: false }
const count = omitted.length === 1
? '1 file over the size limits was left out of the Artifact.'
: `${omitted.length} files over the size limits were left out of the Artifact.`
const listed = omitted.length > MAX_CHECK_FINDINGS ? ` The first ${MAX_CHECK_FINDINGS} are listed.` : ''
return {
name: 'omitted-files',
version: OMITTED_FILES_VERSION,
outcome: 'warn',
required: false,
summary: count + listed,
findings: omitted.slice(0, MAX_CHECK_FINDINGS).map((file) => {
const finding = `${file.path}: ${file.bytes.toLocaleString('en-US')} bytes, ${file.url}`
// A long path drops the URL first: the path is what a reader needs.
return finding.length <= MAX_CHECK_FINDING_CHARACTERS
? finding
: `${file.path}: ${file.bytes.toLocaleString('en-US')} bytes`.slice(0, MAX_CHECK_FINDING_CHARACTERS)
}),
}
}

Expand All @@ -198,6 +239,43 @@ export function checksPermitSigning(checks: CheckResult[], policyVersion: string
&& checks.every(check => !check.required || check.outcome === 'pass')
}

/**
* A PEM private key block: the BEGIN line, a body with no dashes, and the END
* line with the same label. The body bound keeps one match linear; a 4096-bit
* RSA key is about 3,300 characters.
*/
const PRIVATE_KEY_BLOCK = /-----BEGIN ((?:[A-Z0-9]+ )*)PRIVATE KEY-----((?:(?!-----)[\s\S]){0,16384})-----END \1PRIVATE KEY-----/g
/** A line break, real or escaped inside a JSON or code string. */
const BODY_LINE_BREAK = /\r?\n|(?:\\+[rn])+/
/** A legacy PEM header such as `Proc-Type: 4,ENCRYPTED`. */
const PEM_HEADER_LINE = /^[\w-]+:\s/
/**
* Base64 of at least 48 bytes: an Ed25519 PKCS#8 key, the smallest private
* key format, is exactly 64 characters.
*/
const KEY_BODY = /^[A-Z0-9+/]{64,}={0,2}$/i

/**
* True when the text holds a complete private key, encrypted or not.
*
* A placeholder has the BEGIN line but no key: an ellipsis, a bracketed note,
* or a test label in the body never reads as base64. The body may sit in a
* JSON string with escaped line breaks, which is how a leaked service account
* key usually arrives, or be indented inside YAML.
*/
function containsPrivateKey(text: string): boolean {
for (const match of text.matchAll(PRIVATE_KEY_BLOCK)) {
const body = match[2]!
.split(BODY_LINE_BREAK)
.map(line => line.trim())
.filter(line => !PEM_HEADER_LINE.test(line))
.join('')
if (KEY_BODY.test(body))
return true
}
return false
}

function decodeText(bytes: Uint8Array): string | null {
try {
return new TextDecoder('utf-8', { fatal: true, ignoreBOM: false }).decode(bytes)
Expand Down
Loading
Loading