@sous-io/sous 0.2.33 → 0.2.34

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.
@@ -341,6 +341,22 @@ name in `_vars`, for instance `taskFileDir: "${sousDir}/../${taskFileRoot}"`.
341
341
 
342
342
  The `_env` block is still the way to reach any environment variable no recipe asks about.
343
343
 
344
+ ### Secrets stay out of scope dumps
345
+
346
+ A secret renders only where a template names it, as in `{{ apiToken }}`. The two tags that print the whole
347
+ scope leave secrets out: `{% showVars %}` prints `(hidden)` in place of the value, and
348
+ `{% exportScalarVarsJs %}` drops the variable, because runtime code importing the module would read a mask
349
+ as a real value. Both treat a variable as a secret when either holds:
350
+
351
+ - A recipe declares it `secret: true`.
352
+ - It very probably is one, by a thin check that needs no declaration: a name whose last words say so
353
+ (`githubToken`, `OPENAI_API_KEY`, `dbPassword`, `clientSecret`) holding more than a number or a switch, or
354
+ a value in a widely used token format (a GitHub or GitLab token, an AWS access key id, a private key block,
355
+ a JSON Web Token, a URL carrying a password).
356
+
357
+ So `serviceApiKey: "abc123"` in `_vars` is hidden from a dump, while `maxTokens: "4096"` and
358
+ `requireToken: "true"` still show.
359
+
344
360
  ## Where to go next
345
361
 
346
362
  - [Consuming recipes](repositories-consuming.md): subscribing, building, and what lands where
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sous-io/sous",
3
- "version": "0.2.33",
3
+ "version": "0.2.34",
4
4
  "description": "Compiles AI coding agent configuration (CLAUDE.md, skills, memories) from LiquidJS templates",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -205,7 +205,10 @@ Sous plugin; a Sous plugin comes only from sous's built-ins or an npm-style modu
205
205
 
206
206
  **`showVars`**: dumps all variables currently in scope as a fenced JSON block.
207
207
  Useful during development to see exactly what variables are available at a given point
208
- in a template. Remove before finalizing.
208
+ in a template. Remove before finalizing. A secret prints as `(hidden)`: a variable a
209
+ recipe declares `secret: true`, or one that very probably is a secret by its name
210
+ (`githubToken`, `OPENAI_API_KEY`) or its format (a GitHub token, a private key block). A
211
+ template that names a secret explicitly, as in `{% raw %}{{ apiToken }}{% endraw %}`, still renders it.
209
212
 
210
213
  **`getFiles`**: globs files under a root directory and assigns the resulting array to a
211
214
  template variable. It renders nothing; present the results yourself with a `for` loop.
@@ -238,7 +241,8 @@ render paths relative to the root instead of bare file names:
238
241
 
239
242
  **`exportScalarVarsJs`**: emits every in-scope scalar variable (string, finite number,
240
243
  boolean) as an ES module default export, keys sorted. Objects, arrays, `null` and
241
- non-finite numbers are skipped. Use it to compile a settings module that runtime code
244
+ non-finite numbers are skipped, and so is every secret (the same ones `showVars` hides),
245
+ since runtime code would read a mask as a real value. Use it to compile a settings module that runtime code
242
246
  imports, rather than re-deriving project configuration:
243
247
 
244
248
  {% raw %}
@@ -294,7 +298,8 @@ project's variables change.
294
298
 
295
299
  ## Available Variables
296
300
 
297
- The following variables are in scope at compile time in this project:
301
+ The following variables are in scope at compile time in this project (secrets show as
302
+ `(hidden)`):
298
303
 
299
304
  {% showVars %}
300
305
 
@@ -11,7 +11,7 @@ formatVersion: 1
11
11
 
12
12
  namespace: core
13
13
  name: sous-skills
14
- version: 0.2.33
14
+ version: 0.2.34
15
15
 
16
16
  description: >-
17
17
  The skills that teach an agent what sous is and how it works: which files sous
