Skip to content

Commit 2aaeb6b

Browse files
committed
chore(merge): land pr #1476 as one signed change
A true merge of jdalton/fix-main-ci carries 174 unsigned commits from the old-main lineage, which the required-signatures rule refuses. This lands the identical resolved tree — the CI-gate fixes plus the old-main work the consolidation missed (JVM fixture stubs, Maven build isolation, cdxgen failure reporting, plain-text scan report) — as a single signed commit.
1 parent 9ab403b commit 2aaeb6b

38 files changed

Lines changed: 5923 additions & 150 deletions
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
/*
2+
* @file Detector for agent-directed instructions embedded in UNTRUSTED
3+
* content — an issue body, a PR/review comment, a fetched page, a vendored
4+
* file. Such text is DATA TO REPORT, never an instruction to follow, and
5+
* this module is how a guard or a report names what it found.
6+
*
7+
* The motivating shape is a "honeypot" comment: an ordinary-looking thank-you
8+
* posted on every new PR whose raw Markdown hides a block addressed only to
9+
* machines. The block asks the reader to post a short hex code back, and an
10+
* account whose own reply carries that code is labelled automated. The token
11+
* is `randomBytes(6).toString('hex')`, so it is exactly twelve hex
12+
* characters — `findHoneypotTokens` returns those.
13+
*
14+
* Detection is by SHAPE, not by a denylist of vendors. Four families:
15+
*
16+
* 1. An HTML comment whose contents address an automated reader — invisible
17+
* on the rendered page, fully visible to whatever reads the raw body.
18+
* 2. A directive to emit a verification / acknowledgement code, to reply with
19+
* "exactly the following", or to post a code with nothing else around it.
20+
* 3. A "human contributors may skip this" disclaimer, which is the tell that
21+
* the surrounding block was aimed only at machines.
22+
* 4. The one literal vendor marker worth keying on, since it names the
23+
* mechanism outright.
24+
*
25+
* Every scan runs over the raw text AND a `normalizeForScan` copy (invisible
26+
* characters stripped, Unicode Tag block dropped, homoglyphs folded), plus a
27+
* whole-text pass with newlines folded to spaces so a directive split across
28+
* lines is still caught. Same three-pass layering as prompt-injection-guard.
29+
*/
30+
31+
import { normalizeForScan } from './evasion-normalize.mts'
32+
33+
/**
34+
* One embedded-directive hit: what shape matched, the 1-based line it was found
35+
* on, and a clipped copy of the offending text.
36+
*/
37+
export interface UntrustedFinding {
38+
readonly label: string
39+
readonly line: number
40+
readonly excerpt: string
41+
}
42+
43+
interface DirectivePattern {
44+
readonly label: string
45+
readonly re: RegExp
46+
// False → skip the whitespace-folded whole-text pass. That pass turns
47+
// newlines into spaces, which disables the `[^.\n]` proximity brake the
48+
// looser patterns rely on, so two innocent adjacent lines could read as one
49+
// sentence. Those patterns stay per-line.
50+
readonly wholeText?: boolean | undefined
51+
}
52+
53+
// Cap the bytes scanned so a multi-megabyte fetched page cannot wedge a hook.
54+
// A real directive lands near the top of the body that carries it. Matches
55+
// prompt-injection-guard's own cap.
56+
const MAX_SCAN_BYTES = 512 * 1024
57+
58+
const DIRECTIVE_PATTERNS: readonly DirectivePattern[] = [
59+
{
60+
label: 'HTML comment addressed to an automated reader',
61+
// `<!--` then, within 400 characters, a phrase that speaks to a machine
62+
// rather than to the person reading the rendered page. The lazy
63+
// `[\s\S]{0,400}?` lets the phrase sit anywhere in a multi-line comment.
64+
re: /<!--[\s\S]{0,400}?(?:automated\s+contributors?|if\s+you(?:'re|\s+are)\s+(?:an?\s+)?(?:a\.?i\.?|agent|automated|language\s+model|llms?)|llms?\s+reading\s+this|message_for_llms|notice\s+for\s+automated\s+(?:agents?|contributors?))/i,
65+
},
66+
{
67+
label: 'notice addressed to automated readers',
68+
// The same machine-addressed phrases standing on their own, outside any
69+
// HTML comment — a plain-text block, a fenced snippet, a fetched page.
70+
re: /message_for_llms|\bnotice\s+for\s+automated\s+(?:agents?|contributors?)\b|\bautomated\s+contributors?\b|\bllms?\s+reading\s+this\b|\bif\s+you(?:'re|\s+are)\s+an?\s+ai\s+agent\b/i,
71+
},
72+
{
73+
label: 'directive to emit a verification code',
74+
// An emit-style verb, then within 96 characters a
75+
// verification/confirmation/acknowledgement code. The `[^\n]` window keeps
76+
// the two halves in the same neighbourhood.
77+
re: /\b(?:emit|include|paste|post|repeat|reply|respond|return|send|write)\b[^\n]{0,96}\b(?:acknowledge?ment|confirmation|validation|verification)\s+code\b/i,
78+
},
79+
{
80+
label: 'directive to reply with an exact literal',
81+
// "must consist of exactly the following", "reply with exactly the text
82+
// below" — an instruction to echo a literal verbatim.
83+
re: /\b(?:be|consist\s+of|contain|reply\s+with|respond\s+with)\s+(?:only\s+)?exactly\s+the\s+(?:code|following|string|text)\b/i,
84+
},
85+
{
86+
label: 'directive to post a code and nothing else',
87+
// A code/token noun within 40 characters of "and nothing else" (either
88+
// order) — the bait's signature demand that the reply carry the code alone.
89+
re: /\b(?:code|string|token|value|word)\b[^.\n]{0,40}\band\s+nothing\s+else\b|\band\s+nothing\s+else\b[^.\n]{0,40}\b(?:code|string|token|value|word)\b/i,
90+
wholeText: false,
91+
},
92+
{
93+
label: 'disclaimer marking the block as machine-only',
94+
// A line excusing people from following the block it sits in. A block that
95+
// waves off human readers was written for machines.
96+
re: /\bhuman\s+(?:contributors?|maintainers?|readers?|reviewers?|users?)\b[^.\n]{0,80}\b(?:disregard|do(?:es)?\s+not\s+apply|ignore|skip)\b/i,
97+
wholeText: false,
98+
},
99+
{
100+
label: 'honeypot marker literal',
101+
// The vendor marker that names the mechanism outright.
102+
re: /agentscan-honeypot/i,
103+
},
104+
]
105+
106+
// A standalone twelve-hex-character run — the exact shape of a honeypot token
107+
// (`randomBytes(6).toString('hex')`). The word boundaries keep it from firing
108+
// inside a longer hex run such as a full 40-character SHA.
109+
const HONEYPOT_TOKEN_RE = /\b[0-9a-f]{12}\b/g
110+
111+
// A canonical 8-4-4-4-12 UUID. Its last group is twelve hex characters between
112+
// word boundaries, so a UUID pasted into an ordinary comment would otherwise
113+
// read as a token. Blanked out before the token scan.
114+
const UUID_RE =
115+
/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi
116+
117+
// Every HTML comment, opening through closing delimiter. The threat matcher
118+
// (agentscan's hasHoneypotToken) strips comments before testing for the
119+
// token, so a token split by one — `a1b2c3<!-- x -->d4e5f6` — still reads as
120+
// one standalone run to the trap even though it never does to a scan that
121+
// only reads the raw or normalized text.
122+
const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g
123+
124+
function clipExcerpt(text: string): string {
125+
const trimmed = text.trim()
126+
return trimmed.length > 160 ? `${trimmed.slice(0, 157)}...` : trimmed
127+
}
128+
129+
// Best-effort 1-based line of `fragment`'s first word within `text`.
130+
function lineOfFragment(text: string, fragment: string): number {
131+
const firstWord = fragment.trim().split(/\s+/)[0]
132+
if (!firstWord) {
133+
return 1
134+
}
135+
const idx = text.toLowerCase().indexOf(firstWord.toLowerCase())
136+
if (idx < 0) {
137+
return 1
138+
}
139+
return text.slice(0, idx).split('\n').length
140+
}
141+
142+
function matchedLabels(text: string): string[] {
143+
const out: string[] = []
144+
for (let i = 0, { length } = DIRECTIVE_PATTERNS; i < length; i += 1) {
145+
const pattern = DIRECTIVE_PATTERNS[i]!
146+
if (pattern.re.test(text)) {
147+
out.push(pattern.label)
148+
}
149+
}
150+
return out
151+
}
152+
153+
/**
154+
* Every agent-directed instruction embedded in `text`, deduplicated by
155+
* label + line + excerpt. Empty when the text carries none.
156+
*
157+
* Three complementary passes, matching prompt-injection-guard: per line on the
158+
* raw text, per line on a normalized copy, and once over the whole normalized
159+
* text with runs of whitespace folded to a single space so a directive broken
160+
* across lines still reads as one sentence.
161+
*/
162+
export function findEmbeddedAgentDirectives(text: string): UntrustedFinding[] {
163+
const scanned =
164+
text.length > MAX_SCAN_BYTES ? text.slice(0, MAX_SCAN_BYTES) : text
165+
const findings: UntrustedFinding[] = []
166+
const seen = new Set<string>()
167+
const push = (finding: UntrustedFinding): void => {
168+
const key = `${finding.label}:${finding.line}:${finding.excerpt}`
169+
if (!seen.has(key)) {
170+
seen.add(key)
171+
findings.push(finding)
172+
}
173+
}
174+
175+
const lines = scanned.split('\n')
176+
for (let i = 0, { length } = lines; i < length; i += 1) {
177+
const raw = lines[i] ?? ''
178+
const labels = new Set([
179+
...matchedLabels(raw),
180+
...matchedLabels(normalizeForScan(raw)),
181+
])
182+
for (const label of labels) {
183+
push({ excerpt: clipExcerpt(raw), label, line: i + 1 })
184+
}
185+
}
186+
187+
// An HTML comment spanning several lines only reads as one block here, so the
188+
// folded pass is where a multi-line bait block is actually caught.
189+
const folded = normalizeForScan(scanned).replace(/\s+/g, ' ')
190+
for (let i = 0, { length } = DIRECTIVE_PATTERNS; i < length; i += 1) {
191+
const pattern = DIRECTIVE_PATTERNS[i]!
192+
if (pattern.wholeText === false) {
193+
continue
194+
}
195+
const match = pattern.re.exec(folded)
196+
if (match) {
197+
push({
198+
excerpt: clipExcerpt(match[0]),
199+
label: `${pattern.label} [multi-line]`,
200+
line: lineOfFragment(scanned, match[0]),
201+
})
202+
}
203+
}
204+
205+
return findings
206+
}
207+
208+
/**
209+
* Every standalone twelve-hex-character token in `text`, in first-seen order
210+
* and deduplicated. That is the honeypot token shape; a caller decides which of
211+
* them are legitimate (an abbreviated commit SHA resolves against the repo, a
212+
* bait token does not).
213+
*
214+
* A UUID's final group is also twelve hex characters, so UUIDs are blanked out
215+
* first — pasting one into a comment is ordinary, not bait.
216+
*
217+
* Scans the raw text, a `normalizeForScan` copy, AND a comment-stripped copy
218+
* of each — mirroring the upstream matcher's own comment-stripping pass, so a
219+
* token split across an HTML comment boundary is still caught.
220+
*/
221+
export function findHoneypotTokens(text: string): string[] {
222+
const out: string[] = []
223+
const seen = new Set<string>()
224+
const base = [text, normalizeForScan(text)]
225+
const variants = [...base, ...base.map(v => v.replace(HTML_COMMENT_RE, ''))]
226+
for (let i = 0, { length } = variants; i < length; i += 1) {
227+
const raw = variants[i]!
228+
const source = raw.replace(UUID_RE, ' ')
229+
HONEYPOT_TOKEN_RE.lastIndex = 0
230+
let match = HONEYPOT_TOKEN_RE.exec(source)
231+
while (match) {
232+
const token = match[0].toLowerCase()
233+
if (!seen.has(token)) {
234+
seen.add(token)
235+
out.push(token)
236+
}
237+
match = HONEYPOT_TOKEN_RE.exec(source)
238+
}
239+
}
240+
return out
241+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/**
2+
* @file Shared helper for the fleet README markdown rules: detect whether the
3+
* repo being linted has opted into a freeform (non-skeleton) README via the
4+
* cascade roster (`optIns: ['freeform-readme']`). Product / marketplace repos
5+
* (the VS Code + browser extensions, the skills directory) carry public
6+
* READMEs that don't fit the five-section infra skeleton; the
7+
* required-sections rule bails for them, while the universal social-badge /
8+
* wheelhouse-leak / sibling-path rules still apply. Sync (markdownlint-cli2
9+
* calls rule init synchronously) and dependency-free (loaded as a regular ESM
10+
* module, not bundled). Resolves the current repo name from
11+
* SOCKET_FLEET_REPO_NAME (CI) then the cwd basename, local checkout, and
12+
* reads the roster relative to the cwd.
13+
*/
14+
15+
import { existsSync, readFileSync } from 'node:fs'
16+
import path from 'node:path'
17+
import process from 'node:process'
18+
19+
function resolveRepoName(cwd: string) {
20+
return process.env['SOCKET_FLEET_REPO_NAME'] || path.basename(cwd)
21+
}
22+
23+
export function isFreeformReadmeOptIn(cwd = process.cwd()) {
24+
const rosterPath = path.join(
25+
cwd,
26+
'.claude/skills/fleet/cascading-fleet/lib/fleet-repos.json',
27+
)
28+
if (!existsSync(rosterPath)) {
29+
return false
30+
}
31+
let roster
32+
try {
33+
roster = JSON.parse(readFileSync(rosterPath, 'utf8'))
34+
} catch {
35+
return false
36+
}
37+
const name = resolveRepoName(cwd)
38+
const repos = roster && Array.isArray(roster.repos) ? roster.repos : []
39+
for (let i = 0, { length } = repos; i < length; i += 1) {
40+
const r = repos[i]
41+
if (r && r.name === name) {
42+
return Array.isArray(r.optIns) && r.optIns.includes('freeform-readme')
43+
}
44+
}
45+
return false
46+
}

.gitattributes

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ template/.claude/hooks/fleet/_shared/acorn/acorn.wasm binary
108108
.config/fleet/tsconfig.check.base.json linguist-generated=true
109109
.config/fleet/vitest.coverage.fleet.config.mts linguist-generated=true
110110
.config/repo/rolldown/define-guarded.mts linguist-generated=true
111+
.config/repo/vitest.config.mts linguist-generated=true
111112
.editorconfig linguist-generated=true
112113
.git-hooks/_shared linguist-generated=true
113114
.git-hooks/commit-msg linguist-generated=true
@@ -145,11 +146,17 @@ template/.claude/hooks/fleet/_shared/acorn/acorn.wasm binary
145146
.github/workflows/npm-publish.yml linguist-generated=true
146147
.github/workflows/prune-workflow-runs.yml linguist-generated=true
147148
.github/workflows/release-reconcile.yml linguist-generated=true
149+
.github/workflows/weekly-update.lock.yml linguist-generated=true
150+
.github/workflows/weekly-update.md linguist-generated=true
148151
.github/workflows/weekly-update.yml linguist-generated=true
149152
.mcp.json linguist-generated=true
150153
.npmrc linguist-generated=true
151154
assets/badge-follow-bluesky.svg linguist-generated=true
152155
assets/badge-follow-x.svg linguist-generated=true
156+
assets/fleet/badge-follow-bluesky.svg linguist-generated=true
157+
assets/fleet/badge-follow-x.svg linguist-generated=true
158+
assets/fleet/socket-combomark-dark.svg linguist-generated=true
159+
assets/fleet/socket-combomark-light.svg linguist-generated=true
153160
assets/socket-combomark-dark.svg linguist-generated=true
154161
assets/socket-combomark-light.svg linguist-generated=true
155162
docs/agents.md/fleet linguist-generated=true
@@ -159,11 +166,13 @@ docs/design/fleet/tokens.css linguist-generated=true
159166
docs/references/fleet/sfw-local-install.md linguist-generated=true
160167
patches/brace-expansion@5.0.9.patch linguist-generated=true
161168
patches/minimatch@10.2.6.patch linguist-generated=true
169+
patches/taze@19.16.0.patch linguist-generated=true
162170
patches/taze@19.17.1.patch linguist-generated=true
163171
scripts/fleet linguist-generated=true
164172
scripts/repo/bootstrap linguist-generated=true
165173
test/fleet/_shared/lib linguist-generated=true
166174
test/fleet/nock-loopback-passthrough.test.mts linguist-generated=true
175+
test/fleet/publish-infra-placeholder.test.mts linguist-generated=true
167176
test/fleet/publish-infra/cargo/placeholder.test.mts linguist-generated=true
168177
test/fleet/publish-infra/npm/placeholder.test.mts linguist-generated=true
169178
test/fleet/scripts/setup.mts linguist-generated=true

0 commit comments

Comments
 (0)