@hasna/hooks 0.11.7 → 0.12.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 +128 -3
- package/bin/hooks-mcp.js +39 -7
- package/bin/index.js +736 -127
- package/bin/native-safety-entry.js +15 -1718
- package/bin/serve.js +36 -9
- package/dist/index.js +49 -16
- package/dist/lib/native-safety-registration.d.ts +49 -6
- package/dist/lib/registry.d.ts +15 -0
- package/dist/native-safety.d.ts +4 -0
- package/dist/native-safety.js +8 -4
- package/dist/sdk/index.js +1 -1
- package/hooks/hook-signed-link-guard/README.md +228 -0
- package/hooks/hook-signed-link-guard/package.json +12 -0
- package/hooks/hook-signed-link-guard/src/classify.ts +1280 -0
- package/hooks/hook-signed-link-guard/src/hook.ts +72 -0
- package/hooks/hook-signed-link-guard/src/jq.ts +545 -0
- package/hooks/hook-signed-link-guard/src/links.ts +136 -0
- package/hooks/hook-signed-link-output/README.md +47 -0
- package/hooks/hook-signed-link-output/package.json +12 -0
- package/hooks/hook-signed-link-output/src/hook.ts +111 -0
- package/hooks/hook-trash-guard/src/hook.ts +204 -80
- package/hooks/native-safety-entry.ts +28 -7
- package/package.json +2 -2
- package/scripts/validate-package.ts +12 -2
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PreToolUse hook: signed-link-guard
|
|
5
|
+
*
|
|
6
|
+
* Refuses a shell command — Claude Code's `Bash` and `Monitor` tools (Monitor
|
|
7
|
+
* streams a command's stdout to the model) — when any `gh` invocation in it
|
|
8
|
+
* would print composite
|
|
9
|
+
* GitHub content — bodies, comments, reviews, commit messages, check links and
|
|
10
|
+
* details, raw issue/pull request/check objects — where third-party signed
|
|
11
|
+
* action links appear. The refusal happens before the command runs, so the
|
|
12
|
+
* content never reaches the agent's transcript. Bounded scalar reads pass:
|
|
13
|
+
* `gh pr view <n> --json number,state,headRefOid`, `gh pr checks <n> --json
|
|
14
|
+
* name,state,bucket`, `gh api … --jq <scalar projection>`, `gh pr diff`,
|
|
15
|
+
* `gh pr list`, and every write (`create`, `comment`, `merge`, …).
|
|
16
|
+
*
|
|
17
|
+
* The rules and the field classes live in ./classify.ts; the `--jq` proof in
|
|
18
|
+
* ./jq.ts. The reason string is fixed by the engineering decision for this
|
|
19
|
+
* class and is emitted verbatim.
|
|
20
|
+
*
|
|
21
|
+
* Output contract (the same narrow shape the native safety entry accepts):
|
|
22
|
+
* - allowed -> nothing on stdout
|
|
23
|
+
* - refused -> { hookSpecificOutput: { hookEventName: "PreToolUse",
|
|
24
|
+
* permissionDecision: "deny", permissionDecisionReason } }
|
|
25
|
+
*
|
|
26
|
+
* Fail-closed on its own defects: if classification throws, a command that
|
|
27
|
+
* mentions `gh` as a word is refused with the same reason, and any other
|
|
28
|
+
* command is left alone. Nothing about the command is written to stderr.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { getCommand, readInput, respond, type CodewithHookInput, type CodewithHookOutput } from "../../codewith-native-common";
|
|
32
|
+
import { classifyCommand, SIGNED_LINK_REFUSAL } from "./classify";
|
|
33
|
+
|
|
34
|
+
export { classifyCommand, SIGNED_LINK_REFUSAL } from "./classify";
|
|
35
|
+
|
|
36
|
+
const GH_WORD = /(?:^|[^A-Za-z0-9_.-])gh(?:$|[^A-Za-z0-9_.-])/;
|
|
37
|
+
|
|
38
|
+
function deny(): CodewithHookOutput {
|
|
39
|
+
return {
|
|
40
|
+
hookSpecificOutput: {
|
|
41
|
+
hookEventName: "PreToolUse",
|
|
42
|
+
permissionDecision: "deny",
|
|
43
|
+
permissionDecisionReason: SIGNED_LINK_REFUSAL,
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Tools whose `tool_input.command` is a shell command whose output reaches the model. */
|
|
49
|
+
export const SHELL_TOOLS: ReadonlySet<string> = new Set(["Bash", "Monitor"]);
|
|
50
|
+
|
|
51
|
+
/** Verdict for one hook input. `{ continue: true }` means "no decision". */
|
|
52
|
+
export function evaluate(input: CodewithHookInput): CodewithHookOutput {
|
|
53
|
+
if (input?.hook_event_name !== "PreToolUse" || !SHELL_TOOLS.has(input.tool_name ?? "")) return { continue: true };
|
|
54
|
+
const command = getCommand(input);
|
|
55
|
+
if (!command.trim()) return { continue: true };
|
|
56
|
+
try {
|
|
57
|
+
return classifyCommand(command).refused ? deny() : { continue: true };
|
|
58
|
+
} catch {
|
|
59
|
+
return GH_WORD.test(command) ? deny() : { continue: true };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function run(): Promise<void> {
|
|
64
|
+
const verdict = evaluate(readInput());
|
|
65
|
+
// Silence is the documented no-op for Claude and Codex; emit only a decision.
|
|
66
|
+
if (!("continue" in verdict && verdict.continue === true)) respond(verdict);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (import.meta.main) {
|
|
70
|
+
await run();
|
|
71
|
+
process.exit(0);
|
|
72
|
+
}
|
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proof that a `--jq` / `-q` projection prints only bounded scalars.
|
|
3
|
+
*
|
|
4
|
+
* `gh api --jq <filter>` prints only what the filter selects, so a filter that
|
|
5
|
+
* provably ends every output at a scalar, non-free-text field keeps a
|
|
6
|
+
* composite response (a pull request, a comment list, a check run) out of the
|
|
7
|
+
* transcript. This module decides whether a filter is such a projection.
|
|
8
|
+
*
|
|
9
|
+
* It is a proof, not a guess: it accepts a deliberately small subset of the
|
|
10
|
+
* jq language and answers `false` for anything outside it. A `false` makes the
|
|
11
|
+
* guard refuse the command, so every unsupported construct fails closed.
|
|
12
|
+
*
|
|
13
|
+
* The analysis tracks the SHAPE of what each expression outputs:
|
|
14
|
+
*
|
|
15
|
+
* - "safe" literals, numbers, booleans, and values built only from
|
|
16
|
+
* safe parts (an array or object whose members are all safe);
|
|
17
|
+
* - leaf name the value of a field called `name` in the API response. It
|
|
18
|
+
* is safe to print only when `name` is in SCALAR_FIELDS;
|
|
19
|
+
* - "root" the whole response. Like "unknown", but a field listed in the
|
|
20
|
+
* caller's `rootScalars` (a count on a single pull request) is
|
|
21
|
+
* safe when read directly from it;
|
|
22
|
+
* - "unknown" arbitrary response JSON (the elements of an iteration, a whole
|
|
23
|
+
* sub-object). Never safe to print;
|
|
24
|
+
* - "tainted" a value the filter itself built from unsafe parts, or a
|
|
25
|
+
* free-form container (`payload`, `inputs`, check `output`, …).
|
|
26
|
+
* Its field names no longer describe its content, so nothing
|
|
27
|
+
* projected out of it is safe: `{name: .body} | .name` is not.
|
|
28
|
+
* - a union the separate outputs of `,` and `//`.
|
|
29
|
+
*
|
|
30
|
+
* The final output of the filter must be safe. Field access on response data
|
|
31
|
+
* yields the named leaf, so `.[] | .name` is safe while `.`, `.[]`, `.head`
|
|
32
|
+
* and `.body` are not.
|
|
33
|
+
*
|
|
34
|
+
* Comments are refused: gojq continues a `#` comment across a
|
|
35
|
+
* backslash-newline, which a line-based reading would get wrong.
|
|
36
|
+
*
|
|
37
|
+
* Errors: a filter that errors prints gojq's error text, which carries at most
|
|
38
|
+
* a short fixed-length preview of the offending value (gojq truncates value
|
|
39
|
+
* previews), too short to hold a signed link. Functions whose error text can
|
|
40
|
+
* echo a whole string (regular-expression functions given a non-literal
|
|
41
|
+
* pattern) only accept literal arguments.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
export type Shape = "safe" | "unknown" | "root" | "tainted" | { leaf: string } | { union: Shape[] };
|
|
45
|
+
|
|
46
|
+
export interface ProjectionOptions {
|
|
47
|
+
/** Fields that are plain scalars when read directly from the response root. */
|
|
48
|
+
rootScalars?: ReadonlySet<string>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Field names whose values are scalars that are not free text: identifiers,
|
|
53
|
+
* numbers, counts, booleans, enums, refs, object ids, timestamps and short
|
|
54
|
+
* names. A title is single-line user text; it is admitted because the
|
|
55
|
+
* signed-link class is carried by bodies, comments, reviews, commit messages
|
|
56
|
+
* and check output, and a bounded title read is the documented alternative.
|
|
57
|
+
* URL-valued fields are NOT admitted: in a raw API response a field called
|
|
58
|
+
* `url` belongs to whatever object the path lands on, and some objects hold
|
|
59
|
+
* third-party URLs under generic names.
|
|
60
|
+
*/
|
|
61
|
+
export const SCALAR_FIELDS: ReadonlySet<string> = new Set([
|
|
62
|
+
"id", "node_id", "databaseId", "fullDatabaseId", "number", "state", "state_reason", "stateReason",
|
|
63
|
+
"status", "conclusion", "bucket", "name", "login", "sha", "oid", "abbreviatedOid", "ref",
|
|
64
|
+
"headRefName", "baseRefName", "headRefOid", "baseRefOid", "head_sha", "headSha", "head_branch", "headBranch",
|
|
65
|
+
"merged", "mergeable", "mergeable_state", "mergeStateStatus", "draft", "isDraft", "locked", "isLocked",
|
|
66
|
+
"title", "displayTitle", "display_title",
|
|
67
|
+
"created_at", "updated_at", "closed_at", "merged_at", "started_at", "completed_at", "submitted_at", "pushed_at", "published_at",
|
|
68
|
+
"createdAt", "updatedAt", "closedAt", "mergedAt", "startedAt", "completedAt", "submittedAt", "pushedAt", "publishedAt",
|
|
69
|
+
"tag_name", "tagName", "total_count", "totalCount", "count", "additions", "deletions", "changes",
|
|
70
|
+
"changed_files", "changedFiles", "filename", "path", "event", "workflow", "workflowName", "workflow_id",
|
|
71
|
+
"workflowDatabaseId", "run_id", "run_number", "run_attempt", "attempt", "check_suite_id",
|
|
72
|
+
"default_branch", "defaultBranch", "full_name", "fullName", "nameWithOwner", "private", "isPrivate",
|
|
73
|
+
"visibility", "archived", "isArchived", "fork", "isFork", "isRequired", "is_required", "required",
|
|
74
|
+
"context", "type", "__typename", "role", "permission", "size", "reviewDecision",
|
|
75
|
+
"author_association", "authorAssociation", "isCrossRepository", "maintainerCanModify", "maintainer_can_modify",
|
|
76
|
+
"commentsCount", "language",
|
|
77
|
+
// Artifacts, runs, deployments and comparisons (all numbers, booleans, digests or timestamps).
|
|
78
|
+
"digest", "size_in_bytes", "run_started_at", "date", "wait_timer", "ahead_by", "behind_by", "total_commits",
|
|
79
|
+
"expired", "current_user_can_approve", "environment",
|
|
80
|
+
// A GitHub-generated page address. In every response the guard refuses it is
|
|
81
|
+
// github.com/<owner>/<repo>/… with at most an anchor or a GitHub filter query
|
|
82
|
+
// (REST OpenAPI description 1.1.4); the schemas where someone else sets
|
|
83
|
+
// html_url (license, Pages site, dependency-snapshot job) are not among them.
|
|
84
|
+
"html_url",
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
/** Containers whose keys are chosen by users or apps, so a key name says nothing about the value. */
|
|
88
|
+
const FREEFORM: ReadonlySet<string> = new Set([
|
|
89
|
+
"payload", "client_payload", "inputs", "config", "metadata", "output", "properties", "custom_properties", "variables",
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
/** Functions whose output carries no text from their input (numbers, booleans, nothing). */
|
|
93
|
+
const OPAQUE_RESULT = new Set([
|
|
94
|
+
"length", "utf8bytelength", "not", "type", "any", "all", "isempty", "empty",
|
|
95
|
+
"test", "startswith", "endswith", "contains", "inside", "has", "in", "index", "rindex", "indices",
|
|
96
|
+
"floor", "ceil", "round", "fabs", "sqrt", "isinfinite", "isnan", "isnormal", "infinite", "nan",
|
|
97
|
+
"true", "false", "null",
|
|
98
|
+
]);
|
|
99
|
+
|
|
100
|
+
/** Functions whose output is (a re-encoding of) their input. */
|
|
101
|
+
const PASSTHROUGH = new Set([
|
|
102
|
+
"tostring", "tojson", "ascii_downcase", "ascii_upcase", "ltrimstr", "rtrimstr", "trim", "ltrim", "rtrim",
|
|
103
|
+
"sort", "unique", "reverse", "first", "last", "min", "max", "add", "flatten", "join", "split",
|
|
104
|
+
"values", "nulls", "strings", "numbers", "booleans", "arrays", "objects", "iterables", "scalars",
|
|
105
|
+
"sort_by", "group_by", "unique_by", "min_by", "max_by", "select", "limit", "to_entries", "from_entries",
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
/** Arguments of these functions must be literals: their error text can echo a whole argument. */
|
|
109
|
+
const LITERAL_ARGS = new Set([
|
|
110
|
+
"test", "startswith", "endswith", "contains", "inside", "has", "in", "index", "rindex", "indices",
|
|
111
|
+
"ltrimstr", "rtrimstr", "join", "split", "flatten", "limit",
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
const FORMATS = new Set(["@text", "@json", "@csv", "@tsv", "@sh", "@base64", "@uri", "@html"]);
|
|
115
|
+
|
|
116
|
+
type Token =
|
|
117
|
+
| { t: "dot" }
|
|
118
|
+
| { t: "field"; name: string }
|
|
119
|
+
| { t: "str"; parts: Array<string | { src: string }> }
|
|
120
|
+
| { t: "num" }
|
|
121
|
+
| { t: "ident"; name: string }
|
|
122
|
+
| { t: "fmt"; name: string }
|
|
123
|
+
| { t: "op"; v: string };
|
|
124
|
+
|
|
125
|
+
class Unprovable extends Error {}
|
|
126
|
+
|
|
127
|
+
const fail = (): never => { throw new Unprovable(); };
|
|
128
|
+
|
|
129
|
+
function readString(src: string, start: number): { end: number; parts: Array<string | { src: string }> } {
|
|
130
|
+
// src[start] === '"'
|
|
131
|
+
const parts: Array<string | { src: string }> = [];
|
|
132
|
+
let text = "";
|
|
133
|
+
let i = start + 1;
|
|
134
|
+
while (i < src.length) {
|
|
135
|
+
const ch = src[i];
|
|
136
|
+
if (ch === '"') { if (text) parts.push(text); return { end: i + 1, parts }; }
|
|
137
|
+
if (ch === "\\") {
|
|
138
|
+
const next = src[i + 1];
|
|
139
|
+
if (next === "(") {
|
|
140
|
+
// Interpolation: find the matching ')' outside nested strings.
|
|
141
|
+
let depth = 1;
|
|
142
|
+
let j = i + 2;
|
|
143
|
+
while (j < src.length && depth > 0) {
|
|
144
|
+
const c = src[j];
|
|
145
|
+
if (c === '"') { j = readString(src, j).end; continue; }
|
|
146
|
+
if (c === "(") depth++;
|
|
147
|
+
else if (c === ")") depth--;
|
|
148
|
+
j++;
|
|
149
|
+
}
|
|
150
|
+
if (depth !== 0) fail();
|
|
151
|
+
if (text) { parts.push(text); text = ""; }
|
|
152
|
+
parts.push({ src: src.slice(i + 2, j - 1) });
|
|
153
|
+
i = j;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (next === undefined) fail();
|
|
157
|
+
text += next;
|
|
158
|
+
i += 2;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
text += ch;
|
|
162
|
+
i++;
|
|
163
|
+
}
|
|
164
|
+
return fail();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function tokenize(src: string): Token[] {
|
|
168
|
+
const out: Token[] = [];
|
|
169
|
+
let i = 0;
|
|
170
|
+
const ops = ["?//", "|=", "+=", "-=", "*=", "/=", "%=", "//=", "==", "!=", "<=", ">=", "//", "|", ",", "+", "-", "*", "/", "%", "<", ">", "=", "(", ")", "[", "]", "{", "}", ":", ";", "?"];
|
|
171
|
+
while (i < src.length) {
|
|
172
|
+
const ch = src[i];
|
|
173
|
+
if (/\s/.test(ch)) { i++; continue; }
|
|
174
|
+
if (ch === "#") fail(); // comments are not supported (see the header)
|
|
175
|
+
if (ch === '"') { const s = readString(src, i); out.push({ t: "str", parts: s.parts }); i = s.end; continue; }
|
|
176
|
+
if (ch === ".") {
|
|
177
|
+
if (src[i + 1] === ".") fail(); // `..` recursion prints everything
|
|
178
|
+
const m = /^[A-Za-z_][A-Za-z0-9_]*/.exec(src.slice(i + 1));
|
|
179
|
+
if (m) { out.push({ t: "field", name: m[0] }); i += 1 + m[0].length; continue; }
|
|
180
|
+
if (src[i + 1] === '"') {
|
|
181
|
+
const s = readString(src, i + 1);
|
|
182
|
+
if (s.parts.some((p) => typeof p !== "string")) fail();
|
|
183
|
+
out.push({ t: "field", name: s.parts.join("") });
|
|
184
|
+
i = s.end;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
out.push({ t: "dot" });
|
|
188
|
+
i++;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (ch === "$" || ch === "@") {
|
|
192
|
+
const m = /^[@$][A-Za-z_][A-Za-z0-9_]*/.exec(src.slice(i));
|
|
193
|
+
if (!m || ch === "$") fail(); // variables ($ENV, $__loc__, bindings) are not supported
|
|
194
|
+
out.push({ t: "fmt", name: m![0] });
|
|
195
|
+
i += m![0].length;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
const num = /^[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/.exec(src.slice(i));
|
|
199
|
+
if (num) { out.push({ t: "num" }); i += num[0].length; continue; }
|
|
200
|
+
const id = /^[A-Za-z_][A-Za-z0-9_]*(?:::[A-Za-z_][A-Za-z0-9_]*)*/.exec(src.slice(i));
|
|
201
|
+
if (id) { out.push({ t: "ident", name: id[0] }); i += id[0].length; continue; }
|
|
202
|
+
const op = ops.find((o) => src.startsWith(o, i));
|
|
203
|
+
if (!op) fail();
|
|
204
|
+
out.push({ t: "op", v: op! });
|
|
205
|
+
i += op!.length;
|
|
206
|
+
}
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
type Node =
|
|
211
|
+
| { k: "dot" }
|
|
212
|
+
| { k: "field"; name: string; of?: Node }
|
|
213
|
+
| { k: "iter"; of: Node }
|
|
214
|
+
| { k: "index"; of: Node; key: Node }
|
|
215
|
+
| { k: "slice"; of: Node }
|
|
216
|
+
| { k: "opt"; of: Node }
|
|
217
|
+
| { k: "lit" }
|
|
218
|
+
| { k: "str"; parts: Node[]; text?: string }
|
|
219
|
+
| { k: "fmt"; name: string; str?: Node }
|
|
220
|
+
| { k: "array"; of?: Node }
|
|
221
|
+
| { k: "object"; entries: Array<{ key: "static"; value: Node }> }
|
|
222
|
+
| { k: "call"; name: string; args: Node[] }
|
|
223
|
+
| { k: "pipe"; a: Node; b: Node }
|
|
224
|
+
| { k: "comma"; a: Node; b: Node }
|
|
225
|
+
| { k: "alt"; a: Node; b: Node }
|
|
226
|
+
| { k: "bool"; a: Node; b: Node }
|
|
227
|
+
| { k: "arith"; a: Node; b: Node }
|
|
228
|
+
| { k: "neg"; of: Node };
|
|
229
|
+
|
|
230
|
+
/** Constructs outside the supported subset: control flow, bindings, I/O, recursion and path rewriting. */
|
|
231
|
+
const REJECTED_KEYWORDS = new Set(["if", "then", "elif", "else", "end", "as", "def", "reduce", "foreach", "try", "catch", "label", "import", "include", "and", "or", "__loc__", "error", "input", "inputs", "debug", "stderr", "env", "halt", "halt_error", "input_filename", "input_line_number", "recurse", "paths", "leaf_paths", "getpath", "setpath", "delpaths", "del", "path", "splits", "sub", "gsub", "capture", "match", "scan", "keys", "keys_unsorted", "map_values", "with_entries", "tostream", "fromstream", "truncate_stream", "walk", "tonumber", "implode", "explode", "ascii", "fromjson"]);
|
|
232
|
+
|
|
233
|
+
class Parser {
|
|
234
|
+
private at = 0;
|
|
235
|
+
constructor(private readonly tokens: Token[]) {}
|
|
236
|
+
|
|
237
|
+
parse(): Node {
|
|
238
|
+
if (this.tokens.length === 0) fail();
|
|
239
|
+
const node = this.pipe();
|
|
240
|
+
if (this.at !== this.tokens.length) fail();
|
|
241
|
+
return node;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private peek(): Token | undefined { return this.tokens[this.at]; }
|
|
245
|
+
private isOp(v: string): boolean { const t = this.peek(); return !!t && t.t === "op" && t.v === v; }
|
|
246
|
+
private isIdent(v: string): boolean { const t = this.peek(); return !!t && t.t === "ident" && t.name === v; }
|
|
247
|
+
private expectOp(v: string): void { if (!this.isOp(v)) fail(); this.at++; }
|
|
248
|
+
|
|
249
|
+
pipe(): Node {
|
|
250
|
+
let node = this.comma();
|
|
251
|
+
if (this.isIdent("as")) fail();
|
|
252
|
+
if (this.isOp("|")) { this.at++; node = { k: "pipe", a: node, b: this.pipe() }; }
|
|
253
|
+
return node;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private comma(): Node {
|
|
257
|
+
let node = this.alt();
|
|
258
|
+
while (this.isOp(",")) { this.at++; node = { k: "comma", a: node, b: this.alt() }; }
|
|
259
|
+
return node;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private alt(): Node {
|
|
263
|
+
const node = this.or();
|
|
264
|
+
if (this.isOp("//")) { this.at++; return { k: "alt", a: node, b: this.alt() }; }
|
|
265
|
+
return node;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private or(): Node {
|
|
269
|
+
let node = this.and();
|
|
270
|
+
while (this.isIdent("or")) { this.at++; node = { k: "bool", a: node, b: this.and() }; }
|
|
271
|
+
return node;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private and(): Node {
|
|
275
|
+
let node = this.cmp();
|
|
276
|
+
while (this.isIdent("and")) { this.at++; node = { k: "bool", a: node, b: this.cmp() }; }
|
|
277
|
+
return node;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private cmp(): Node {
|
|
281
|
+
const node = this.add();
|
|
282
|
+
for (const v of ["==", "!=", "<=", ">=", "<", ">"]) {
|
|
283
|
+
if (this.isOp(v)) { this.at++; return { k: "bool", a: node, b: this.add() }; }
|
|
284
|
+
}
|
|
285
|
+
for (const v of ["=", "|=", "+=", "-=", "*=", "/=", "%=", "//=", "?//"]) if (this.isOp(v)) fail();
|
|
286
|
+
return node;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private add(): Node {
|
|
290
|
+
let node = this.mul();
|
|
291
|
+
while (this.isOp("+") || this.isOp("-")) { this.at++; node = { k: "arith", a: node, b: this.mul() }; }
|
|
292
|
+
return node;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private mul(): Node {
|
|
296
|
+
let node = this.unary();
|
|
297
|
+
while (this.isOp("*") || this.isOp("/") || this.isOp("%")) { this.at++; node = { k: "arith", a: node, b: this.unary() }; }
|
|
298
|
+
return node;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private unary(): Node {
|
|
302
|
+
if (this.isOp("-")) { this.at++; return { k: "neg", of: this.postfix() }; }
|
|
303
|
+
return this.postfix();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Object values: jq admits a term or a pipe of terms without commas. */
|
|
307
|
+
private objectValue(): Node {
|
|
308
|
+
let node = this.unary();
|
|
309
|
+
if (this.isOp("|")) { this.at++; node = { k: "pipe", a: node, b: this.objectValue() }; }
|
|
310
|
+
return node;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private postfix(): Node {
|
|
314
|
+
let node = this.primary();
|
|
315
|
+
for (;;) {
|
|
316
|
+
const t = this.peek();
|
|
317
|
+
if (!t) return node;
|
|
318
|
+
if (t.t === "field") { this.at++; node = { k: "field", name: t.name, of: node }; continue; }
|
|
319
|
+
if (t.t === "dot" && this.tokens[this.at + 1]?.t === "op" && (this.tokens[this.at + 1] as { v: string }).v === "[") {
|
|
320
|
+
this.at++;
|
|
321
|
+
continue; // `.[...]` after a term is the same as `[...]`
|
|
322
|
+
}
|
|
323
|
+
if (t.t === "op" && t.v === "?") { this.at++; node = { k: "opt", of: node }; continue; }
|
|
324
|
+
if (t.t === "op" && t.v === "[") { node = this.bracket(node); continue; }
|
|
325
|
+
return node;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private bracket(of: Node): Node {
|
|
330
|
+
this.expectOp("[");
|
|
331
|
+
if (this.isOp("]")) { this.at++; return { k: "iter", of }; }
|
|
332
|
+
if (this.isOp(":")) { this.at++; if (!this.isOp("]")) this.pipe(); this.expectOp("]"); return { k: "slice", of }; }
|
|
333
|
+
const key = this.pipe();
|
|
334
|
+
if (this.isOp(":")) { this.at++; if (!this.isOp("]")) this.pipe(); this.expectOp("]"); return { k: "slice", of }; }
|
|
335
|
+
this.expectOp("]");
|
|
336
|
+
return { k: "index", of, key };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private primary(): Node {
|
|
340
|
+
const t = this.peek();
|
|
341
|
+
if (!t) return fail();
|
|
342
|
+
this.at++;
|
|
343
|
+
switch (t.t) {
|
|
344
|
+
case "dot": return { k: "dot" };
|
|
345
|
+
case "field": return { k: "field", name: t.name };
|
|
346
|
+
case "num": return { k: "lit" };
|
|
347
|
+
case "str": return this.stringNode(t.parts);
|
|
348
|
+
case "fmt": {
|
|
349
|
+
if (!FORMATS.has(t.name)) fail();
|
|
350
|
+
const next = this.peek();
|
|
351
|
+
if (next?.t === "str") { this.at++; return { k: "fmt", name: t.name, str: this.stringNode(next.parts) }; }
|
|
352
|
+
return { k: "fmt", name: t.name };
|
|
353
|
+
}
|
|
354
|
+
case "ident": {
|
|
355
|
+
if (REJECTED_KEYWORDS.has(t.name)) fail();
|
|
356
|
+
if (t.name === "map" || OPAQUE_RESULT.has(t.name) || PASSTHROUGH.has(t.name)) {
|
|
357
|
+
const args: Node[] = [];
|
|
358
|
+
if (this.isOp("(")) {
|
|
359
|
+
this.at++;
|
|
360
|
+
args.push(this.pipe());
|
|
361
|
+
while (this.isOp(";")) { this.at++; args.push(this.pipe()); }
|
|
362
|
+
this.expectOp(")");
|
|
363
|
+
}
|
|
364
|
+
return { k: "call", name: t.name, args };
|
|
365
|
+
}
|
|
366
|
+
return fail();
|
|
367
|
+
}
|
|
368
|
+
case "op": {
|
|
369
|
+
if (t.v === "(") { const inner = this.pipe(); this.expectOp(")"); return inner; }
|
|
370
|
+
if (t.v === "[") {
|
|
371
|
+
if (this.isOp("]")) { this.at++; return { k: "array" }; }
|
|
372
|
+
const inner = this.pipe();
|
|
373
|
+
this.expectOp("]");
|
|
374
|
+
return { k: "array", of: inner };
|
|
375
|
+
}
|
|
376
|
+
if (t.v === "{") return this.object();
|
|
377
|
+
return fail();
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return fail();
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
private stringNode(parts: Array<string | { src: string }>): Node {
|
|
384
|
+
const nodes: Node[] = [];
|
|
385
|
+
for (const part of parts) {
|
|
386
|
+
if (typeof part === "string") continue;
|
|
387
|
+
nodes.push(new Parser(tokenize(part.src)).parse());
|
|
388
|
+
}
|
|
389
|
+
return nodes.length === 0 ? { k: "str", parts: nodes, text: parts.join("") } : { k: "str", parts: nodes };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
private object(): Node {
|
|
393
|
+
const entries: Array<{ key: "static"; value: Node }> = [];
|
|
394
|
+
if (this.isOp("}")) { this.at++; return { k: "object", entries }; }
|
|
395
|
+
for (;;) {
|
|
396
|
+
const t = this.peek();
|
|
397
|
+
if (!t) fail();
|
|
398
|
+
this.at++;
|
|
399
|
+
let shorthand: string | undefined;
|
|
400
|
+
if (t!.t === "ident") shorthand = t!.name;
|
|
401
|
+
else if (t!.t === "str") {
|
|
402
|
+
if (t!.parts.some((p) => typeof p !== "string")) fail(); // dynamic keys can carry values
|
|
403
|
+
shorthand = t!.parts.join("");
|
|
404
|
+
} else fail(); // `$var` and `(expr)` keys are not supported
|
|
405
|
+
if (this.isOp(":")) { this.at++; entries.push({ key: "static", value: this.objectValue() }); }
|
|
406
|
+
else entries.push({ key: "static", value: { k: "field", name: shorthand! } });
|
|
407
|
+
if (this.isOp(",")) { this.at++; continue; }
|
|
408
|
+
this.expectOp("}");
|
|
409
|
+
return { k: "object", entries };
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export function isFinalSafe(shape: Shape): boolean {
|
|
415
|
+
if (typeof shape === "object" && "union" in shape) return shape.union.every(isFinalSafe);
|
|
416
|
+
return shape === "safe" || (typeof shape === "object" && SCALAR_FIELDS.has(shape.leaf));
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** A value built from parts: safe only when every part is. */
|
|
420
|
+
const combine = (...shapes: Shape[]): Shape => (shapes.every(isFinalSafe) ? "safe" : "tainted");
|
|
421
|
+
|
|
422
|
+
function union(...shapes: Shape[]): Shape {
|
|
423
|
+
const flat: Shape[] = [];
|
|
424
|
+
for (const shape of shapes) {
|
|
425
|
+
if (typeof shape === "object" && "union" in shape) flat.push(...shape.union);
|
|
426
|
+
else flat.push(shape);
|
|
427
|
+
}
|
|
428
|
+
return flat.length === 1 ? flat[0] : { union: flat };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Apply `f` to every member of a union. */
|
|
432
|
+
function each(shape: Shape, f: (member: Shape) => Shape): Shape {
|
|
433
|
+
return typeof shape === "object" && "union" in shape ? union(...shape.union.map(f)) : f(shape);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const isFreeform = (shape: Shape) => typeof shape === "object" && "leaf" in shape && FREEFORM.has(shape.leaf);
|
|
437
|
+
|
|
438
|
+
/** An element of `shape` (iteration, a numeric index, min/max/first/last). */
|
|
439
|
+
const element = (shape: Shape): Shape => each(shape, (member) =>
|
|
440
|
+
member === "safe" ? "safe" : member === "tainted" || isFreeform(member) ? "tainted" : "unknown");
|
|
441
|
+
|
|
442
|
+
function literalArg(node: Node): boolean {
|
|
443
|
+
return node.k === "lit" || (node.k === "str" && node.parts.length === 0) || (node.k === "call" && ["true", "false", "null"].includes(node.name) && node.args.length === 0);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
interface Scope { rootScalars: ReadonlySet<string> }
|
|
447
|
+
|
|
448
|
+
function field(shape: Shape, name: string, scope: Scope): Shape {
|
|
449
|
+
return each(shape, (member) => {
|
|
450
|
+
if (member === "safe" || member === "tainted") return member;
|
|
451
|
+
if (isFreeform(member)) return "tainted";
|
|
452
|
+
if (member === "root" && scope.rootScalars.has(name)) return "safe";
|
|
453
|
+
return { leaf: name };
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function evaluate(node: Node, input: Shape, scope: Scope): Shape {
|
|
458
|
+
switch (node.k) {
|
|
459
|
+
case "dot": return input;
|
|
460
|
+
case "field": return field(node.of ? evaluate(node.of, input, scope) : input, node.name, scope);
|
|
461
|
+
case "iter": return element(evaluate(node.of, input, scope));
|
|
462
|
+
case "index": {
|
|
463
|
+
const base = evaluate(node.of, input, scope);
|
|
464
|
+
if (node.key.k === "str" && node.key.text !== undefined) return field(base, node.key.text, scope); // .["name"] is .name
|
|
465
|
+
if (node.key.k === "lit") return element(base);
|
|
466
|
+
evaluate(node.key, input, scope);
|
|
467
|
+
return "tainted"; // a computed key: which field is read is not known
|
|
468
|
+
}
|
|
469
|
+
case "slice": return each(evaluate(node.of, input, scope), (member) => (member === "root" ? "unknown" : isFreeform(member) ? "tainted" : member));
|
|
470
|
+
case "opt": return evaluate(node.of, input, scope);
|
|
471
|
+
case "lit": return "safe";
|
|
472
|
+
case "str": return combine(...node.parts.map((part) => evaluate(part, input, scope)));
|
|
473
|
+
case "fmt": return node.str ? evaluate(node.str, input, scope) : input;
|
|
474
|
+
case "array": return node.of ? combine(evaluate(node.of, input, scope)) : "safe";
|
|
475
|
+
case "object": return combine(...node.entries.map((entry) => evaluate(entry.value, input, scope)));
|
|
476
|
+
case "pipe": return evaluate(node.b, evaluate(node.a, input, scope), scope);
|
|
477
|
+
case "comma": return union(evaluate(node.a, input, scope), evaluate(node.b, input, scope));
|
|
478
|
+
case "alt": return union(evaluate(node.a, input, scope), evaluate(node.b, input, scope));
|
|
479
|
+
case "bool": evaluate(node.a, input, scope); evaluate(node.b, input, scope); return "safe";
|
|
480
|
+
case "arith": return combine(evaluate(node.a, input, scope), evaluate(node.b, input, scope));
|
|
481
|
+
case "neg": return evaluate(node.of, input, scope);
|
|
482
|
+
case "call": return evaluateCall(node, input, scope);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** A function whose output re-encodes its input: the whole response stops being the root. */
|
|
487
|
+
const passthrough = (input: Shape): Shape => each(input, (member) => (member === "root" ? "unknown" : isFreeform(member) ? "tainted" : member));
|
|
488
|
+
|
|
489
|
+
function evaluateCall(node: Extract<Node, { k: "call" }>, input: Shape, scope: Scope): Shape {
|
|
490
|
+
const { name, args } = node;
|
|
491
|
+
if (LITERAL_ARGS.has(name) && name !== "limit" && !args.every(literalArg)) fail();
|
|
492
|
+
switch (name) {
|
|
493
|
+
case "map":
|
|
494
|
+
if (args.length !== 1) fail();
|
|
495
|
+
return combine(evaluate(args[0], element(input), scope));
|
|
496
|
+
case "select":
|
|
497
|
+
if (args.length !== 1) fail();
|
|
498
|
+
evaluate(args[0], input, scope);
|
|
499
|
+
return input;
|
|
500
|
+
case "limit":
|
|
501
|
+
if (args.length !== 2 || !literalArg(args[0])) fail();
|
|
502
|
+
return evaluate(args[1], input, scope);
|
|
503
|
+
case "first":
|
|
504
|
+
case "last":
|
|
505
|
+
if (args.length > 1) fail();
|
|
506
|
+
return args.length === 1 ? evaluate(args[0], input, scope) : element(input);
|
|
507
|
+
case "min":
|
|
508
|
+
case "max":
|
|
509
|
+
return element(input);
|
|
510
|
+
case "sort_by":
|
|
511
|
+
case "group_by":
|
|
512
|
+
case "unique_by":
|
|
513
|
+
if (args.length !== 1) fail();
|
|
514
|
+
evaluate(args[0], element(input), scope);
|
|
515
|
+
return passthrough(input);
|
|
516
|
+
case "min_by":
|
|
517
|
+
case "max_by":
|
|
518
|
+
if (args.length !== 1) fail();
|
|
519
|
+
evaluate(args[0], element(input), scope);
|
|
520
|
+
return element(input);
|
|
521
|
+
case "any":
|
|
522
|
+
case "all":
|
|
523
|
+
case "isempty":
|
|
524
|
+
for (const arg of args) evaluate(arg, "unknown", scope);
|
|
525
|
+
return "safe";
|
|
526
|
+
}
|
|
527
|
+
if (OPAQUE_RESULT.has(name)) return "safe";
|
|
528
|
+
if (PASSTHROUGH.has(name)) return passthrough(input);
|
|
529
|
+
return fail();
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Whether `filter` provably prints only bounded scalars. Any construct outside
|
|
534
|
+
* the supported subset, and any parse failure, answers false.
|
|
535
|
+
*/
|
|
536
|
+
export function jqProjectionIsScalar(filter: string, options: ProjectionOptions = {}): boolean {
|
|
537
|
+
if (typeof filter !== "string" || !filter.trim() || filter.length > 4096) return false;
|
|
538
|
+
try {
|
|
539
|
+
const node = new Parser(tokenize(filter)).parse();
|
|
540
|
+
return isFinalSafe(evaluate(node, "root", { rootScalars: options.rootScalars ?? new Set() }));
|
|
541
|
+
} catch (error) {
|
|
542
|
+
if (error instanceof Unprovable) return false;
|
|
543
|
+
return false;
|
|
544
|
+
}
|
|
545
|
+
}
|