@@ -118,13 +118,15 @@ export function resolveRecipeTargets(
118
118
 
119
119
  const missing = unansweredWarning(resolved, rootScope);
120
120
  if (missing !== undefined) recipes.warnings.push(missing);
121
- return recipes;
121
+ return { ...recipes, secretVariables: [...resolved.secrets] };
122
122
  }
123
123
 
124
124
  /**
125
125
  * Adds the recipe targets to a project's own compilation config. A project with
126
126
  * no compilation block of its own still compiles its recipes, so the config is
127
- * created when there is none and there is something to compile.
127
+ * created when there is none and there is something to compile. The names the
128
+ * recipes declare secret go onto the config either way, because a project's own
129
+ * template may dump the scope too.
128
130
  *
129
131
  * @param config - The project's own compilation config, or null when it has none.
130
132
  * @param recipes - The targets the subscribed recipes contribute.
@@ -137,15 +139,17 @@ export function withRecipeTargets(
137
139
  settings: Settings,
138
140
  rootScope: Record<string, string>
139
141
  ): CompilationConfig | null {
140
- if (recipes.targets.length === 0) return config;
142
+ const secrets = recipes.secretVariables?.length ? { secretVariables: recipes.secretVariables } : {};
143
+ if (recipes.targets.length === 0) return config === null ? null : { ...config, ...secrets };
141
144
  if (config === null) {
142
145
  return {
143
146
  targets: recipes.targets,
144
147
  aliases: resolveAliases(settings, rootScope),
145
148
  includeScope: rootScope,
149
+ ...secrets,
146
150
  };
147
151
  }
148
- return { ...config, targets: [...config.targets, ...recipes.targets] };
152
+ return { ...config, targets: [...config.targets, ...recipes.targets], ...secrets };
149
153
  }
150
154
 
151
155
  /**
@@ -68,6 +68,12 @@ export type CompilationConfig = {
68
68
  views?: ViewMap;
69
69
  /** Variable scope for `${var}` substitution in `@include` paths. */
70
70
  includeScope?: Record<string, string>;
71
+ /**
72
+ * Every variable name a recipe declares `secret: true`. The tags that dump a
73
+ * whole scope hide these (and anything that very probably is a secret); a
74
+ * template that names one explicitly still renders it.
75
+ */
76
+ secretVariables?: string[];
71
77
  };
72
78
 
