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
8 changes: 8 additions & 0 deletions .changeset/shell-positional-args.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@memnox/core': patch
'@memnox/proxy': patch
'@memnox/interceptors': patch
'memnox': patch
---

The governed shell now passes the words after a `-c` line on to the real shell, so `$0`, `$1` and `"$@"` arrive as they were given, and a `--` after the line no longer switches it into the argv form. Options that take a value, such as `-o pipefail`, keep their value instead of it being run as a script.
3 changes: 2 additions & 1 deletion packages/interceptors/src/shell-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
pidSessionId,
} from './seam-runtime';
import {
commandShellArgs,
parseShellInvocation,
realShell,
SHELL_MODE,
Expand Down Expand Up @@ -65,7 +66,7 @@
/**
* A signal is `128 + n`, as a shell reports one;
* no code and no signal means it never ran.
*/

Check warning

Code scanning / CodeQL

Indirect uncontrolled command line Medium

This command depends on an unsanitized
command-line argument
.
This command depends on an unsanitized
command-line argument
.
This command depends on an unsanitized
command-line argument
.
This command depends on an unsanitized
command-line argument
.
This command depends on an unsanitized
command-line argument
.
function signalledExit(signal: NodeJS.Signals | null): number {
if (signal === null || !isNumberedSignal(signal)) return SHELL_EXIT_WITHHELD;
return exitCodeForSignal(signal);
Expand Down Expand Up @@ -179,7 +180,7 @@
await keepBeforeDestroying(line);
const status =
invocation.mode === SHELL_MODE.COMMAND
? await run(shell, [...invocation.flags, '-c', invocation.line ?? ''])
? await run(shell, commandShellArgs(invocation))
: await runResolved(outcome.run);
if (status === null) return SHELL_EXIT_OK;
await keep({ decision: outcome.decision, line, exitCode: status, startedAt });
Expand Down
49 changes: 41 additions & 8 deletions packages/interceptors/src/shell-invocation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,31 +20,54 @@ export interface ShellInvocation {
mode: ShellMode;
/** The line to gate and to hand on unchanged, for the `-c` form. */
line?: string;
/** The words after the `-c` line, which the shell reads as `$0`, `$1` and on. */
positional?: string[];
/** The command to gate and to spawn, for the `--` form. */
argv?: string[];
/** Flags seen before `-c`, so the real shell is invoked as it was asked to be. */
flags: string[];
}

/** Options whose next word is their value rather than a script path. */
const OPTIONS_WITH_VALUE: ReadonlySet<string> = new Set([
'-o',
'+o',
'-O',
'+O',
'--rcfile',
'--init-file',
]);

/** `-c`, and the combined forms a login or interactive shell arrives as: `-lc`, `-ic`. */
function isCommandFlag(argument: string): boolean {
return /^-[a-z]*c$/.test(argument);
}

export function parseShellInvocation(argv: readonly string[]): ShellInvocation {
const separator = argv.indexOf('--');
if (separator !== -1) {
return { mode: SHELL_MODE.ARGV, argv: [...argv.slice(separator + 1)], flags: [] };
}

const flags: string[] = [];
for (const [index, argument] of argv.entries()) {
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index] ?? '';
// Only a `--` ahead of any -c ends the options, since after the line it is `$0`.
if (argument === '--') {
return { mode: SHELL_MODE.ARGV, argv: [...argv.slice(index + 1)], flags: [] };
}
if (isCommandFlag(argument)) {
const line = argv[index + 1];
if (line === undefined) break;
return { mode: SHELL_MODE.COMMAND, line, flags };
return {
mode: SHELL_MODE.COMMAND,
line,
positional: [...argv.slice(index + 2)],
flags,
};
}
if (argument.startsWith('-')) {
if (OPTIONS_WITH_VALUE.has(argument)) {
const value = argv[index + 1];
flags.push(...(value === undefined ? [argument] : [argument, value]));
index += 1;
continue;
}
if (argument.startsWith('-') || argument.startsWith('+')) {
flags.push(argument);
continue;
}
Expand All @@ -55,6 +78,16 @@ export function parseShellInvocation(argv: readonly string[]): ShellInvocation {
return { mode: SHELL_MODE.INTERACTIVE, flags };
}

/** The real shell's argv for the `-c` form, positionals kept so `$0` and `$1` survive. */
export function commandShellArgs(invocation: ShellInvocation): string[] {
return [
...invocation.flags,
'-c',
invocation.line ?? '',
...(invocation.positional ?? []),
];
}

export const REAL_SHELL_VAR = 'MEMNOX_REAL_SHELL';

/**
Expand Down
36 changes: 36 additions & 0 deletions packages/interceptors/test/shell-line.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { spawnSync } from 'node:child_process';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
Expand All @@ -7,6 +8,7 @@ import type { HookAuthorizer } from '../src/hook-authorizer';
import { HookAuthorizer as RealAuthorizer } from '../src/hook-authorizer';
import { ShellSeam, SHELL_EXIT_OK, SHELL_EXIT_WITHHELD } from '../src/shell-seam';
import {
commandShellArgs,
FALLBACK_SHELL,
realShell,
parseShellInvocation,
Expand Down Expand Up @@ -127,6 +129,40 @@ describe('how a shell was invoked', () => {
it('treats no command as a shell somebody wants to type into', () => {
expect(parseShellInvocation([]).mode).toBe(SHELL_MODE.INTERACTIVE);
});

it('keeps the words after the -c line, which the line reads as $0 and $1', () => {
const invocation = parseShellInvocation(['-c', 'echo "$1"', '_', 'hi']);

expect(invocation.mode).toBe(SHELL_MODE.COMMAND);
expect(invocation.line).toBe('echo "$1"');
expect(invocation.positional).toEqual(['_', 'hi']);
});

it('reads a -- after the -c line as $0 rather than as the argv form', () => {
const invocation = parseShellInvocation(['-c', 'exec "$@"', '--', 'prog', 'a']);

expect(invocation.mode).toBe(SHELL_MODE.COMMAND);
expect(invocation.line).toBe('exec "$@"');
expect(invocation.positional).toEqual(['--', 'prog', 'a']);
});

it('takes the value of -o with it rather than reading it as a script', () => {
const invocation = parseShellInvocation(['-o', 'pipefail', '-c', 'cmd']);

expect(invocation.mode).toBe(SHELL_MODE.COMMAND);
expect(invocation.line).toBe('cmd');
expect(invocation.flags).toEqual(['-o', 'pipefail']);
});

it('hands the real shell the positionals unchanged, after the line', () => {
const argv = ['-e', '-c', 'echo "$1"', '_', 'hi'];
const invocation = parseShellInvocation(argv);

expect(commandShellArgs(invocation)).toEqual(argv);
// The literal is spawned rather than the parsed copy, which the line above proves equal.
const ran = spawnSync(FALLBACK_SHELL, argv, { encoding: 'utf8' });
expect(ran.stdout).toBe('hi\n');
});
});

describe('the shell handed the command on', () => {
Expand Down
13 changes: 8 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,15 @@ packages:
allowBuilds:
better-sqlite3: true
esbuild: true

# source-map-js is a transitive dev dependency of vitest and tsup pulled below
# GHSA-68fv-2mgg-jv7q; the audit fails the build at high, so hold it at the patched line.
overrides:
source-map-js: '>=1.2.2'

# braces is a transitive dev dependency of changesets, knip and vitest with no patched
# release yet (GHSA-vfj7-8cjw-p6xm lists none and 3.0.3 is still the latest), so the only
# way past the gate is to ignore it. Drop this line the day a fixed braces ships.
auditConfig:
ignoreGhsas:
- GHSA-vfj7-8cjw-p6xm
Loading