@yagni-app/code-staging 1.2.6-staging.1952.1 → 1.2.6-staging.1956.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/dist/extension/branding.d.ts +20 -0
  2. package/dist/extension/branding.js +49 -8
  3. package/dist/extension/index.js +5 -0
  4. package/dist/extension/pipeline/changeScan.d.ts +62 -0
  5. package/dist/extension/pipeline/changeScan.js +307 -0
  6. package/dist/extension/pipeline/cloud.d.ts +205 -0
  7. package/dist/extension/pipeline/cloud.js +382 -0
  8. package/dist/extension/pipeline/continuation.d.ts +7 -3
  9. package/dist/extension/pipeline/continuation.js +29 -13
  10. package/dist/extension/pipeline/findings.d.ts +9 -4
  11. package/dist/extension/pipeline/findings.js +25 -10
  12. package/dist/extension/pipeline/finish.d.ts +11 -1
  13. package/dist/extension/pipeline/finish.js +51 -5
  14. package/dist/extension/pipeline/headlessGo.d.ts +20 -1
  15. package/dist/extension/pipeline/headlessGo.js +205 -33
  16. package/dist/extension/pipeline/orchestrator.d.ts +8 -2
  17. package/dist/extension/pipeline/orchestrator.js +249 -68
  18. package/dist/extension/pipeline/personas.d.ts +8 -3
  19. package/dist/extension/pipeline/personas.js +42 -7
  20. package/dist/extension/pipeline/planOutput.d.ts +42 -0
  21. package/dist/extension/pipeline/planOutput.js +94 -0
  22. package/dist/extension/pipeline/stages.d.ts +34 -5
  23. package/dist/extension/pipeline/stages.js +136 -7
  24. package/dist/extension/pipeline/tierCap.d.ts +22 -1
  25. package/dist/extension/pipeline/tierCap.js +48 -1
  26. package/dist/extension/pipeline/types.d.ts +31 -0
  27. package/dist/extension/pipeline/types.js +2 -0
  28. package/dist/extension/pipeline/verify.d.ts +33 -0
  29. package/dist/extension/pipeline/verify.js +101 -9
  30. package/package.json +2 -2
@@ -96,6 +96,21 @@ export declare const SANDBOX_ABSENT_COMMANDS: readonly string[];
96
96
  * contain the standalone word "pi", no `- ` bullet lines, no emojis.
97
97
  */
98
98
  export declare const SANDBOX_SECTION: string;
99
+ /**
100
+ * The "Your sandbox" section for a cloud session (`yagni go --headless
101
+ * --cloud`, the one-attempt lane), in place of {@link SANDBOX_SECTION}: `gh`
102
+ * works through YAGNI's forwarder (the run token stands in for a GitHub
103
+ * token, and the forwarder holds the real one), ticket images are files, and
104
+ * sibling repositories clone on demand. The only absolute paths it names are
105
+ * the browser's, which {@link SANDBOX_SECTION} names too, so the pi image
106
+ * smoke's check of that section covers them; the input folder and the clone
107
+ * folder are named relative to the ticket and the home directory, since the
108
+ * host creates them per session and they are not on the image.
109
+ *
110
+ * Content constraints (parity with {@link TICKET_IMAGE_RULE}): must not
111
+ * contain the standalone word "pi", no `- ` bullet lines, no emojis.
112
+ */
113
+ export declare const SANDBOX_CLOUD_SECTION: string;
99
114
  /**
100
115
  * File-tool steering: prefer the first-class write/edit tools over bash file
101
116
  * writes, one write call per file instead of a heredoc batch, and never create
@@ -200,6 +215,11 @@ export interface BrandSystemPromptOptions {
200
215
  * operations section. Absent or false, the prompt is exactly the laptop's.
201
216
  */
202
217
  sandbox?: boolean;
218
+ /**
219
+ * With `sandbox`, the session is a cloud session (`--cloud`): it gets
220
+ * {@link SANDBOX_CLOUD_SECTION} instead of {@link SANDBOX_SECTION}.
221
+ */
222
+ cloud?: boolean;
203
223
  }
