vouchington-tooling 0.8.1 → 0.9.0

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.
package/README.md CHANGED
@@ -208,8 +208,21 @@ import { runRetrospectiveTranscript } from 'vouchington-tooling/retrospective-tr
208
208
  import { appendJournal, probeBlackboard } from 'vouchington-tooling/agent-blackboard'
209
209
  import { buildSessionFrictionReport, recordFriction } from 'vouchington-tooling/session-friction'
210
210
  import { createPullRequest, getDiffAgainstBase, runGh, runGit } from 'vouchington-tooling/gh-cli'
211
+ import {
212
+ shellScriptViolations,
213
+ workflowYamlViolations,
214
+ } from 'vouchington-tooling/gh-api-shell-quoting'
211
215
  ```
212
216
 
217
+ `shellScriptViolations`/`workflowYamlViolations` flag a `gh api` call whose argument carries an
218
+ unquoted `?` or `&`: an unquoted `&` silently backgrounds the command and truncates the query
219
+ (the call still exits 0), and an unquoted `?` fails loudly under zsh glob-nomatch but passes
220
+ through unexpanded under bash. `workflowYamlViolations` decodes quoted and folded YAML `run:`
221
+ scalars before scanning, so a hazard hidden by YAML's own quote-stripping is still caught; it
222
+ throws on a `run:` value that is a YAML alias or a multiline PLAIN scalar, shapes it cannot yet
223
+ scan safely. Both functions scan already-in-scope source text — deciding which files count as a
224
+ shell script or a workflow/action YAML file is left to the caller.
225
+
213
226
  `checkWorkspaceGatesPolicy` rejects tracked test assertions that hard-code the exact version of a
214
227
  dependency declared by a non-fixture package manifest. Assert dependency membership or placement,
215
228
  or derive a configuration or documentation package spec from that manifest instead.
@@ -0,0 +1,5 @@
1
+ import type { ShellQuotingViolation } from './yaml.mts';
2
+ export type { ShellQuotingViolation } from './yaml.mts';
3
+ export { workflowYamlViolations } from './yaml.mts';
4
+ /** For a plain shell script: `source` is the whole file, so hit offsets are already absolute. */
5
+ export declare function shellScriptViolations(source: string): ShellQuotingViolation[];
@@ -0,0 +1,17 @@
1
+ // Detects `gh api <arg>` invocations, in a shell script or a workflow/composite-action `run:`
2
+ // block, where the argument carries an unquoted `?` or `&`. An unquoted `&` backgrounds the
3
+ // command and drops every argument after it — the truncated call still exits 0, so CI can report
4
+ // success on a silently short-circuited query. An unquoted `?` fails loudly under zsh
5
+ // glob-nomatch but passes through unexpanded under bash.
6
+ //
7
+ // This module only scans already-in-scope source text; deciding which files count as a shell
8
+ // script or a workflow/action YAML file is repo-layout policy left to the caller.
9
+ import { ghApiShellQuotingHits, lineNumberAt } from './scan.mjs';
10
+ export { workflowYamlViolations } from './yaml.mjs';
11
+ /** For a plain shell script: `source` is the whole file, so hit offsets are already absolute. */
12
+ export function shellScriptViolations(source) {
13
+ return ghApiShellQuotingHits(source).map((hit) => ({
14
+ line: lineNumberAt(source, hit.offset),
15
+ excerpt: hit.excerpt,
16
+ }));
17
+ }
@@ -0,0 +1,7 @@
1
+ export type ShellQuotingHit = {
2
+ offset: number;
3
+ excerpt: string;
4
+ };
5
+ export declare function lineNumberAt(source: string, offset: number): number;
6
+ /** Hits in `text` with offsets relative to `text` itself, not any enclosing document. */
7
+ export declare function ghApiShellQuotingHits(text: string): ShellQuotingHit[];
@@ -0,0 +1,181 @@
1
+ // Pure `gh api` shell-argument scanner: detects an unquoted `?` or `&` in a `gh api` call's
2
+ // argument list. An unquoted `&` backgrounds the command and drops every argument after it — the
3
+ // truncated call still exits 0, so CI can report success on a silently short-circuited query. An
4
+ // unquoted `?` fails loudly under zsh glob-nomatch but passes through unexpanded under bash. See
5
+ // ./yaml.mts and ./index.mts for what feeds this and how it maps hits back to source locations.
6
+ //
7
+ // This is a small heuristic scanner, not a shell parser: it tracks quote state, backslash
8
+ // escapes, and `$(...)` command-substitution nesting per logical line (physical lines joined
9
+ // across a trailing, unescaped `\`). It stops once it finds the first unsafe character in a
10
+ // call's argument list. A general-purpose shell tokenizer whose "expandable" notion conflates
11
+ // glob metacharacters with `$`/backtick expansion would false-positive on a quoted
12
+ // `"…${VAR}…?per_page=1"`, so this scanner tracks its own narrower quote-state machine instead.
13
+ const GH_API_BOUNDARY = /[\s;|&(){}]/;
14
+ const GH_API_HEAD = /^gh[ \t]+api\b/;
15
+ function matchGhApiHead(text, index) {
16
+ if (index > 0 && !GH_API_BOUNDARY.test(text[index - 1]))
17
+ return 0;
18
+ const match = GH_API_HEAD.exec(text.slice(index));
19
+ return match ? match[0].length : 0;
20
+ }
21
+ function excerptAround(text, index) {
22
+ return text.slice(Math.max(0, index - 24), Math.min(text.length, index + 12)).trim();
23
+ }
24
+ function newFrame() {
25
+ return { inSingleQuote: false, inDoubleQuote: false, scanningArgs: false, parenDepth: 0 };
26
+ }
27
+ function pushSubstitutionFrame(stack) {
28
+ const frame = newFrame();
29
+ frame.parenDepth = 1;
30
+ stack.push(frame);
31
+ }
32
+ // Scans one already-joined logical line for `gh api` calls and returns at most one hit per call —
33
+ // the first unquoted `?` or `&`, whichever comes first. `offsets[k]` maps character k of `text`
34
+ // back to its absolute offset in the original source, so callers can report real line numbers.
35
+ function scanLogicalLine(text, offsets) {
36
+ const hits = [];
37
+ const stack = [newFrame()];
38
+ let i = 0;
39
+ while (i < text.length) {
40
+ const frame = stack[stack.length - 1];
41
+ const char = text[i];
42
+ if (frame.inSingleQuote) {
43
+ if (char === "'")
44
+ frame.inSingleQuote = false;
45
+ i += 1;
46
+ continue;
47
+ }
48
+ if (char === '\\') {
49
+ i += 2;
50
+ continue;
51
+ }
52
+ if (frame.inDoubleQuote) {
53
+ if (char === '"') {
54
+ frame.inDoubleQuote = false;
55
+ i += 1;
56
+ continue;
57
+ }
58
+ if (char === '$' && text[i + 1] === '(') {
59
+ pushSubstitutionFrame(stack);
60
+ i += 2;
61
+ continue;
62
+ }
63
+ i += 1;
64
+ continue;
65
+ }
66
+ if (char === '#' && (i === 0 || /\s/.test(text[i - 1])))
67
+ break;
68
+ if (char === "'") {
69
+ frame.inSingleQuote = true;
70
+ i += 1;
71
+ continue;
72
+ }
73
+ if (char === '"') {
74
+ frame.inDoubleQuote = true;
75
+ i += 1;
76
+ continue;
77
+ }
78
+ if (char === '$' && text[i + 1] === '(') {
79
+ pushSubstitutionFrame(stack);
80
+ i += 2;
81
+ continue;
82
+ }
83
+ if (stack.length > 1 && (char === '(' || char === ')')) {
84
+ frame.parenDepth += char === '(' ? 1 : -1;
85
+ i += 1;
86
+ if (frame.parenDepth === 0)
87
+ stack.pop();
88
+ continue;
89
+ }
90
+ if (frame.scanningArgs) {
91
+ if (char === ';' || char === '|') {
92
+ frame.scanningArgs = false;
93
+ i += 1;
94
+ continue;
95
+ }
96
+ if (char === '&') {
97
+ if (text[i + 1] === '&') {
98
+ frame.scanningArgs = false;
99
+ i += 2;
100
+ continue;
101
+ }
102
+ // `2>&1`/`>&2`/`<&3` fd-duplication redirects don't end the argument list; whitespace-
103
+ // preceded `&` is Bash's background operator and does. Only an `&` embedded in an
104
+ // unquoted argument is unsafe.
105
+ if (i > 0 && /[><]/.test(text[i - 1])) {
106
+ i += 1;
107
+ continue;
108
+ }
109
+ if (!(i > 0 && /[ \t]/.test(text[i - 1])))
110
+ hits.push({ offset: offsets[i], excerpt: excerptAround(text, i) });
111
+ frame.scanningArgs = false;
112
+ i += 1;
113
+ continue;
114
+ }
115
+ if (char === '?') {
116
+ hits.push({ offset: offsets[i], excerpt: excerptAround(text, i) });
117
+ frame.scanningArgs = false;
118
+ i += 1;
119
+ continue;
120
+ }
121
+ }
122
+ const headLength = frame.scanningArgs ? 0 : matchGhApiHead(text, i);
123
+ if (headLength > 0) {
124
+ frame.scanningArgs = true;
125
+ i += headLength;
126
+ continue;
127
+ }
128
+ i += 1;
129
+ }
130
+ return hits;
131
+ }
132
+ // A logical line joins physical lines across a trailing, unescaped `\` — bash line continuation —
133
+ // so a `gh api \` / URL-on-next-line split (common in multi-line `run:` steps) is scanned as one
134
+ // command instead of two truncated fragments.
135
+ function endsWithLineContinuation(line) {
136
+ let backslashRun = 0;
137
+ for (let i = line.length - 1; i >= 0 && line[i] === '\\'; i -= 1)
138
+ backslashRun += 1;
139
+ return backslashRun % 2 === 1;
140
+ }
141
+ function joinLogicalLines(source) {
142
+ const lines = [];
143
+ let position = 0;
144
+ while (position <= source.length) {
145
+ let text = '';
146
+ const offsets = [];
147
+ for (;;) {
148
+ const newlineIndex = source.indexOf('\n', position);
149
+ const lineEnd = newlineIndex === -1 ? source.length : newlineIndex;
150
+ const physical = source.slice(position, lineEnd);
151
+ const continues = endsWithLineContinuation(physical);
152
+ const contentEnd = continues ? physical.length - 1 : physical.length;
153
+ for (let i = 0; i < contentEnd; i += 1) {
154
+ text += physical[i];
155
+ offsets.push(position + i);
156
+ }
157
+ position = lineEnd + 1;
158
+ if (!continues || newlineIndex === -1)
159
+ break;
160
+ }
161
+ lines.push({ text, offsets });
162
+ if (position > source.length)
163
+ break;
164
+ }
165
+ return lines;
166
+ }
167
+ export function lineNumberAt(source, offset) {
168
+ let line = 1;
169
+ for (let i = 0; i < offset; i += 1)
170
+ if (source[i] === '\n')
171
+ line += 1;
172
+ return line;
173
+ }
174
+ /** Hits in `text` with offsets relative to `text` itself, not any enclosing document. */
175
+ export function ghApiShellQuotingHits(text) {
176
+ const hits = [];
177
+ for (const logicalLine of joinLogicalLines(text)) {
178
+ hits.push(...scanLogicalLine(logicalLine.text, logicalLine.offsets));
179
+ }
180
+ return hits;
181
+ }
@@ -0,0 +1,6 @@
1
+ export type ShellQuotingViolation = {
2
+ line: number;
3
+ excerpt: string;
4
+ };
5
+ /** For a workflow/composite-action YAML file: scans every `run:` block's effective shell text. */
6
+ export declare function workflowYamlViolations(source: string): ShellQuotingViolation[];
@@ -0,0 +1,95 @@
1
+ // Extracts and scans `run:` step bodies from a GitHub Actions workflow or composite-action YAML
2
+ // document for the same unquoted `?`/`&` hazard `./scan.mts` detects in a plain shell script. See
3
+ // ./index.mts for the exported entry points.
4
+ import { isAlias, isScalar, parseDocument, visit } from 'yaml';
5
+ import { ghApiShellQuotingHits, lineNumberAt } from './scan.mjs';
6
+ // Any `run:` scalar anywhere in the document is a shell step body — workflow job steps and
7
+ // composite-action steps both use the same key. This module does not resolve YAML aliases: a
8
+ // `run:` value has no reason to be shared via an anchor, so instead of silently skipping an
9
+ // aliased `run:` (which would let an unsafe invocation bypass detection undetected), `runBlocks`
10
+ // throws when it finds one — see below.
11
+ //
12
+ // A folded scalar (`run: >-`) joins its source lines with spaces before the shell ever sees it, so
13
+ // `gh api` on one physical line and an unquoted `repos/x/y?a=1&b=2` on the next form one unsafe
14
+ // command at runtime even though they are two lines in the file. A quoted flow scalar
15
+ // (`run: 'gh api …?a=1&b=2'` or `run: "gh api …?a=1&b=2"`) has its outer quotes stripped by YAML
16
+ // before the shell ever sees it too, so scanning the raw source slice — which still includes those
17
+ // quotes — makes the shell scanner mistake them for argument quoting and miss a real violation.
18
+ // `text` is the already-decoded string for these shapes (`pair.value.value`, not the raw source
19
+ // slice, which still carries the block header or quote delimiters) so the scanner sees exactly
20
+ // what the shell would; `decoded` tells the caller that per-hit offsets inside `text` no longer
21
+ // correspond 1:1 to source byte offsets, since decoding discards those delimiters (and, for folded
22
+ // scalars, the original line breaks). `BLOCK_LITERAL` (`run: |`) and single-line `PLAIN` scalars
23
+ // keep their raw source slice: both scan identically decoded or not, so precise per-hit offsets
24
+ // stay correct and existing per-line assertions are unaffected. A *multiline* `PLAIN` scalar folds
25
+ // its line breaks the same way a folded scalar does, so its raw slice is not equivalent to what
26
+ // the shell sees either — worse, the raw slice's un-joined `\n` (with no trailing backslash) makes
27
+ // `ghApiShellQuotingHits` treat the continuation as an unrelated logical line, silently missing a
28
+ // `gh api` call split across it (see below). `runBlocks` throws on this shape below rather than
29
+ // decode it, mirroring the alias handling above.
30
+ const DECODED_SCALAR_TYPES = new Set([
31
+ 'BLOCK_FOLDED',
32
+ 'QUOTE_SINGLE',
33
+ 'QUOTE_DOUBLE',
34
+ ]);
35
+ function runBlocks(source) {
36
+ const document = parseDocument(source);
37
+ if (document.errors.length > 0)
38
+ throw document.errors[0];
39
+ const blocks = [];
40
+ visit(document, {
41
+ Pair(_key, pair) {
42
+ if (!isScalar(pair.key) || pair.key.value !== 'run')
43
+ return;
44
+ if (isAlias(pair.value)) {
45
+ const resolved = pair.value.resolve(document);
46
+ if (isScalar(resolved) && typeof resolved.value === 'string') {
47
+ throw new Error('run: value is a YAML alias to a string scalar — this guard does not resolve ' +
48
+ 'aliases, so an aliased shell body could bypass it undetected. Write the run: ' +
49
+ 'value inline, or extend runBlocks() to resolve aliases before adding one.');
50
+ }
51
+ return;
52
+ }
53
+ if (!isScalar(pair.value) || typeof pair.value.value !== 'string')
54
+ return;
55
+ /* v8 ignore next -- parseDocument always assigns a range to a scalar it parsed from source;
56
+ this only guards the type narrowing above, not a reachable runtime state. */
57
+ if (!pair.value.range)
58
+ throw new Error('run: scalar has no source range');
59
+ const raw = source.slice(pair.value.range[0], pair.value.range[1]);
60
+ if (pair.value.type === 'PLAIN' && raw.includes('\n')) {
61
+ throw new Error('run: value is a multiline PLAIN scalar — its raw source slice does not match the ' +
62
+ 'decoded shell text (YAML folds a PLAIN line break into a single space), and this ' +
63
+ 'guard only decodes BLOCK_FOLDED/QUOTE_SINGLE/QUOTE_DOUBLE scalars, so scanning the ' +
64
+ 'raw slice can silently miss a call split across the fold. Rewrite the run: value as ' +
65
+ 'a quoted or block scalar, or extend DECODED_SCALAR_TYPES and runBlocks() to decode ' +
66
+ 'multiline PLAIN scalars before adding one.');
67
+ }
68
+ const decoded = pair.value.type !== undefined && DECODED_SCALAR_TYPES.has(pair.value.type);
69
+ blocks.push({
70
+ offset: pair.value.range[0],
71
+ text: decoded ? pair.value.value : raw,
72
+ decoded,
73
+ });
74
+ },
75
+ });
76
+ return blocks;
77
+ }
78
+ /** For a workflow/composite-action YAML file: scans every `run:` block's effective shell text. */
79
+ export function workflowYamlViolations(source) {
80
+ const violations = [];
81
+ for (const block of runBlocks(source)) {
82
+ const blockStartLine = lineNumberAt(source, block.offset);
83
+ for (const hit of ghApiShellQuotingHits(block.text)) {
84
+ // A decoded block's delimiters (and, for folded blocks, its line breaks) are gone by the
85
+ // time the shell runs it, so there is no single source line the hit "belongs" to — report
86
+ // the block's start line instead of computing a byte offset into decoded text that no
87
+ // longer lines up with `source`.
88
+ violations.push({
89
+ line: block.decoded ? blockStartLine : lineNumberAt(source, block.offset + hit.offset),
90
+ excerpt: hit.excerpt,
91
+ });
92
+ }
93
+ }
94
+ return violations;
95
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vouchington-tooling",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "description": "Vouchington CLI and extractable tooling libraries.",
5
5
  "homepage": "https://github.com/vouchington/vouchington-tooling/tree/main/packages/vouchington-tooling#readme",
6
6
  "bugs": {
@@ -267,6 +267,11 @@
267
267
  "import": "./dist/gh-cli/index.mjs",
268
268
  "default": "./dist/gh-cli/index.mjs"
269
269
  },
270
+ "./gh-api-shell-quoting": {
271
+ "types": "./dist/gh-api-shell-quoting/index.d.mts",
272
+ "import": "./dist/gh-api-shell-quoting/index.mjs",
273
+ "default": "./dist/gh-api-shell-quoting/index.mjs"
274
+ },
270
275
  "./package.json": "./package.json"
271
276
  },
272
277
  "publishConfig": {
@@ -25,11 +25,18 @@ When an external creation target is denied, never write there. Search for and cr
25
25
  tracking issue in the current repository, or a consumer-selected tracker. Immediately before that
26
26
  write, refetch the destination repository and apply the issue-operation gate above. Include the
27
27
  intended upstream repository and a copy-ready report so a human can decide whether to file it. Before
28
- copying details to a less-restricted destination, remove private repository identity, paths, links,
29
- code, and findings; if redaction would make the report unusable, require explicit destination approval
30
- or return the draft without mutation. Authorization to file the external issue includes this tracking
31
- fallback unless the caller opts out; report the reroute explicitly. If no tracker passes, return the
32
- draft without mutation. Never fall back silently or to an unverified repository.
28
+ naming that upstream repository anywhere in the report, resolve it and compare the returned canonical
29
+ name against the name being written a renamed repository's redirect resolves successfully under the
30
+ stale name, so existence alone proves nothing. This comparison serves report accuracy, not the
31
+ mutation-authority gate above: a rename is not the hard-deny mismatch that gate defines, so report the
32
+ canonical name in its place; when the name does not resolve at all, route the follow-up to the current
33
+ repository instead of naming an unreachable target. Before copying details to a less-restricted
34
+ destination, remove private repository identity, paths, links, code, and findings; if redaction would
35
+ make the report unusable, require explicit destination approval or return the draft without mutation.
36
+ Authorization to file the external issue includes this tracking fallback unless the caller opts out;
37
+ report the reroute explicitly. If no tracker passes, return the draft without mutation. Never fall back
38
+ silently or to an unverified
39
+ repository.
33
40
 
34
41
  ## Issue workflow
35
42