73
79
  export type CompilationServiceOptions = {
@@ -209,6 +215,8 @@ export class CompilationService {
209
215
  private numberFormatter: Intl.NumberFormat;
210
216
  private aliases: AliasMap;
211
217
  private includeScope: Record<string, string>;
218
+ /** Every variable name a recipe declares secret, for the scope-dumping tags. */
219
+ private secretVariables: string[];
212
220
  private namespaceResolver?: NamespaceResolver;
213
221
  /**
214
222
  * `.tpl.` outputs that were written without a variable scope, so LiquidJS never
@@ -232,6 +240,7 @@ export class CompilationService {
232
240
  this.numberFormatter = new Intl.NumberFormat("en-US");
233
241
  this.aliases = {};
234
242
  this.includeScope = {};
243
+ this.secretVariables = [];
235
244
  this.namespaceResolver = options.namespaceResolver;
236
245
  this.unrenderedTemplates = [];
237
246
  }
@@ -535,6 +544,7 @@ export class CompilationService {
535
544
  scope: { ...this.includeScope, ...vars },
536
545
  namespaceResolver: this.namespaceResolver,
537
546
  fromFile,
547
+ secretVariables: this.secretVariables,
538
548
  });
539
549
  try {
540
550
  return await engine.parseAndRender(content, vars);
@@ -700,9 +710,13 @@ ${taskFileContents}
700
710
  // A rendered output depends on its variables as much as on its source: a
701
711
  // changed answer or `_vars` value with the same template must re-render,
702
712
  // so the variable scope is part of a `.tpl.` output's source hash. A
703
- // verbatim copy hashes its content alone.
713
+ // verbatim copy hashes its content alone. The secret names are part of it
714
+ // too, because they decide what a scope-dumping tag hides.
704
715
  const srcHash = rendersAnything && output.vars
705
- ? hashContent(`${contentHash}\n${stableVarsFingerprint(output.vars)}`)
716
+ ? hashContent(
717
+ `${contentHash}\n${stableVarsFingerprint(output.vars)}\n` +
718
+ `secrets:${JSON.stringify([...this.secretVariables].sort())}`
719
+ )
706
720
  : contentHash;
707
721
 
708
722
  // Skip if content is unchanged and file already exists (unless --rebuild)
@@ -859,6 +873,7 @@ ${taskFileContents}
859
873
  this.views = config.views ?? {};
860
874
  this.includedFilesSeen = new Set();
861
875
  this.includeScope = config.includeScope ?? {};
876
+ this.secretVariables = config.secretVariables ?? [];
862
877
 
863
878
  this.initializeEncoder();
864
879
 
@@ -78,6 +78,11 @@ export type RecipeTargets = {
78
78
  watchDirs: string[];
79
79
  /** Complete, plain-language sentences a build should print before it compiles. */
80
80
  warnings: string[];
81
+ /**
82
+ * Every variable name a recipe declares `secret: true`, which the scope-dumping
83
+ * template tags hide. Set by the build from the resolved answers.
84
+ */
85
+ secretVariables?: string[];
81
86
  };
82
87
 
83
88
  /**
@@ -23,6 +23,11 @@
23
23
  * is something to tell the user about, not a reason to refuse to build the
24
24
  * rest of the project.
25
25
  *
26
+ * Alongside them comes `secrets`: every name some definition declares
27
+ * `secret: true`. The tags that dump a whole scope (`{% showVars %}`,
28
+ * `{% exportScalarVarsJs %}`) read it so a secret answer never lands in
29
+ * compiled output unless a template names it explicitly.
30
+ *
26
31
  * A definition's `default` counts as an answer of last resort: the
27
32
  * description a publisher writes promises what the default does, and a
28
33
  * template rendering an empty string instead would break that promise.
@@ -49,6 +54,8 @@ export interface RecipeAnswers {
49
54
  byRecipe: Map<string, VarScope>;
50
55
  /** Every required definition that nothing answered and that has no default. */
51
56
  unanswered: DefinedVariable[];
57
+ /** Every variable name some definition declares `secret: true`, answered or not. */
58
+ secrets: Set<string>;
52
59
  }
53
60
 
54
61
  /** How the answers are resolved. */