204
224
  /**
205
225
  * Rebrand pi's assembled system prompt as YAGNI Code's, and optionally inject a
@@ -145,7 +145,7 @@ export const GITHUB_OPERATIONS = "## GitHub Operations\n" +
145
145
  "quoting surface. Use the session scratchpad for the file.\n" +
146
146
  "\n" +
147
147
  "Write the body with the `write` tool, then:\n" +
148
- " gh pr create --title \"…\" --body-file <path>\n" +
148
+ ' gh pr create --title "…" --body-file <path>\n' +
149
149
  " gh pr comment <n> --body-file <path>\n" +
150
150
  " gh api repos/{owner}/{repo}/pulls/{number}/comments/{comment_id}/replies -F body=@<path>\n" +
151
151
  " git commit -F <path>\n" +
@@ -196,7 +196,7 @@ export const SANDBOX_SECTION = `${SANDBOX_SECTION_HEADER}\n` +
196
196
  "\n" +
197
197
  "Ticket images: the ticket's screenshots are not on disk here. Each one is " +
198
198
  "described in words in the ticket's evidence pack, the \"Evidence behind the " +
199
- "ticket\" section of the ticket you were given. Trust that description; a " +
199
+ 'ticket" section of the ticket you were given. Trust that description; a ' +
200
200
  "screenshot named there as not read could not be described.\n" +
201
201
  "\n" +
202
202
  `A browser: Chrome is at \`${SANDBOX_CHROME_PATH}\` and Playwright is installed ` +
@@ -207,6 +207,49 @@ export const SANDBOX_SECTION = `${SANDBOX_SECTION_HEADER}\n` +
207
207
  "\n" +
208
208
  "Installs: the network is open, and you may install what the work needs. The " +
209
209
  "mission file says what to install.";
210
+ /**
211
+ * The "Your sandbox" section for a cloud session (`yagni go --headless
212
+ * --cloud`, the one-attempt lane), in place of {@link SANDBOX_SECTION}: `gh`
213
+ * works through YAGNI's forwarder (the run token stands in for a GitHub
214
+ * token, and the forwarder holds the real one), ticket images are files, and
215
+ * sibling repositories clone on demand. The only absolute paths it names are
216
+ * the browser's, which {@link SANDBOX_SECTION} names too, so the pi image
217
+ * smoke's check of that section covers them; the input folder and the clone
218
+ * folder are named relative to the ticket and the home directory, since the
219
+ * host creates them per session and they are not on the image.
220
+ *
221
+ * Content constraints (parity with {@link TICKET_IMAGE_RULE}): must not
222
+ * contain the standalone word "pi", no `- ` bullet lines, no emojis.
223
+ */
224
+ export const SANDBOX_CLOUD_SECTION = `${SANDBOX_SECTION_HEADER}\n` +
225
+ "\n" +
226
+ "This session runs in a YAGNI sandbox, not on a developer's laptop. Five things " +
227
+ "are true here.\n" +
228
+ "\n" +
229
+ "GitHub: `gh` works. It talks to YAGNI's forwarder, which makes each call as " +
230
+ "this Team's GitHub App, so no token you could write with is in this " +
231
+ "sandbox. It reads the repositories the installation can see, and it writes " +
232
+ "only to this attempt's branch and pull request; merging and approving a " +
233
+ "review are refused. Commit your work and do not push: YAGNI pushes the " +
234
+ "branch and opens the pull request after you finish.\n" +
235
+ "\n" +
236
+ "Ticket images: every image the ticket carries is a file in the `images` " +
237
+ "folder beside the ticket file, and the ticket names each one. Open each " +
238
+ "with the read tool before you rely on what the ticket says.\n" +
239
+ "\n" +
240
+ `A browser: Chrome is at \`${SANDBOX_CHROME_PATH}\` and Playwright is installed ` +
241
+ `at \`${SANDBOX_PLAYWRIGHT_DIR}\`. Load Playwright from ` +
242
+ `\`${SANDBOX_PLAYWRIGHT_DIR}/node_modules/playwright\` and launch Chrome with ` +
243
+ `\`executablePath: "${SANDBOX_CHROME_PATH}"\` to open a page, click through it, ` +
244
+ "or take a screenshot.\n" +
245
+ "\n" +
246
+ "Installs: the network is open, and you may install what the work needs.\n" +
247
+ "\n" +
248
+ "Other repositories: the ticket lists this account's other repositories. When " +
249
+ "the work depends on one, clone it read-only on demand with " +
250
+ "`git clone --filter=blob:none --depth 1` into `~/context/<repo>`: file " +
251
+ "contents arrive as you read them, so a clone takes seconds. Nothing there is " +
252
+ "part of your change.";
210
253
  /**
211
254
  * File-tool steering: prefer the first-class write/edit tools over bash file
212
255
  * writes, one write call per file instead of a heredoc batch, and never create
@@ -322,10 +365,10 @@ const PI_IDENTITY_LOOSE_RE = /You are [^\n.]*operating inside pi[^\n.]*\.(?:\s*Y
322
365
  const PI_DOCS_BLOCK_RE = /\n*Pi documentation \(read only[^\n]*(?:\n- [^\n]*)*/gi;
