Skip to content

Commit d25d0c2

Browse files
committed
fix(approval): address R4 review feedback on PR #69
- tokenize: normalize shell-quote output to drop {comment} tokens and convert {op:"glob"} tokens to their literal pattern strings, preventing crashes on non-string objects and ensuring path-based deny rules match glob patterns (e.g. rm -rf /tmp/*) - github-utils: fix extractGitHubRepo regex to capture dots in repo names (e.g. socket.io) by using ([^/]+?) with optional .git suffix stripping instead of ([^/.]+) - gh-exemption: handle -R=owner/repo combined short flag form by scanning raw args, since parseSegment expands multi-char short flags per-character and loses the value - prescan: remove unreachable { decision: "continue" } variant from PrescanResult type, add explicit PrescanLineResult type for prescanLine - check-command: replace unreachable "lines" in check with lines.length === 0 now that normalizeAndSplit always returns lines 323 tests pass (+7 new).
1 parent ab67a93 commit d25d0c2

9 files changed

Lines changed: 105 additions & 14 deletions

File tree

approval/__tests__/gh-exemption.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ describe("extractRepoTarget", () => {
5555
);
5656
expect(extractRepoTarget(seg)).toEqual({ owner: "owner", repo: "repo" });
5757
});
58+
test("extracts from -R=owner/repo (combined short flag form)", () => {
59+
const seg = parseSegment(["gh", "issue", "list", "-R=owner/repo"], false);
60+
expect(extractRepoTarget(seg)).toEqual({ owner: "owner", repo: "repo" });
61+
});
5862
test("returns 'implicit' for gh pr create without --repo", () => {
5963
const seg = parseSegment(["gh", "pr", "create", "--title", "test"], false);
6064
expect(extractRepoTarget(seg)).toBe("implicit");

approval/__tests__/github-utils.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,21 @@ describe("extractGitHubRepo", () => {
3939
test("returns null for non-GitHub URL", () => {
4040
expect(extractGitHubRepo("https://gitlab.com/owner/repo.git")).toBeNull();
4141
});
42+
test("handles repo name with dots (e.g. socket.io)", () => {
43+
expect(extractGitHubRepo("https://github.com/socketio/socket.io")).toEqual({
44+
owner: "socketio",
45+
repo: "socket.io",
46+
});
47+
});
48+
test("handles repo name with dots and .git suffix", () => {
49+
expect(extractGitHubRepo("git@github.com:socketio/socket.io.git")).toEqual({
50+
owner: "socketio",
51+
repo: "socket.io",
52+
});
53+
});
54+
test("handles SSH URL with dots in repo name", () => {
55+
expect(
56+
extractGitHubRepo("ssh://git@github.com/socketio/socket.io.git"),
57+
).toEqual({ owner: "socketio", repo: "socket.io" });
58+
});
4259
});

approval/__tests__/pipeline.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { evaluateRules } from "../rules";
1010
function quickEval(command: string): "deny" | "escalate" | "allow" {
1111
const splitResult = normalizeAndSplit(command);
1212
if (splitResult.decision === "deny") return "deny";
13-
if (!("lines" in splitResult)) return "allow";
13+
if (splitResult.lines.length === 0) return "allow";
1414

1515
let worst: "allow" | "escalate" | "deny" = "allow";
1616

approval/__tests__/tokenize.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,24 @@ describe("tokenize", () => {
4747
const strings = tokens.filter((t): t is string => typeof t === "string");
4848
expect(strings[0]).toBe("$SHELL");
4949
});
50+
51+
test("drops comment tokens from output", () => {
52+
const tokens = tokenize("echo hello # this is a comment");
53+
// Comment should be stripped — only string and operator tokens remain
54+
expect(tokens).toEqual(["echo", "hello"]);
55+
});
56+
57+
test("converts glob tokens to pattern strings", () => {
58+
const tokens = tokenize("echo *.txt");
59+
// Glob should become its literal pattern string
60+
expect(tokens).toEqual(["echo", "*.txt"]);
61+
});
62+
63+
test("preserves glob pattern with path for deny rule matching", () => {
64+
const tokens = tokenize("rm -rf /tmp/*");
65+
const strings = tokens.filter((t): t is string => typeof t === "string");
66+
expect(strings).toContain("/tmp/*");
67+
});
5068
});
5169

5270
describe("splitSegments", () => {

approval/check-command.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ async function evaluateCommand(raw: string): Promise<SegmentResult> {
106106
return { decision: "deny", reason: splitResult.reason };
107107
}
108108

109-
if (!("lines" in splitResult)) {
109+
if (splitResult.lines.length === 0) {
110110
return { decision: "allow", reason: "empty command" };
111111
}
112112

approval/gh-exemption.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ export function extractRepoTarget(
3939
return null;
4040
}
4141

42-
// --repo=value or -R=value (from flags map)
43-
const repoFlag = flags.get("--repo") ?? flags.get("-R");
42+
// --repo=value (from flags map, parsed as --key=value)
43+
const repoFlag = flags.get("--repo");
4444
if (typeof repoFlag === "string") {
4545
const parts = repoFlag.split("/");
4646
if (parts.length === 2 && parts[0] && parts[1]) {
@@ -49,6 +49,19 @@ export function extractRepoTarget(
4949
return null;
5050
}
5151

52+
// -R=value — parseSegment expands multi-char short flags per-character,
53+
// so -R=owner/repo becomes individual char flags and the value is lost.
54+
// Scan raw args for the combined form.
55+
const attachedShortRepo = args.find((arg) => arg.startsWith("-R="));
56+
if (attachedShortRepo) {
57+
const val = attachedShortRepo.slice(3);
58+
const parts = val.split("/");
59+
if (parts.length === 2 && parts[0] && parts[1]) {
60+
return { owner: parts[0], repo: parts[1] };
61+
}
62+
return null;
63+
}
64+
5265
// -R value (next arg) — short flags are expanded, so -R is in flags as true
5366
// but the value is the next arg after -R in the args array
5467
const rIdx = args.indexOf("-R");

approval/github-utils.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@ export function extractGitHubOwner(url: string): string | null {
2323

2424
export function extractGitHubRepo(url: string): RepoTarget | null {
2525
const patterns = [
26-
/https?:\/\/github\.com\/([^/]+)\/([^/.]+)/,
27-
/git@github\.com:([^/]+)\/([^/.]+)/,
28-
/ssh:\/\/git@github\.com\/([^/]+)\/([^/.]+)/,
26+
/https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/,
27+
/git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/,
28+
/ssh:\/\/git@github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/,
2929
];
3030

3131
for (const pattern of patterns) {

approval/prescan.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
export type PrescanResult =
22
| { decision: "deny"; reason: string }
33
| { decision: "escalate"; reason: string }
4-
| { decision: "continue"; lines: string[] }
5-
| { decision: "continue" };
4+
| { decision: "continue"; lines: string[] };
65

76
const INVALID_CONTROL_CHARS = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
87

@@ -20,7 +19,9 @@ const DEBUG_TELEMETRY = /GH_DEBUG|OTEL_EXPORTER_OTLP_HEADERS/;
2019

2120
export function normalizeAndSplit(
2221
raw: string,
23-
): Extract<PrescanResult, { lines: string[] } | { decision: "deny" }> {
22+
):
23+
| { decision: "deny"; reason: string }
24+
| { decision: "continue"; lines: string[] } {
2425
if (raw.includes("\0")) {
2526
return { decision: "deny", reason: "null byte in command" };
2627
}
@@ -41,9 +42,12 @@ export function normalizeAndSplit(
4142
return { decision: "continue", lines };
4243
}
4344

44-
export function prescanLine(
45-
line: string,
46-
): Exclude<PrescanResult, { lines: string[] }> {
45+
export type PrescanLineResult =
46+
| { decision: "deny"; reason: string }
47+
| { decision: "escalate"; reason: string }
48+
| { decision: "continue" };
49+
50+
export function prescanLine(line: string): PrescanLineResult {
4751
if (CREDENTIAL_REF.test(line)) {
4852
return { decision: "deny", reason: "credential reference in command" };
4953
}

approval/tokenize.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,43 @@ function preserveEnvReference(key: string): string {
3434
return `$${key}`;
3535
}
3636

37+
/**
38+
* Normalize raw shell-quote output into strict ShellToken shapes.
39+
* shell-quote can emit { comment: string } for # comments and
40+
* { op: "glob", pattern: string } for unquoted wildcards. Comments
41+
* are dropped; globs are converted to their literal pattern string
42+
* so path-based deny rules still match (e.g. /tmp/*).
43+
*/
44+
function normalizeToken(token: unknown): ShellToken | null {
45+
if (typeof token === "string") return token;
46+
if (!token || typeof token !== "object") return null;
47+
48+
// Drop shell comments — not relevant for rule evaluation
49+
if ("comment" in token) return null;
50+
51+
// Convert glob tokens to literal pattern strings so they appear
52+
// in args/positionals for deny rule matching
53+
const record = token as Record<string, unknown>;
54+
if (record.op === "glob" && typeof record.pattern === "string") {
55+
return record.pattern;
56+
}
57+
58+
// Known operator tokens
59+
if ("op" in token && typeof (token as { op: unknown }).op === "string") {
60+
return { op: (token as { op: string }).op };
61+
}
62+
63+
return null;
64+
}
65+
3766
export function tokenize(line: string): ShellToken[] {
38-
return parse(line, preserveEnvReference) as ShellToken[];
67+
const raw = parse(line, preserveEnvReference) as unknown[];
68+
const tokens: ShellToken[] = [];
69+
for (const token of raw) {
70+
const normalized = normalizeToken(token);
71+
if (normalized !== null) tokens.push(normalized);
72+
}
73+
return tokens;
3974
}
4075

4176
export function splitSegments(tokens: ShellToken[]): RawSegment[] {

0 commit comments

Comments
 (0)