@@ -76,7 +83,7 @@ export interface RecipeAnswerOptions {
76
83
 
77
84
  /** The empty result, for a project that locks nothing. */
78
85
  export function noRecipeAnswers(): RecipeAnswers {
79
- return { merged: {}, byRecipe: new Map(), unanswered: [] };
86
+ return { merged: {}, byRecipe: new Map(), unanswered: [], secrets: new Set() };
80
87
  }
81
88
 
82
89
  /**
@@ -103,6 +110,7 @@ export function resolveRecipeAnswers(options: RecipeAnswerOptions): RecipeAnswer
103
110
  for (const defined of definitions) {
104
111
  const value = answerFor(defined, context);
105
112
  const name = defined.definition.name;
113
+ if (defined.definition.secret) result.secrets.add(name);
106
114
 
107
115
  if (value === undefined) {
108
116
  if (defined.definition.required) result.unanswered.push(defined);
@@ -19,9 +19,7 @@ import {
19
19
  type DefiningRecipe,
20
20
  } from "./definition-source.js";
21
21
  import { constraintBullets } from "./validate.js";
22
-
23
- /** What the value column shows for a secret whose answer is known. */
24
- export const HIDDEN_VALUE = "(hidden)";
22
+ import { HIDDEN_VALUE } from "./secrets.js";
25
23
 
26
24
  /** What the value column shows for a variable nothing has answered. */
27
25
  export const UNANSWERED_VALUE = "(unanswered)";
@@ -17,3 +17,4 @@ export * from "./ask.js";
17
17
  export * from "./report.js";
18
18
  export * from "./preanswers.js";
19
19
  export * from "./question-plan.js";
20
+ export * from "./secrets.js";
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Which variables hold a secret.
3
+ *
4
+ * A recipe marks a variable `secret: true`, and that declaration is the
5
+ * authority. Everything else in a template's scope (an `_env` mapping, a
6
+ * project `_vars` entry, an answer to a definition that forgot the flag) is
7
+ * checked by a deliberately thin heuristic: a name whose last words say it is
8
+ * a secret (`githubToken`, `OPENAI_API_KEY`, `dbPassword`), or a value in one
9
+ * of the widely used token formats (a GitHub or GitLab token, an AWS access
10
+ * key, a private key block, a URL carrying a password). The heuristic only
11
+ * catches what is very probably a secret; it is a safety net, not a scanner.
12
+ *
13
+ * This module imports nothing, so the template tags can use it without
14
+ * pulling the variables layer into the template engine.
15
+ */
16
+
17
+ /** What a hidden value shows in place of the secret. */
18
+ export const HIDDEN_VALUE = "(hidden)";
19
+
20
+ /**
21
+ * Name endings that mark a secret, as lowercase words. A name matches when its
22
+ * last words are one of these, so `apiToken` and `GITHUB_TOKEN` match while
23
+ * `tokenLimit` does not.
24
+ */
25
+ const SECRET_NAME_ENDINGS: readonly (readonly string[])[] = [
26
+ ["token"],
27
+ ["secret"],
28
+ ["secrets"],
29
+ ["password"],
30
+ ["passwd"],
31
+ ["passphrase"],
32
+ ["credential"],
33
+ ["credentials"],
34
+ ["apikey"],
35
+ ["api", "key"],
36
+ ["private", "key"],
37
+ ["access", "key"],
38
+ ["secret", "key"],
39
+ ["signing", "key"],
40
+ ["encryption", "key"],
41
+ ];
42
+
43
+ /** Widely used secret formats, each specific enough that a match is very probably a secret. */
44
+ const SECRET_VALUE_PATTERNS: readonly RegExp[] = [
45
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----/, // a PEM private key
46
+ /\bgh[pousr]_[A-Za-z0-9]{36,}/, // GitHub token
47
+ /\bgithub_pat_[A-Za-z0-9_]{22,}/, // GitHub fine-grained token
48
+ /\bglpat-[A-Za-z0-9_-]{20,}/, // GitLab personal access token
49
+ /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/, // AWS access key id
50
+ /\bxox[abposr]-[A-Za-z0-9-]{10,}/, // Slack token
51
+ /\b[rs]k_live_[A-Za-z0-9]{16,}/, // Stripe live key
52
+ /\bsk-ant-[A-Za-z0-9_-]{20,}/, // Anthropic API key
53
+ /\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}/, // OpenAI API key
54
+ /\bAIza[0-9A-Za-z_-]{35}/, // Google API key
55
+ /\bnpm_[A-Za-z0-9]{36}\b/, // npm token
56
+ /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/, // JSON Web Token
57
+ /^[a-z][a-z0-9+.-]*:\/\/[^/\s:@]+:[^/\s@]+@/i, // a URL carrying a password
58
+ ];
59
+
60
+ /** Values a secret-sounding name may hold without being a secret: nothing, a number, a switch. */
61
+ const PLAIN_VALUE = /^(?:|-?\d+(?:\.\d+)?|true|false|yes|no|on|off)$/i;
62
+
63
+ /**
64
+ * The words in a variable name, lowercased: split at `_`, `-`, `.`, spaces and
65
+ * camelCase boundaries, so `OPENAI_API_KEY`, `openaiApiKey` and `openai-api-key`
66
+ * all read as `openai api key`.
67
+ *
68
+ * @param name - The variable name.
69
+ */
70
+ function nameWords(name: string): string[] {
71
+ return name
72
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
73
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
74
+ .split(/[^A-Za-z0-9]+/)
75
+ .filter((word) => word.length > 0)
76
+ .map((word) => word.toLowerCase());
77
+ }
78
+
79
+ /**
80
+ * Whether a variable name says it holds a secret.
81
+ *
82
+ * @param name - The variable name.
83
+ */
84
+ export function hasSecretName(name: string): boolean {
85
+ const words = nameWords(name);
86
+ return SECRET_NAME_ENDINGS.some(
87
+ (ending) =>
88
+ words.length >= ending.length &&
89
+ ending.every((word, i) => words[words.length - ending.length + i] === word)
90
+ );
91
+ }
92
+
93
+ /**
94
+ * Whether a value is in one of the widely used secret formats.
95
+ *
96
+ * @param value - The value.
97
+ */
98
+ export function hasSecretValue(value: string): boolean {
99
+ return SECRET_VALUE_PATTERNS.some((pattern) => pattern.test(value));
100
+ }
101
+
102
+ /**
103
+ * Whether a variable very probably holds a secret: its name says so (and its
104
+ * value is more than a number or a switch), or its value is in a known secret
105
+ * format.
106
+ *
107
+ * @param name - The variable name.
108
+ * @param value - Its value.
109
+ */
110
+ export function looksLikeSecret(name: string, value: string): boolean {
111
+ if (hasSecretValue(value)) return true;
112
+ return hasSecretName(name) && !PLAIN_VALUE.test(value.trim());
113
+ }
114
+
115
+ /**
116
+ * Whether a variable is to be treated as a secret: a recipe declared it one,
117
+ * or the heuristic says it very probably is.
118
+ *
119
+ * @param name - The variable name.
120
+ * @param value - Its value.
121
+ * @param declared - The names recipes declared `secret: true`.
122
+ */
123
+ export function isSecretVariable(
124
+ name: string,
125
+ value: unknown,
126
+ declared: ReadonlySet<string> = new Set()
127
+ ): boolean {
128
+ if (typeof value !== "string") return false;
129
+ if (declared.has(name)) return value.length > 0;
130
+ return looksLikeSecret(name, value);
131
+ }
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { Liquid, type FS } from "liquidjs";
4
4
  import filterRegistrars from "./filters/index.js";