323
366
  /** Neutralize residual pi/Earendil brand tokens in pi-owned text. */
324
367
  function scrubPiHarness(text) {
325
- return text
368
+ return (text
326
369
  .replace(/\bEarendil(?:\s+Works)?\b/gi, "YAGNI")
327
370
  // Whole-word "pi" only, so "api", "pip", "pipeline" are untouched.
328
- .replace(/\bpi\b/gi, BRAND_NAME);
371
+ .replace(/\bpi\b/gi, BRAND_NAME));
329
372
  }
330
373
  /**
331
374
  * Scrub pi-brand tokens everywhere EXCEPT inside the user's <project_context>.
@@ -335,9 +378,7 @@ function scrubPiHarness(text) {
335
378
  */
336
379
  function scrubOutsideProjectContext(s) {
337
380
  const parts = s.split(/(<project_context>[\s\S]*?<\/project_context>)/);
338
- return parts
339
- .map((p) => (p.startsWith("<project_context>") ? p : scrubPiHarness(p)))
340
- .join("");
381
+ return parts.map((p) => (p.startsWith("<project_context>") ? p : scrubPiHarness(p))).join("");
341
382
  }
342
383
  /**
343
384
  * Env switch that bypasses the system-prompt rewrite entirely, so pi's
@@ -425,7 +466,7 @@ export function brandSystemPrompt(original, opts = {}) {
425
466
  .replace(`\n\n${TICKET_IMAGE_RULE_HEADER}\n${TICKET_IMAGE_RULE}`, "")
426
467
  .replace(`\n\n${GITHUB_OPERATIONS}`, "");
427
468
  if (!s.includes(SANDBOX_SECTION_HEADER)) {
428
- s = `${s}\n\n${SANDBOX_SECTION}`;
469
+ s = `${s}\n\n${opts.cloud ? SANDBOX_CLOUD_SECTION : SANDBOX_SECTION}`;
429
470
  }
430
471
  }
431
472
  else if (!s.includes(TICKET_IMAGE_RULE_HEADER)) {
@@ -46,6 +46,7 @@ import { sandboxFailureKey } from "./permission/approvedPrefixes.js";
46
46
  import { createEscapeTally } from "./sandbox/escapeTally.js";
47
47
  import { createGrantCensus } from "./permission/grantCensus.js";
48
48
  import { runsInSandboxHarness, toolchainSpawnHookFromEnv, toolchainUserBashOperations } from "./toolchain/spawnHook.js";
49
+ import { CLOUD_SESSION_ENV } from "./pipeline/cloud.js";
49
50
  import { registerTelemetry as defaultRegisterTelemetry } from "./telemetry/register.js";
50
51
  import { loadHooksConfig, makeHookRunner, registerHooks } from "./hooks.js";
51
52
  import { loadPermissionRules } from "./permissionRules/loadConfig.js";
@@ -1584,6 +1585,9 @@ export async function registerYagni(pi, deps = {}) {
1584
1585
  // tool the section sends GitHub reads to) is registered. A local eval
1585
1586
  // compare sets eval mode without the harness, so it keeps the laptop's.
1586
1587
  const sandboxSession = evalMode && runsInSandboxHarness(env);
1588
+ // A stage child of `yagni go --headless --cloud`: headlessGo sets the env
1589
+ // where it parses the flag, and the session is told the cloud sandbox.
1590
+ const cloudSession = sandboxSession && env[CLOUD_SESSION_ENV] === "1";
1587
1591
  pi.on("before_agent_start", (event) => noBranding
1588
1592
  ? undefined
1589
1593
  : {
@@ -1608,6 +1612,7 @@ export async function registerYagni(pi, deps = {}) {
1608
1612
  attributionSection: attributionPromptSection(attribution()),
1609
1613
  fileToolSteering: condensedToolsRegistered,
1610
1614
  ...(sandboxSession ? { sandbox: true } : {}),
1615
+ ...(cloudSession ? { cloud: true } : {}),
1611
1616
  }),
1612
1617
  });
1613
1618
  // Turn-lifecycle WAL: a `turn_start` with no matching `turn_end` is the
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The secret and binary scans the host runs when it stores and publishes a
3
+ * build's change, run inside the build's own verify (YAG-1000) so a hit comes
4
+ * back as a finding the fix turn removes, instead of a stop a person answers
5
+ * after the session has ended.
6
+ *
7
+ * Only the host's STOP rules are here, with its patterns, kept in step by hand
8
+ * (this package does not depend on the backend): the credential formats and
9
+ * live authorization headers of `PROVIDER_SECRET_PATTERNS` and
10
+ * `scanSecretSurface` in agent/runs/mission/codeCandidateMaterializer.ts; a
11
+ * random value assigned to a credential's name in code that ships (its
12
+ * `high_entropy_secret`); a credential of this session's own in the change
13
+ * (`assertDiffSafeForStorage` in integrations/e2b/candidatePersistence.ts);
14
+ * and a binary file other than an image (`binaryDiffPaths`). The host's
15
+ * note-only guesses (a random-looking string with no credential's name) stay
16
+ * on the host, where they are notes. The host's scans stay the backstop.
17
+ *
18
+ * Only what this run added is scanned: added lines of tracked files, and new
19
+ * files whole, never the tree's pre-run dirt.
20
+ */
21
+ import type { Finding } from "./types.js";
22
+ /** A test, fixture, doc or example file: its credentials are fakes written to be read. */
23
+ export declare function isSamplePath(path: string): boolean;
24
+ /** Text a file gained, with the line number each added line has in the file. */
25
+ export interface AddedText {
26
+ path: string;
27
+ lines: Array<{
28
+ line: number;
29
+ text: string;
30
+ }>;
31
+ }
32
+ /**
33
+ * The stop-class secret findings in what this run added, one per kind and
34
+ * line. `sessionSecrets` are this session's own credentials (the run token),
35
+ * matched by value and never echoed.
36
+ */
37
+ export declare function scanAddedText(files: readonly AddedText[], sessionSecrets?: readonly string[]): Finding[];
38
+ /** A binary file this run added or changed, other than an image: the host never stores one. */
39
+ export declare function binaryFindings(paths: readonly string[]): Finding[];
40
+ /**
41
+ * A unified diff (`-U0`) as the text each file gained: its `+` lines with
42
+ * their new line numbers. Deleted files and binary sections gain nothing.
43
+ */
44
+ export declare function addedTextOfDiff(diff: string): AddedText[];
45
+ /** The git seam: argv run in `cwd`, resolving its exit code and output. */
46
+ export type ScanExec = (argv: string[], cwd: string, signal?: AbortSignal) => Promise<{
47
+ code: number;
48
+ output: string;
49
+ }>;
50
+ export interface ChangeScanOpts {
51
+ exec: ScanExec;
52
+ /** This session's own credentials, matched by value. */
53
+ sessionSecrets?: readonly string[];
54
+ readFile?: (path: string) => Buffer;
55
+ }
56
+ /**
57
+ * Scan what this run added on top of HEAD: tracked files' added lines, new
58
+ * files whole, minus `skip` (the tree's pre-run dirt). Null when git could
59
+ * not say what changed: the gate then notes the scan did not run.
60
+ */
61
+ export declare function scanChange(cwd: string, opts: ChangeScanOpts, skip?: readonly string[], signal?: AbortSignal): Promise<Finding[] | null>;
62
+ //# sourceMappingURL=changeScan.d.ts.map
@@ -0,0 +1,307 @@
1
+ /**
2
+ * The secret and binary scans the host runs when it stores and publishes a
3
+ * build's change, run inside the build's own verify (YAG-1000) so a hit comes
4
+ * back as a finding the fix turn removes, instead of a stop a person answers
5
+ * after the session has ended.
6
+ *
7
+ * Only the host's STOP rules are here, with its patterns, kept in step by hand
8
+ * (this package does not depend on the backend): the credential formats and
9
+ * live authorization headers of `PROVIDER_SECRET_PATTERNS` and
10
+ * `scanSecretSurface` in agent/runs/mission/codeCandidateMaterializer.ts; a
11
+ * random value assigned to a credential's name in code that ships (its
12
+ * `high_entropy_secret`); a credential of this session's own in the change
13
+ * (`assertDiffSafeForStorage` in integrations/e2b/candidatePersistence.ts);
14
+ * and a binary file other than an image (`binaryDiffPaths`). The host's
15
+ * note-only guesses (a random-looking string with no credential's name) stay
16
+ * on the host, where they are notes. The host's scans stay the backstop.
17
+ *
18
+ * Only what this run added is scanned: added lines of tracked files, and new
19
+ * files whole, never the tree's pre-run dirt.
20
+ */
21
+ import { readFileSync } from "node:fs";
22
+ import { join } from "node:path";
23
+ /** Credential formats: each stops publication in every file. */
24
+ const PROVIDER_SECRET_PATTERNS = [
25
+ [
26
+ "private_key",
27
+ /-----BEGIN (?:(?:RSA|EC|DSA|OPENSSH|ENCRYPTED) )?PRIVATE KEY-----|-----BEGIN PGP PRIVATE KEY BLOCK-----/i,
28
+ ],
29
+ ["github_token", /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})\b/],
30
+ ["aws_access_key", /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/],
31
+ ["slack_token", /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/],
32
+ ["anthropic_key", /\bsk-ant-(?:api\d{2}-)?[A-Za-z0-9_-]{20,}\b/],
33
+ ["openai_key", /\bsk-(?:proj-|svcacct-)?[A-Za-z0-9_-]{20,}\b/],
34
+ ["stripe_secret", /\b(?:(?:sk|rk)_live_[A-Za-z0-9]{16,}|whsec_[A-Za-z0-9]{16,})\b/],
35
+ ["google_api_key", /\bAIza[0-9A-Za-z_-]{20,}\b/],
36
+ ["gitlab_token", /\bglpat-[A-Za-z0-9_-]{20,}\b/],
37
+ ["npm_token", /\bnpm_[A-Za-z0-9]{20,}\b/],
38
+ ["sendgrid_key", /\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\b/],
39
+ ["yagni_code_token", /\byagnicode_[A-Za-z0-9_-]{20,}\b/],
40
+ [
41
+ "credentialed_url",
42
+ /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis):\/\/[^:\s/@]+:[^@\s/]{8,}@/i,
43
+ ],
44
+ ];
45
+ /** A name that says its value is a credential (`API_SECRET`, `aws_secret_access_key`, `DB_PASSWORD`). */
46
+ const SENSITIVE_NAME = "(?:[A-Za-z0-9]+_)*(?:api[_-]?key|client[_-]?secret|access[_-]?token|access[_-]?key|private[_-]?key|secret[_-]?key|auth[_-]?token|authorization|credentials?|password|passwd|secret|token)";
47
+ /** A name whose value people choose from words, so words are no sign it is not one. */
48
+ const PASSWORD_NAME = /pass(?:word|wd)|pwd/i;
49
+ const QUOTED_ASSIGNMENT = new RegExp(`\\b${SENSITIVE_NAME}\\b[\\s\\]}'".-]{0,40}[:=]\\s*["'\\x60]([^"'\\x60\\r\\n]{12,512})["'\\x60]`, "gi");
50
+ const UNQUOTED_ASSIGNMENT = new RegExp(`\\b${SENSITIVE_NAME}\\b[\\s\\]}'".-]{0,40}[:=]\\s*([A-Za-z0-9_+./=-]{16,512})(?=[\\s,;}#]|$)`, "gim");
51
+ const AUTHORIZATION = /\bauthorization\b[\s\]}'".-]{0,40}[:=]\s*["'`](?:bearer|basic)\s+([^\s"'`]{12,512})["'`]/gi;
52
+ const IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".ico"];
53
+ function shannonEntropy(value) {
54
+ const frequencies = new Map();
55
+ for (const character of value)
56
+ frequencies.set(character, (frequencies.get(character) ?? 0) + 1);
57
+ let entropy = 0;
58
+ for (const count of frequencies.values()) {
59
+ const probability = count / value.length;
60
+ entropy -= probability * Math.log2(probability);
61
+ }
62
+ return entropy;
63
+ }
64
+ function looksLikePlaceholder(value) {
65
+ return (/(?:example|placeholder|change[-_ ]?me|not[-_ ]?a[-_ ]?real|dummy|sample|redacted|your[-_ ]?(?:key|secret|token)|<[^>]+>|\$\{)/.test(value.toLowerCase()) || /^(.)\1{11,}$/.test(value));
66
+ }
67
+ function looksLikeHighEntropySecret(value) {
68
+ const trimmed = value.trim();
69
+ if (trimmed.length < 16 ||
70
+ trimmed.length > 512 ||
71
+ looksLikePlaceholder(trimmed) ||
72
+ /^[0-9a-f]{40}$|^[0-9a-f]{64}$|^[0-9a-f]{128}$/i.test(trimmed) ||
73
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(trimmed) ||
74
+ /^-----BEGIN (?:PUBLIC KEY|CERTIFICATE)-----$/i.test(trimmed) ||
75
+ /^(?:https?:\/\/|process\.env\.|env\.)/i.test(trimmed)) {
76
+ return false;
77
+ }
78
+ const compact = trimmed.replace(/\s/g, "");
79
+ if (compact.length < 16)
80
+ return false;
81
+ const classes = [/[a-z]/, /[A-Z]/, /[0-9]/, /[^A-Za-z0-9]/].filter((pattern) => pattern.test(compact)).length;
82
+ const entropy = shannonEntropy(compact);
83
+ return entropy >= 3.8 || (entropy >= 3.25 && classes >= 3);
84
+ }
85
+ /** A name a program gives something (`verifyWorkerAuthorization`, `NMT-DISCOVERY-FLATLINE-KEY`), not a value it keeps. */
86
+ function identifierShaped(value) {
87
+ if (!/^[A-Za-z0-9_./:#@-]+$/.test(value))
88
+ return false;
89
+ if ((value.match(/\d/g)?.length ?? 0) > 3)
90
+ return false;
91
+ const words = value
92
+ .split(/[^A-Za-z0-9]+/)
93
+ .flatMap((part) => part.split(/(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/))
94
+ .filter(Boolean);
95
+ if (words.length < 2)
96
+ return false;
97
+ const characters = words.reduce((sum, word) => sum + word.length, 0);
98
+ return (characters >= words.length * 4 &&
99
+ words.every((word) => /^(?:[A-Z]?[a-z]+|[A-Z]+)\d{0,3}$|^[a-z]*\d{1,3}[a-z]*$/.test(word) &&
100
+ (word.length >= 2 || /^\d$/.test(word)) &&
101
+ (word.length < 5 || /[aeiouy]/i.test(word))));
102
+ }
103
+ /** A test, fixture, doc or example file: its credentials are fakes written to be read. */
104
+ export function isSamplePath(path) {
105
+ return (/(?:^|\/)(?:tests?|__tests__|specs?|e2e|fixtures?|__fixtures__|testdata)\//.test(path) ||
106
+ /\.(?:test|spec)\.[A-Za-z0-9]+$|_(?:test|spec)\.[A-Za-z0-9]+$|(?:^|\/)test_[^/]*\.py$/.test(path) ||
107
+ /[a-z0-9]Tests?\.(?:php|java|kt|cs|swift|scala|groovy)$/.test(path) ||
108
+ /(?:^|\/)(?:docs?|examples?|samples?)\//.test(path) ||
109
+ /\.(?:md|mdx|markdown|rst|adoc)$/i.test(path));
110
+ }
111
+ function isImagePath(path) {
112
+ const lower = path.toLowerCase();
113
+ return IMAGE_EXTENSIONS.some((extension) => lower.endsWith(extension));
114
+ }
115
+ const WORDS = {
116
+ private_key: "a private key",
117
+ github_token: "a GitHub token",
118
+ aws_access_key: "an AWS access key",
119
+ slack_token: "a Slack token",
120
+ anthropic_key: "an Anthropic key",
121
+ openai_key: "an OpenAI key",
122
+ stripe_secret: "a Stripe secret",
123
+ google_api_key: "a Google API key",
124
+ gitlab_token: "a GitLab token",
125
+ npm_token: "an npm token",
126
+ sendgrid_key: "a SendGrid key",
127
+ yagni_code_token: "a YAGNI Code token",
128
+ credentialed_url: "a database URL with its password",
129
+ authorization_token: "a live authorization header",
130
+ high_entropy_secret: "a random value assigned to a credential's name",
131
+ session_credential: "a credential from this session",
132
+ };
133
+ function finding(path, line, kind) {
134
+ return {
135
+ severity: "critical",
136
+ lens: "does_it_hold",
137
+ file: path,
138
+ line,
139
+ message: `secret scan: this adds ${WORDS[kind] ?? kind.replace(/_/g, " ")}, and YAGNI will not publish a change that holds one. ` +
140
+ "Take it out (read it from the environment or a secret store instead). A test value must not look like a real credential: use an obvious fake such as `test-token`.",
141
+ };
142
+ }
143
+ /**
144
+ * The stop-class secret findings in what this run added, one per kind and
145
+ * line. `sessionSecrets` are this session's own credentials (the run token),
146
+ * matched by value and never echoed.
147
+ */
148
+ export function scanAddedText(files, sessionSecrets = []) {
149
+ const out = [];
150
+ const seen = new Set();
151
+ const secrets = sessionSecrets.filter((secret) => secret.length >= 16);
152
+ for (const file of files) {
153
+ const text = file.lines.map((entry) => entry.text).join("\n");
154
+ const starts = [];
155
+ let offset = 0;
156
+ for (const entry of file.lines) {
157
+ starts.push(offset);
158
+ offset += entry.text.length + 1;
159
+ }
160
+ const lineAt = (index) => {
161
+ let at = 0;
162
+ while (at + 1 < starts.length && starts[at + 1] <= index)
163
+ at += 1;
164
+ return file.lines[at]?.line ?? 1;
165
+ };
166
+ const add = (kind, index) => {
167
+ const line = lineAt(index);
168
+ const key = `${file.path}\u0000${line}\u0000${kind}`;
169
+ if (seen.has(key))
170
+ return;
171
+ seen.add(key);
172
+ out.push(finding(file.path, line, kind));
173
+ };
174
+ for (const [kind, pattern] of PROVIDER_SECRET_PATTERNS) {
175
+ const index = text.search(pattern);
176
+ if (index >= 0)
177
+ add(kind, index);
178
+ }
179
+ for (const match of text.matchAll(AUTHORIZATION)) {
180
+ if (!looksLikePlaceholder(match[1] ?? ""))
181
+ add("authorization_token", match.index ?? 0);
182
+ }
183
+ if (!isSamplePath(file.path)) {
184
+ const named = (index, value, assignment) => {
185
+ if (!looksLikeHighEntropySecret(value))
186
+ return;
187
+ if (identifierShaped(value) && !PASSWORD_NAME.test(assignment.slice(0, -value.length)))
188
+ return;
189
+ add("high_entropy_secret", index);
190
+ };
191
+ for (const match of text.matchAll(QUOTED_ASSIGNMENT)) {
192
+ named(match.index ?? 0, match[1] ?? "", match[0].slice(0, -1));
193
+ }
194
+ for (const match of text.matchAll(UNQUOTED_ASSIGNMENT))
195
+ named(match.index ?? 0, match[1] ?? "", match[0]);
196
+ }
197
+ for (const secret of secrets) {
198
+ const index = text.indexOf(secret);
199
+ if (index >= 0)
200
+ add("session_credential", index);
201
+ }
202
+ }
203
+ return out;
204
+ }
205
+ /** A binary file this run added or changed, other than an image: the host never stores one. */
206
+ export function binaryFindings(paths) {
207
+ return paths
208
+ .filter((path) => !isImagePath(path))
209
+ .map((path) => ({
210
+ severity: "critical",
211
+ lens: "does_it_hold",
212
+ file: path,
213
+ message: "binary scan: this adds or changes a binary file, and YAGNI will not publish a change that holds one (images are the exception). " +
214
+ "Remove it, or produce it from source in the build instead of committing it.",
215
+ }));
216
+ }
217
+ /**
218
+ * A unified diff (`-U0`) as the text each file gained: its `+` lines with
219
+ * their new line numbers. Deleted files and binary sections gain nothing.
220
+ */
221
+ export function addedTextOfDiff(diff) {
222
+ const files = [];
223
+ let current;
224
+ let next = 0;
225
+ for (const raw of diff.split("\n")) {
226
+ if (raw.startsWith("diff --git ")) {
227
+ current = undefined;
228
+ continue;
229
+ }
230
+ if (raw.startsWith("+++ ")) {
231
+ const path = raw.slice(4).replace(/\t.*$/, "");
232
+ current = path === "/dev/null" ? undefined : { path: path.replace(/^b\//, ""), lines: [] };
233
+ if (current)
234
+ files.push(current);
235
+ continue;
236
+ }
237
+ const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(raw);
238
+ if (hunk) {
239
+ next = Number(hunk[1]);
240
+ continue;
241
+ }
242
+ if (current && raw.startsWith("+")) {
243
+ current.lines.push({ line: next, text: raw.slice(1) });
244
+ next += 1;
245
+ }
246
+ }
247
+ return files.filter((file) => file.lines.length > 0);
248
+ }
249
+ /** A new file's bytes hold a NUL in their first 8000: git's own test for binary. */
250
+ function bytesAreBinary(bytes) {
251
+ return bytes.subarray(0, 8000).includes(0);
252
+ }
253
+ /** How much of one new file is scanned: a credential sits in source, not past the first megabyte. */
254
+ const MAX_SCANNED_FILE_BYTES = 1024 * 1024;
255
+ /**
256
+ * Scan what this run added on top of HEAD: tracked files' added lines, new
257
+ * files whole, minus `skip` (the tree's pre-run dirt). Null when git could
258
+ * not say what changed: the gate then notes the scan did not run.
259
+ */
260
+ export async function scanChange(cwd, opts, skip = [], signal) {
261
+ // `git status --porcelain` lists an untracked folder as `dir/`, so a skip
262
+ // entry ending in `/` covers every file under it (FINISH leaves it out of
263
+ // the commit the same way).
264
+ const skipFiles = new Set(skip.filter((path) => !path.endsWith("/")));
265
+ const skipDirs = skip.filter((path) => path.endsWith("/"));
266
+ const skipped = {
267
+ has: (path) => skipFiles.has(path) || skipDirs.some((dir) => path.startsWith(dir)),
268
+ };
269
+ const git = (args) => opts.exec(["git", "-c", "core.quotepath=off", ...args], cwd, signal);
270
+ // A deleted binary is no binary in the change: the host lets it through.
271
+ const [numstat, diff, others] = await Promise.all([
272
+ git(["diff", "HEAD", "--numstat", "--no-renames", "--no-ext-diff", "--diff-filter=d"]),
273
+ git(["diff", "HEAD", "-U0", "--no-color", "--no-renames", "--no-ext-diff"]),
274
+ git(["ls-files", "--others", "--exclude-standard"]),
275
+ ]);
276
+ if (numstat.code !== 0 || diff.code !== 0 || others.code !== 0)
277
+ return null;
278
+ const binary = numstat.output
279
+ .split("\n")
280
+ .map((line) => /^-\t-\t(.+)$/.exec(line)?.[1])
281
+ .filter((path) => Boolean(path) && !skipped.has(path));
282
+ const added = addedTextOfDiff(diff.output).filter((file) => !skipped.has(file.path));
283
+ const read = opts.readFile ?? ((path) => readFileSync(path));
284
+ for (const path of others.output.split("\n").map((line) => line.trim())) {
285
+ if (!path || skipped.has(path))
286
+ continue;
287
+ let bytes;
288
+ try {
289
+ bytes = read(join(cwd, path));
290
+ }
291
+ catch {
292
+ continue;
293
+ }
294
+ if (bytesAreBinary(bytes)) {
295
+ binary.push(path);
296
+ continue;
297
+ }
298
+ const lines = bytes
299
+ .subarray(0, MAX_SCANNED_FILE_BYTES)
300
+ .toString("utf8")
301
+ .split("\n")
302
+ .map((text, index) => ({ line: index + 1, text }));
303
+ added.push({ path, lines });
304
+ }
305
+ return [...binaryFindings(binary), ...scanAddedText(added, opts.sessionSecrets ?? [])];
306
+ }
307
+ //# sourceMappingURL=changeScan.js.map