5
5
  import tagRegistrars from "./tags/index.js";
6
+ import { setSecretNames } from "./lib/secret-scope.js";
6
7
  import { resolveIncludeFiles, type AliasMap, type ViewMap } from "../lib/include-resolver.js";
7
8
  import {
8
9
  formatNamespaceProblem,
@@ -25,6 +26,12 @@ export type EngineAliasOptions = {
25
26
  * resolves them against, which is enough to locate the owning recipe.
26
27
  */
27
28
  fromFile?: string;
29
+ /**
30
+ * The variable names a recipe declared `secret: true`. `{% showVars %}` and
31
+ * `{% exportScalarVarsJs %}` hide them, along with anything that very probably
32
+ * is a secret.
33
+ */
34
+ secretVariables?: Iterable<string>;
28
35
  };
29
36
 
30
37
  /**
@@ -127,5 +134,6 @@ export function createLiquidEngine(roots: string[], aliasOpts: EngineAliasOption
127
134
  register(engine);
128
135
  }
129
136
 
137
+ setSecretNames(engine, aliasOpts.secretVariables ?? []);
130
138
  return engine;
131
139
  }
@@ -0,0 +1,41 @@
1
+ import type { Liquid } from "liquidjs";
2
+ import { HIDDEN_VALUE, isSecretVariable } from "../../lib/vars/secrets.js";
3
+
4
+ /**
5
+ * The variable names each engine was told are secret. Kept beside the engine
6
+ * rather than in its scope, so the list itself never shows up in a dump.
7
+ */
8
+ const secretNamesByEngine = new WeakMap<Liquid, ReadonlySet<string>>();
9
+
10
+ /**
11
+ * Records the variable names a recipe declared `secret: true` for one engine.
12
+ *
13
+ * @param engine - The engine the tags render with.
14
+ * @param names - The declared names.
15
+ */
16
+ export function setSecretNames(engine: Liquid, names: Iterable<string>): void {
17
+ secretNamesByEngine.set(engine, new Set(names));
18
+ }
19
+
20
+ /**
21
+ * The declared secret names for an engine; empty when none were recorded.
22
+ *
23
+ * @param engine - The engine the tags render with.
24
+ */
25
+ export function secretNamesFor(engine: Liquid): ReadonlySet<string> {
26
+ return secretNamesByEngine.get(engine) ?? new Set();
27
+ }
28
+
29
+ /**
30
+ * Whether a scope entry is a secret a whole-scope dump must not print: a
31
+ * declared secret, or one the heuristic in `lib/vars/secrets.ts` recognizes.
32
+ *
33
+ * @param engine - The engine the tag renders with.
34
+ * @param name - The variable name.
35
+ * @param value - Its value.
36
+ */
37
+ export function isSecretEntry(engine: Liquid, name: string, value: unknown): boolean {
38
+ return isSecretVariable(name, value, secretNamesFor(engine));
39
+ }
40
+
41
+ export { HIDDEN_VALUE };
@@ -1,6 +1,7 @@
1
1
  import type { Liquid } from "liquidjs";
2
2
  import type { Context } from "liquidjs/dist/context/context.js";
3
3
  import { sortObjectKeys } from "../../utils/formatting.js";
4
+ import { isSecretEntry } from "../lib/secret-scope.js";
4
5
 
5
6
  /**
6
7
  * A "scalar" for export purposes: the value types that can be safely embedded
@@ -20,7 +21,9 @@ function isScalar(value: unknown): value is string | number | boolean {
20
21
  * Iterates every variable currently in the LiquidJS scope, keeps only scalars
21
22
  * (strings, finite numbers, booleans), sorts them by key, and emits a valid
22
23
  * `export default { ... };` block. Objects, arrays, functions, null, undefined,
23
- * and non-finite numbers are skipped.
24
+ * and non-finite numbers are skipped, and so is every secret (one a recipe
25
+ * declared, or one that very probably is): runtime code would read a masked
26
+ * value as a real one, so a secret is left out rather than hidden.
24
27
  *
25
28
  * Intended for compiling a `settings.tpl.mjs` that downstream runtime code
26
29
  * (e.g. browser automation scripts) imports for project configuration.
@@ -32,7 +35,7 @@ export function registerExportScalarVarsJsTag(engine: Liquid): void {
32
35
  const scalars: Record<string, string | number | boolean> = {};
33
36
 
34
37
  for (const [key, value] of Object.entries(scope)) {
35
- if (isScalar(value)) scalars[key] = value;
38
+ if (isScalar(value) && !isSecretEntry(engine, key, value)) scalars[key] = value;
36
39
  }
37
40
 
38
41
  const sorted = sortObjectKeys(scalars);
@@ -1,18 +1,24 @@
1
1
  import type { Liquid } from "liquidjs";
2
2
  import type { Context } from "liquidjs/dist/context/context.js";
3
3
  import { sortObjectKeys } from "../../utils/formatting.js";
4
+ import { HIDDEN_VALUE, isSecretEntry } from "../lib/secret-scope.js";
4
5
 
5
- /** Dumps all variables currently in scope as a fenced JSON block. */
6
+ /**
7
+ * Dumps all variables currently in scope as a fenced JSON block. A secret (one a
8
+ * recipe declared, or one that very probably is) prints as `(hidden)`, so the
9
+ * dump never carries it into compiled output.
10
+ */
6
11
  export function registerShowVarsTag(engine: Liquid): void {
7
12
  engine.registerTag("showVars", {
8
13
  render(ctx: Context) {
9
14
  const seen = new WeakSet();
10
15
  const scope = sortObjectKeys(ctx.getAll() as Record<string, unknown>);
11
- const json = JSON.stringify(scope, (_key, value) => {
16
+ const json = JSON.stringify(scope, (key, value) => {
12
17
  if (typeof value === "object" && value !== null) {
13
18
  if (seen.has(value)) return "[Circular]";
14
19
  seen.add(value);
15
20
  }
21
+ if (key !== "" && isSecretEntry(engine, key, value)) return HIDDEN_VALUE;
16
22
  return value;
17
23
  }, 2);
18
24