agy-worker-mcp 0.3.1

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.
Files changed (227) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/LICENSE +21 -0
  3. package/README.md +309 -0
  4. package/commands/agy-ceiling.md +31 -0
  5. package/dist/broker/blockers.d.ts +86 -0
  6. package/dist/broker/blockers.d.ts.map +1 -0
  7. package/dist/broker/blockers.js +390 -0
  8. package/dist/broker/blockers.js.map +1 -0
  9. package/dist/broker/outcome.d.ts +85 -0
  10. package/dist/broker/outcome.d.ts.map +1 -0
  11. package/dist/broker/outcome.js +147 -0
  12. package/dist/broker/outcome.js.map +1 -0
  13. package/dist/broker/reconcile.d.ts +74 -0
  14. package/dist/broker/reconcile.d.ts.map +1 -0
  15. package/dist/broker/reconcile.js +415 -0
  16. package/dist/broker/reconcile.js.map +1 -0
  17. package/dist/broker/result.d.ts +88 -0
  18. package/dist/broker/result.d.ts.map +1 -0
  19. package/dist/broker/result.js +234 -0
  20. package/dist/broker/result.js.map +1 -0
  21. package/dist/broker/verify.d.ts +52 -0
  22. package/dist/broker/verify.d.ts.map +1 -0
  23. package/dist/broker/verify.js +174 -0
  24. package/dist/broker/verify.js.map +1 -0
  25. package/dist/contract/errors.d.ts +119 -0
  26. package/dist/contract/errors.d.ts.map +1 -0
  27. package/dist/contract/errors.js +99 -0
  28. package/dist/contract/errors.js.map +1 -0
  29. package/dist/contract/index.d.ts +10 -0
  30. package/dist/contract/index.d.ts.map +1 -0
  31. package/dist/contract/index.js +10 -0
  32. package/dist/contract/index.js.map +1 -0
  33. package/dist/contract/paths.d.ts +139 -0
  34. package/dist/contract/paths.d.ts.map +1 -0
  35. package/dist/contract/paths.js +359 -0
  36. package/dist/contract/paths.js.map +1 -0
  37. package/dist/contract/schema.sql +64 -0
  38. package/dist/contract/types.d.ts +1113 -0
  39. package/dist/contract/types.d.ts.map +1 -0
  40. package/dist/contract/types.js +114 -0
  41. package/dist/contract/types.js.map +1 -0
  42. package/dist/events/cursor.d.ts +65 -0
  43. package/dist/events/cursor.d.ts.map +1 -0
  44. package/dist/events/cursor.js +286 -0
  45. package/dist/events/cursor.js.map +1 -0
  46. package/dist/events/detect.d.ts +58 -0
  47. package/dist/events/detect.d.ts.map +1 -0
  48. package/dist/events/detect.js +183 -0
  49. package/dist/events/detect.js.map +1 -0
  50. package/dist/events/normalize.d.ts +30 -0
  51. package/dist/events/normalize.d.ts.map +1 -0
  52. package/dist/events/normalize.js +158 -0
  53. package/dist/events/normalize.js.map +1 -0
  54. package/dist/events/parse.d.ts +41 -0
  55. package/dist/events/parse.d.ts.map +1 -0
  56. package/dist/events/parse.js +89 -0
  57. package/dist/events/parse.js.map +1 -0
  58. package/dist/gate/bind.d.ts +28 -0
  59. package/dist/gate/bind.d.ts.map +1 -0
  60. package/dist/gate/bind.js +154 -0
  61. package/dist/gate/bind.js.map +1 -0
  62. package/dist/gate/gate.d.ts +139 -0
  63. package/dist/gate/gate.d.ts.map +1 -0
  64. package/dist/gate/gate.js +488 -0
  65. package/dist/gate/gate.js.map +1 -0
  66. package/dist/gate/hooks-file.d.ts +55 -0
  67. package/dist/gate/hooks-file.d.ts.map +1 -0
  68. package/dist/gate/hooks-file.js +104 -0
  69. package/dist/gate/hooks-file.js.map +1 -0
  70. package/dist/gate.d.ts +3 -0
  71. package/dist/gate.d.ts.map +1 -0
  72. package/dist/gate.js +20 -0
  73. package/dist/gate.js.map +1 -0
  74. package/dist/policy/ceiling-review.d.ts +22 -0
  75. package/dist/policy/ceiling-review.d.ts.map +1 -0
  76. package/dist/policy/ceiling-review.js +170 -0
  77. package/dist/policy/ceiling-review.js.map +1 -0
  78. package/dist/policy/ceiling.d.ts +128 -0
  79. package/dist/policy/ceiling.d.ts.map +1 -0
  80. package/dist/policy/ceiling.js +273 -0
  81. package/dist/policy/ceiling.js.map +1 -0
  82. package/dist/policy/containment.d.ts +100 -0
  83. package/dist/policy/containment.d.ts.map +1 -0
  84. package/dist/policy/containment.js +1071 -0
  85. package/dist/policy/containment.js.map +1 -0
  86. package/dist/policy/hard-deny.d.ts +28 -0
  87. package/dist/policy/hard-deny.d.ts.map +1 -0
  88. package/dist/policy/hard-deny.js +78 -0
  89. package/dist/policy/hard-deny.js.map +1 -0
  90. package/dist/policy/profiles.d.ts +82 -0
  91. package/dist/policy/profiles.d.ts.map +1 -0
  92. package/dist/policy/profiles.js +317 -0
  93. package/dist/policy/profiles.js.map +1 -0
  94. package/dist/policy/rules.d.ts +138 -0
  95. package/dist/policy/rules.d.ts.map +1 -0
  96. package/dist/policy/rules.js +1180 -0
  97. package/dist/policy/rules.js.map +1 -0
  98. package/dist/policy/seatbelt.d.ts +32 -0
  99. package/dist/policy/seatbelt.d.ts.map +1 -0
  100. package/dist/policy/seatbelt.js +59 -0
  101. package/dist/policy/seatbelt.js.map +1 -0
  102. package/dist/policy/tools.d.ts +36 -0
  103. package/dist/policy/tools.d.ts.map +1 -0
  104. package/dist/policy/tools.js +94 -0
  105. package/dist/policy/tools.js.map +1 -0
  106. package/dist/runner/gate-watchdog.d.ts +93 -0
  107. package/dist/runner/gate-watchdog.d.ts.map +1 -0
  108. package/dist/runner/gate-watchdog.js +153 -0
  109. package/dist/runner/gate-watchdog.js.map +1 -0
  110. package/dist/runner/idle.d.ts +51 -0
  111. package/dist/runner/idle.d.ts.map +1 -0
  112. package/dist/runner/idle.js +81 -0
  113. package/dist/runner/idle.js.map +1 -0
  114. package/dist/runner/inbox.d.ts +56 -0
  115. package/dist/runner/inbox.d.ts.map +1 -0
  116. package/dist/runner/inbox.js +134 -0
  117. package/dist/runner/inbox.js.map +1 -0
  118. package/dist/runner/reap.d.ts +65 -0
  119. package/dist/runner/reap.d.ts.map +1 -0
  120. package/dist/runner/reap.js +168 -0
  121. package/dist/runner/reap.js.map +1 -0
  122. package/dist/runner/runner.d.ts +51 -0
  123. package/dist/runner/runner.d.ts.map +1 -0
  124. package/dist/runner/runner.js +289 -0
  125. package/dist/runner/runner.js.map +1 -0
  126. package/dist/runner/spawn.d.ts +98 -0
  127. package/dist/runner/spawn.d.ts.map +1 -0
  128. package/dist/runner/spawn.js +230 -0
  129. package/dist/runner/spawn.js.map +1 -0
  130. package/dist/runner/verify.d.ts +24 -0
  131. package/dist/runner/verify.d.ts.map +1 -0
  132. package/dist/runner/verify.js +108 -0
  133. package/dist/runner/verify.js.map +1 -0
  134. package/dist/runner.d.ts +3 -0
  135. package/dist/runner.d.ts.map +1 -0
  136. package/dist/runner.js +15 -0
  137. package/dist/runner.js.map +1 -0
  138. package/dist/server/context.d.ts +41 -0
  139. package/dist/server/context.d.ts.map +1 -0
  140. package/dist/server/context.js +59 -0
  141. package/dist/server/context.js.map +1 -0
  142. package/dist/server/instructions.d.ts +13 -0
  143. package/dist/server/instructions.d.ts.map +1 -0
  144. package/dist/server/instructions.js +121 -0
  145. package/dist/server/instructions.js.map +1 -0
  146. package/dist/server/server.d.ts +20 -0
  147. package/dist/server/server.d.ts.map +1 -0
  148. package/dist/server/server.js +55 -0
  149. package/dist/server/server.js.map +1 -0
  150. package/dist/server/tools/cancel.d.ts +24 -0
  151. package/dist/server/tools/cancel.d.ts.map +1 -0
  152. package/dist/server/tools/cancel.js +59 -0
  153. package/dist/server/tools/cancel.js.map +1 -0
  154. package/dist/server/tools/capabilities.d.ts +36 -0
  155. package/dist/server/tools/capabilities.d.ts.map +1 -0
  156. package/dist/server/tools/capabilities.js +116 -0
  157. package/dist/server/tools/capabilities.js.map +1 -0
  158. package/dist/server/tools/ceiling.d.ts +31 -0
  159. package/dist/server/tools/ceiling.d.ts.map +1 -0
  160. package/dist/server/tools/ceiling.js +121 -0
  161. package/dist/server/tools/ceiling.js.map +1 -0
  162. package/dist/server/tools/index.d.ts +38 -0
  163. package/dist/server/tools/index.d.ts.map +1 -0
  164. package/dist/server/tools/index.js +115 -0
  165. package/dist/server/tools/index.js.map +1 -0
  166. package/dist/server/tools/listJobs.d.ts +30 -0
  167. package/dist/server/tools/listJobs.d.ts.map +1 -0
  168. package/dist/server/tools/listJobs.js +37 -0
  169. package/dist/server/tools/listJobs.js.map +1 -0
  170. package/dist/server/tools/logs.d.ts +31 -0
  171. package/dist/server/tools/logs.d.ts.map +1 -0
  172. package/dist/server/tools/logs.js +96 -0
  173. package/dist/server/tools/logs.js.map +1 -0
  174. package/dist/server/tools/result.d.ts +32 -0
  175. package/dist/server/tools/result.d.ts.map +1 -0
  176. package/dist/server/tools/result.js +95 -0
  177. package/dist/server/tools/result.js.map +1 -0
  178. package/dist/server/tools/send.d.ts +37 -0
  179. package/dist/server/tools/send.d.ts.map +1 -0
  180. package/dist/server/tools/send.js +71 -0
  181. package/dist/server/tools/send.js.map +1 -0
  182. package/dist/server/tools/sessions.d.ts +28 -0
  183. package/dist/server/tools/sessions.d.ts.map +1 -0
  184. package/dist/server/tools/sessions.js +54 -0
  185. package/dist/server/tools/sessions.js.map +1 -0
  186. package/dist/server/tools/start.d.ts +107 -0
  187. package/dist/server/tools/start.d.ts.map +1 -0
  188. package/dist/server/tools/start.js +482 -0
  189. package/dist/server/tools/start.js.map +1 -0
  190. package/dist/server/tools/wait.d.ts +29 -0
  191. package/dist/server/tools/wait.d.ts.map +1 -0
  192. package/dist/server/tools/wait.js +120 -0
  193. package/dist/server/tools/wait.js.map +1 -0
  194. package/dist/server.d.ts +3 -0
  195. package/dist/server.d.ts.map +1 -0
  196. package/dist/server.js +17 -0
  197. package/dist/server.js.map +1 -0
  198. package/dist/setup/install.d.ts +52 -0
  199. package/dist/setup/install.d.ts.map +1 -0
  200. package/dist/setup/install.js +108 -0
  201. package/dist/setup/install.js.map +1 -0
  202. package/dist/setup.d.ts +3 -0
  203. package/dist/setup.d.ts.map +1 -0
  204. package/dist/setup.js +24 -0
  205. package/dist/setup.js.map +1 -0
  206. package/dist/store/db.d.ts +49 -0
  207. package/dist/store/db.d.ts.map +1 -0
  208. package/dist/store/db.js +101 -0
  209. package/dist/store/db.js.map +1 -0
  210. package/dist/store/jobs.d.ts +45 -0
  211. package/dist/store/jobs.d.ts.map +1 -0
  212. package/dist/store/jobs.js +150 -0
  213. package/dist/store/jobs.js.map +1 -0
  214. package/dist/store/locks.d.ts +60 -0
  215. package/dist/store/locks.d.ts.map +1 -0
  216. package/dist/store/locks.js +172 -0
  217. package/dist/store/locks.js.map +1 -0
  218. package/dist/store/sessions.d.ts +37 -0
  219. package/dist/store/sessions.d.ts.map +1 -0
  220. package/dist/store/sessions.js +117 -0
  221. package/dist/store/sessions.js.map +1 -0
  222. package/dist/trace/digest.d.ts +77 -0
  223. package/dist/trace/digest.d.ts.map +1 -0
  224. package/dist/trace/digest.js +481 -0
  225. package/dist/trace/digest.js.map +1 -0
  226. package/package.json +68 -0
  227. package/skills/agy-ceiling/SKILL.md +72 -0
@@ -0,0 +1,1180 @@
1
+ import { ValidationError } from '../contract/errors.js';
2
+ /**
3
+ * Permission rule strings, in agy's own vocabulary (§12):
4
+ * `command(...)`, `read_file(...)`, `write_file(...)`, `fetch(...)`, `url(...)`,
5
+ * `mcp(...)`, `browser(...)`. Matching is strict and non-regex by default;
6
+ * `regex:` opts in. Wildcards `command(*)` and `read_file(/)` are recognized.
7
+ */
8
+ export const RULE_VERBS = [
9
+ 'command',
10
+ 'read_file',
11
+ 'write_file',
12
+ 'fetch',
13
+ 'url',
14
+ 'mcp',
15
+ 'browser',
16
+ ];
17
+ const REGEX_PREFIX = 'regex:';
18
+ /** @throws {import('../contract/errors.js').ValidationError} carrying `allowed_verbs`. */
19
+ export function parseRule(raw) {
20
+ const trimmed = raw.trim();
21
+ const openIdx = trimmed.indexOf('(');
22
+ if (openIdx <= 0 || !trimmed.endsWith(')')) {
23
+ throw new ValidationError({
24
+ field: 'rule',
25
+ value: raw,
26
+ expected: 'a string shaped like verb(pattern)',
27
+ allowed_verbs: [...RULE_VERBS],
28
+ });
29
+ }
30
+ const verb = trimmed.slice(0, openIdx);
31
+ if (!RULE_VERBS.includes(verb)) {
32
+ throw new ValidationError({
33
+ field: 'rule',
34
+ value: raw,
35
+ expected: `verb to be one of ${RULE_VERBS.join(', ')}`,
36
+ allowed_verbs: [...RULE_VERBS],
37
+ });
38
+ }
39
+ let pattern = trimmed.slice(openIdx + 1, -1);
40
+ let regex = false;
41
+ if (pattern.startsWith(REGEX_PREFIX)) {
42
+ regex = true;
43
+ pattern = pattern.slice(REGEX_PREFIX.length);
44
+ }
45
+ return { verb: verb, pattern, regex, raw: trimmed };
46
+ }
47
+ /** Parses every rule; the first bad one throws, so a caller never gets a half set. */
48
+ export function parseRules(raws) {
49
+ return raws.map(parseRule);
50
+ }
51
+ /** Same as {@link parseRules}, but a malformed entry is dropped instead of thrown. */
52
+ export function parseRulesLenient(raws) {
53
+ const out = [];
54
+ for (const raw of raws) {
55
+ try {
56
+ out.push(parseRule(raw));
57
+ }
58
+ catch {
59
+ // Corrupted or hand-edited policy.json should degrade, not crash the gate.
60
+ }
61
+ }
62
+ return out;
63
+ }
64
+ function escapeRegExpChar(c) {
65
+ return /[.+?^${}()|[\]\\]/.test(c) ? `\\${c}` : c;
66
+ }
67
+ /** `**` -> any depth, `*` -> one path segment / token, everything else literal. */
68
+ export function globToRegex(pattern) {
69
+ let re = '';
70
+ for (let i = 0; i < pattern.length; i++) {
71
+ const c = pattern[i];
72
+ if (c === '*' && pattern[i + 1] === '*') {
73
+ re += '.*';
74
+ i++;
75
+ continue;
76
+ }
77
+ if (c === '*') {
78
+ re += '[^/]*';
79
+ continue;
80
+ }
81
+ re += escapeRegExpChar(c);
82
+ }
83
+ return new RegExp(`^${re}$`);
84
+ }
85
+ /**
86
+ * Environment variables that must never be assigned inline or via env/export.
87
+ * Rationale:
88
+ * - PATH: Binary resolution hijacking by prepending untrusted directories.
89
+ * - LD_*, DYLD_*: Dynamic linker hijacking (preloading arbitrary shared libraries on Linux / macOS).
90
+ * - NODE_OPTIONS: Node.js runtime flag / hook injection (e.g. --require, --inspect).
91
+ * - BASH_ENV, ENV: Startup script execution on shell invocation.
92
+ * - GIT_DIR, GIT_WORK_TREE: Redirects git repository / working tree outside the workspace.
93
+ * - GIT_SSH, GIT_SSH_COMMAND: Arbitrary binary execution during git network operations.
94
+ * - GIT_CONFIG*, GIT_EXEC_PATH: Git config override or custom hook/binary injection.
95
+ * - PYTHONSTARTUP, PYTHONPATH: Arbitrary code execution or library hijacking at Python startup.
96
+ * - PERL5OPT, RUBYOPT: Option/code injection at Perl/Ruby interpreter startup.
97
+ * - JAVA_TOOL_OPTIONS, _JAVA_OPTIONS: JVM argument injection (e.g. -javaagent).
98
+ */
99
+ export const DENIED_ENV_PATTERNS = [
100
+ 'PATH',
101
+ /^LD_/,
102
+ /^DYLD_/,
103
+ 'NODE_OPTIONS',
104
+ 'BASH_ENV',
105
+ 'ENV',
106
+ 'GIT_DIR',
107
+ 'GIT_WORK_TREE',
108
+ 'GIT_SSH',
109
+ 'GIT_SSH_COMMAND',
110
+ /^GIT_CONFIG/,
111
+ 'GIT_EXEC_PATH',
112
+ 'PYTHONSTARTUP',
113
+ 'PYTHONPATH',
114
+ 'PERL5OPT',
115
+ 'RUBYOPT',
116
+ 'JAVA_TOOL_OPTIONS',
117
+ '_JAVA_OPTIONS',
118
+ ];
119
+ export function isDeniedEnvVar(name) {
120
+ return DENIED_ENV_PATTERNS.some((pat) => typeof pat === 'string' ? pat === name : pat.test(name));
121
+ }
122
+ function matchCommandTokens(patternTokens, valueTokens) {
123
+ if (patternTokens.length === 0)
124
+ return false;
125
+ if (valueTokens.length < patternTokens.length)
126
+ return false;
127
+ for (let i = 0; i < patternTokens.length; i++) {
128
+ const alternatives = patternTokens[i].split('|');
129
+ if (!alternatives.includes(valueTokens[i]))
130
+ return false;
131
+ }
132
+ return true;
133
+ }
134
+ /**
135
+ * `command(git status|log|diff)` means: first token exactly `git`, second token
136
+ * one of `status|log|diff`, trailing tokens unconstrained. Extra pattern tokens
137
+ * beyond the value's length never match (the value can't be too short).
138
+ */
139
+ function matchCommandPattern(pattern, value) {
140
+ if (pattern === '*')
141
+ return true;
142
+ const valueTokens = tokenizeCommand(value);
143
+ if (valueTokens.length === 0)
144
+ return false;
145
+ const envRes = stripEnvPrefixTokens(valueTokens);
146
+ if (envRes.deniedVar !== null)
147
+ return false;
148
+ if (envRes.isExport && envRes.remainingTokens.length === 0)
149
+ return true;
150
+ let tokens = envRes.remainingTokens;
151
+ if (tokens.length === 0)
152
+ return false;
153
+ const head = basename(tokens[0]);
154
+ if (['bash', 'sh'].includes(head)) {
155
+ const cIdx = tokens.findIndex((t, idx) => idx > 0 && isShellCFlag(t));
156
+ if (cIdx !== -1 && cIdx + 1 < tokens.length) {
157
+ return matchCommandPattern(pattern, tokens[cIdx + 1]);
158
+ }
159
+ let sIdx = 1;
160
+ while (sIdx < tokens.length && tokens[sIdx].startsWith('-'))
161
+ sIdx++;
162
+ if (sIdx < tokens.length) {
163
+ tokens = [tokens[sIdx], ...tokens.slice(sIdx + 1)];
164
+ }
165
+ }
166
+ const patternTokens = pattern.trim().split(/\s+/).filter(Boolean);
167
+ return matchCommandTokens(patternTokens, tokens);
168
+ }
169
+ /** Recognize -c inside short flag clusters, e.g. -c, -lc, -ec, -xc, -lec */
170
+ export function isShellCFlag(token) {
171
+ return /^-[a-zA-Z0-9]*c[a-zA-Z0-9]*$/.test(token);
172
+ }
173
+ /** Check if head token contains backslash, $, quote, or non-ASCII */
174
+ export function headTokenIsObfuscated(token) {
175
+ return /[\\$'"]|[^\x00-\x7F]/.test(token);
176
+ }
177
+ /** Tokenizer that preserves quote characters in tokens for obfuscation detection */
178
+ export function tokenizeCommandRaw(cmd) {
179
+ const tokens = [];
180
+ let i = 0;
181
+ const n = cmd.length;
182
+ while (i < n) {
183
+ while (i < n && /\s/.test(cmd[i]))
184
+ i++;
185
+ if (i >= n)
186
+ break;
187
+ let token = '';
188
+ let quote = null;
189
+ while (i < n) {
190
+ const c = cmd[i];
191
+ if (quote === null && /\s/.test(c))
192
+ break;
193
+ if (c === "'" || c === '"') {
194
+ if (quote === null)
195
+ quote = c;
196
+ else if (quote === c)
197
+ quote = null;
198
+ }
199
+ token += c;
200
+ i++;
201
+ }
202
+ tokens.push(token);
203
+ }
204
+ return tokens;
205
+ }
206
+ /** Check if rm invocation has both recursive and force flags */
207
+ export function isRmRfTokens(tokens) {
208
+ if (tokens.length === 0)
209
+ return false;
210
+ if (basename(tokens[0]) !== 'rm')
211
+ return false;
212
+ let hasRecursive = false;
213
+ let hasForce = false;
214
+ for (let i = 1; i < tokens.length; i++) {
215
+ const tok = tokens[i];
216
+ if (tok === '--') {
217
+ break;
218
+ }
219
+ if (tok === '--recursive') {
220
+ hasRecursive = true;
221
+ }
222
+ else if (tok === '--force') {
223
+ hasForce = true;
224
+ }
225
+ else if (tok.startsWith('-') && !tok.startsWith('--')) {
226
+ const flags = tok.slice(1);
227
+ if (flags.includes('r') || flags.includes('R')) {
228
+ hasRecursive = true;
229
+ }
230
+ if (flags.includes('f')) {
231
+ hasForce = true;
232
+ }
233
+ }
234
+ }
235
+ return hasRecursive && hasForce;
236
+ }
237
+ /** Keys that must not be written to in git config */
238
+ export function isDeniedGitConfigKey(key) {
239
+ const k = key.toLowerCase();
240
+ if (k === 'core.hookspath' ||
241
+ k === 'core.sshcommand' ||
242
+ k === 'core.pager' ||
243
+ k === 'core.editor' ||
244
+ k === 'core.gitproxy' ||
245
+ k === 'core.askpass') {
246
+ return true;
247
+ }
248
+ if (k.startsWith('credential.') ||
249
+ k.startsWith('alias.') ||
250
+ k.startsWith('filter.') ||
251
+ k.startsWith('url.') ||
252
+ k.startsWith('http.')) {
253
+ return true;
254
+ }
255
+ if (/^diff\..+\.command$/.test(k)) {
256
+ return true;
257
+ }
258
+ if (/^merge\..+\.driver$/.test(k)) {
259
+ return true;
260
+ }
261
+ if (/^remote\..+\.url$/.test(k)) {
262
+ return true;
263
+ }
264
+ return false;
265
+ }
266
+ const GIT_CONFIG_READ_FLAGS = new Set([
267
+ '-l',
268
+ '--list',
269
+ '--get',
270
+ '--get-all',
271
+ '--get-regexp',
272
+ '--get-urlmatch',
273
+ '--get-color',
274
+ '--get-colorbool',
275
+ ]);
276
+ function isForbiddenGitGlobalOption(tok) {
277
+ if (tok === '-c' || tok.startsWith('-c=') || tok.startsWith('-c'))
278
+ return true;
279
+ if (tok === '-C' || tok.startsWith('-C=') || tok.startsWith('-C'))
280
+ return true;
281
+ if (tok === '--git-dir' || tok.startsWith('--git-dir='))
282
+ return true;
283
+ if (tok === '--work-tree' || tok.startsWith('--work-tree='))
284
+ return true;
285
+ if (tok === '--exec-path' || tok.startsWith('--exec-path='))
286
+ return true;
287
+ if (tok === '--namespace' || tok.startsWith('--namespace='))
288
+ return true;
289
+ if (tok === '--config-env' || tok.startsWith('--config-env='))
290
+ return true;
291
+ return false;
292
+ }
293
+ /** Check if git command uses forbidden global options or writes to sensitive config keys */
294
+ export function checkGitSegment(tokens) {
295
+ if (tokens.length === 0 || basename(tokens[0]) !== 'git') {
296
+ return { denied: false };
297
+ }
298
+ let subIdx = -1;
299
+ for (let i = 1; i < tokens.length; i++) {
300
+ const tok = tokens[i];
301
+ if (isForbiddenGitGlobalOption(tok)) {
302
+ return { denied: true, reason: 'git_global_option_denied' };
303
+ }
304
+ if (!tok.startsWith('-')) {
305
+ subIdx = i;
306
+ break;
307
+ }
308
+ }
309
+ if (subIdx !== -1 && tokens[subIdx] === 'config') {
310
+ const configTokens = tokens.slice(subIdx + 1);
311
+ const isRead = configTokens.some((t) => GIT_CONFIG_READ_FLAGS.has(t) || t.startsWith('--get='));
312
+ if (!isRead) {
313
+ for (let j = 0; j < configTokens.length; j++) {
314
+ const t = configTokens[j];
315
+ if (t === '--file' || t === '-f') {
316
+ j++;
317
+ continue;
318
+ }
319
+ if (t.startsWith('-')) {
320
+ continue;
321
+ }
322
+ const key = t.includes('=') ? t.slice(0, t.indexOf('=')) : t;
323
+ if (isDeniedGitConfigKey(key)) {
324
+ return { denied: true, reason: 'git_config_key_denied' };
325
+ }
326
+ }
327
+ }
328
+ }
329
+ return { denied: false };
330
+ }
331
+ const FORBIDDEN_FIND_PREDICATES = new Set([
332
+ '-exec',
333
+ '-execdir',
334
+ '-ok',
335
+ '-okdir',
336
+ '-delete',
337
+ '-fprint',
338
+ '-fprint0',
339
+ '-fprintf',
340
+ '-fls',
341
+ ]);
342
+ export function isDeniedFindSegment(tokens) {
343
+ if (tokens.length === 0 || basename(tokens[0]) !== 'find')
344
+ return false;
345
+ return tokens.some((t) => FORBIDDEN_FIND_PREDICATES.has(t));
346
+ }
347
+ export function extractXargsTarget(tokens) {
348
+ if (tokens.length === 0 || basename(tokens[0]) !== 'xargs')
349
+ return null;
350
+ let i = 1;
351
+ while (i < tokens.length) {
352
+ const tok = tokens[i];
353
+ if (tok === '--') {
354
+ i++;
355
+ break;
356
+ }
357
+ if (!tok.startsWith('-')) {
358
+ break;
359
+ }
360
+ if (/^-[InLsPEda]$/.test(tok)) {
361
+ i += 2;
362
+ continue;
363
+ }
364
+ if ([
365
+ '--max-args',
366
+ '--max-lines',
367
+ '--max-chars',
368
+ '--max-procs',
369
+ '--replace',
370
+ '--eof',
371
+ '--delimiter',
372
+ '--arg-file',
373
+ ].includes(tok)) {
374
+ i += 2;
375
+ continue;
376
+ }
377
+ i++;
378
+ }
379
+ if (i >= tokens.length) {
380
+ return null;
381
+ }
382
+ return tokens.slice(i);
383
+ }
384
+ function basename(token) {
385
+ const idx = token.lastIndexOf('/');
386
+ return idx === -1 ? token : token.slice(idx + 1);
387
+ }
388
+ /** A minimal shell-aware tokenizer: `'...'`/`"..."` groups survive as one token, quotes stripped. */
389
+ export function tokenizeCommand(cmd) {
390
+ const tokens = [];
391
+ let i = 0;
392
+ const n = cmd.length;
393
+ while (i < n) {
394
+ while (i < n && /\s/.test(cmd[i]))
395
+ i++;
396
+ if (i >= n)
397
+ break;
398
+ let token = '';
399
+ while (i < n && !/\s/.test(cmd[i])) {
400
+ const c = cmd[i];
401
+ if (c === "'" || c === '"') {
402
+ const quote = c;
403
+ i++;
404
+ while (i < n && cmd[i] !== quote) {
405
+ token += cmd[i];
406
+ i++;
407
+ }
408
+ i++;
409
+ }
410
+ else {
411
+ token += c;
412
+ i++;
413
+ }
414
+ }
415
+ tokens.push(token);
416
+ }
417
+ return tokens;
418
+ }
419
+ export function stripEnvPrefixTokens(tokens) {
420
+ const assignments = [];
421
+ let i = 0;
422
+ let isExport = false;
423
+ while (i < tokens.length) {
424
+ const token = tokens[i];
425
+ if (token === 'export') {
426
+ isExport = true;
427
+ i++;
428
+ while (i < tokens.length) {
429
+ const t = tokens[i];
430
+ const eqIdx = t.indexOf('=');
431
+ if (eqIdx > 0 && /^[A-Za-z_][A-Za-z0-9_]*$/.test(t.slice(0, eqIdx))) {
432
+ assignments.push({ name: t.slice(0, eqIdx), value: t.slice(eqIdx + 1) });
433
+ i++;
434
+ }
435
+ else if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(t)) {
436
+ assignments.push({ name: t, value: '' });
437
+ i++;
438
+ }
439
+ else {
440
+ break;
441
+ }
442
+ }
443
+ break;
444
+ }
445
+ if (token === 'env') {
446
+ i++;
447
+ if (i < tokens.length && (tokens[i] === '-i' || tokens[i] === '--ignore-environment')) {
448
+ i++;
449
+ }
450
+ while (i < tokens.length) {
451
+ const t = tokens[i];
452
+ const eqIdx = t.indexOf('=');
453
+ if (eqIdx > 0 && /^[A-Za-z_][A-Za-z0-9_]*$/.test(t.slice(0, eqIdx))) {
454
+ assignments.push({ name: t.slice(0, eqIdx), value: t.slice(eqIdx + 1) });
455
+ i++;
456
+ }
457
+ else {
458
+ break;
459
+ }
460
+ }
461
+ break;
462
+ }
463
+ const eqIdx = token.indexOf('=');
464
+ if (eqIdx > 0 && /^[A-Za-z_][A-Za-z0-9_]*$/.test(token.slice(0, eqIdx))) {
465
+ assignments.push({ name: token.slice(0, eqIdx), value: token.slice(eqIdx + 1) });
466
+ i++;
467
+ continue;
468
+ }
469
+ break;
470
+ }
471
+ let deniedVar = null;
472
+ for (const a of assignments) {
473
+ if (isDeniedEnvVar(a.name)) {
474
+ deniedVar = a.name;
475
+ break;
476
+ }
477
+ }
478
+ const remainingTokens = tokens.slice(i);
479
+ const isAssignmentOnly = remainingTokens.length === 0 && assignments.length > 0;
480
+ return {
481
+ assignments,
482
+ deniedVar,
483
+ isExport,
484
+ isAssignmentOnly,
485
+ remainingTokens,
486
+ };
487
+ }
488
+ /**
489
+ * Split command line by delimiters `&&`, `||`, `;`, `|` outside quotes.
490
+ * Returns `null` on syntax error (unbalanced quotes, heredoc `<<`).
491
+ */
492
+ export function splitChainSegments(cmd) {
493
+ const segments = [];
494
+ let current = '';
495
+ let quote = null;
496
+ let i = 0;
497
+ const n = cmd.length;
498
+ while (i < n) {
499
+ const c = cmd[i];
500
+ if (quote === '"' && c === '\\' && i + 1 < n) {
501
+ current += c + cmd[i + 1];
502
+ i += 2;
503
+ continue;
504
+ }
505
+ if (c === "'" || c === '"') {
506
+ if (quote === null) {
507
+ quote = c;
508
+ }
509
+ else if (quote === c) {
510
+ quote = null;
511
+ }
512
+ current += c;
513
+ i++;
514
+ continue;
515
+ }
516
+ if (quote !== null) {
517
+ current += c;
518
+ i++;
519
+ continue;
520
+ }
521
+ // Heredoc check: << outside quotes
522
+ if (c === '<' && i + 1 < n && cmd[i + 1] === '<') {
523
+ return null;
524
+ }
525
+ // CRLF (\r\n) or LF (\n)
526
+ if (c === '\r' && i + 1 < n && cmd[i + 1] === '\n') {
527
+ const trimmed = current.trim();
528
+ if (trimmed.length > 0)
529
+ segments.push(trimmed);
530
+ current = '';
531
+ i += 2;
532
+ continue;
533
+ }
534
+ if (c === '\n') {
535
+ const trimmed = current.trim();
536
+ if (trimmed.length > 0)
537
+ segments.push(trimmed);
538
+ current = '';
539
+ i++;
540
+ continue;
541
+ }
542
+ // Check for && or ||
543
+ if ((c === '&' && i + 1 < n && cmd[i + 1] === '&') || (c === '|' && i + 1 < n && cmd[i + 1] === '|')) {
544
+ const trimmed = current.trim();
545
+ if (trimmed.length > 0)
546
+ segments.push(trimmed);
547
+ current = '';
548
+ i += 2;
549
+ continue;
550
+ }
551
+ // Check for |&
552
+ if (c === '|' && i + 1 < n && cmd[i + 1] === '&') {
553
+ const trimmed = current.trim();
554
+ if (trimmed.length > 0)
555
+ segments.push(trimmed);
556
+ current = '';
557
+ i += 2;
558
+ continue;
559
+ }
560
+ // Check for ;
561
+ if (c === ';') {
562
+ const trimmed = current.trim();
563
+ if (trimmed.length > 0)
564
+ segments.push(trimmed);
565
+ current = '';
566
+ i++;
567
+ continue;
568
+ }
569
+ // Check for standalone | (not part of redirect >|)
570
+ if (c === '|') {
571
+ const prevNonSpace = current.trimEnd().slice(-1);
572
+ if (prevNonSpace === '>') {
573
+ current += c;
574
+ i++;
575
+ continue;
576
+ }
577
+ const trimmed = current.trim();
578
+ if (trimmed.length > 0)
579
+ segments.push(trimmed);
580
+ current = '';
581
+ i++;
582
+ continue;
583
+ }
584
+ // Check for single & (not part of &&, &>, or >&)
585
+ if (c === '&') {
586
+ const prevNonSpace = current.trimEnd().slice(-1);
587
+ if (prevNonSpace === '>' || (i + 1 < n && cmd[i + 1] === '>')) {
588
+ current += c;
589
+ i++;
590
+ continue;
591
+ }
592
+ const trimmed = current.trim();
593
+ if (trimmed.length > 0)
594
+ segments.push(trimmed);
595
+ current = '';
596
+ i++;
597
+ continue;
598
+ }
599
+ current += c;
600
+ i++;
601
+ }
602
+ if (quote !== null) {
603
+ return null;
604
+ }
605
+ const trimmed = current.trim();
606
+ if (trimmed.length > 0) {
607
+ segments.push(trimmed);
608
+ }
609
+ return segments;
610
+ }
611
+ /**
612
+ * Extract command substitutions `$(...)` and ``` `...` ``` outside single quotes.
613
+ * Returns `null` on unbalanced substitution or quote syntax error.
614
+ */
615
+ export function extractCommandSubstitutions(segment) {
616
+ const substitutions = [];
617
+ let sanitized = '';
618
+ let i = 0;
619
+ const n = segment.length;
620
+ let quote = null;
621
+ while (i < n) {
622
+ const c = segment[i];
623
+ if (quote === '"' && c === '\\' && i + 1 < n) {
624
+ sanitized += c + segment[i + 1];
625
+ i += 2;
626
+ continue;
627
+ }
628
+ if (c === "'" || c === '"') {
629
+ if (quote === null) {
630
+ quote = c;
631
+ }
632
+ else if (quote === c) {
633
+ quote = null;
634
+ }
635
+ sanitized += c;
636
+ i++;
637
+ continue;
638
+ }
639
+ if (quote === "'") {
640
+ sanitized += c;
641
+ i++;
642
+ continue;
643
+ }
644
+ if ((c === '$' || c === '<' || c === '>') && i + 1 < n && segment[i + 1] === '(') {
645
+ i += 2;
646
+ let depth = 1;
647
+ let subQuote = null;
648
+ let inner = '';
649
+ while (i < n && depth > 0) {
650
+ const sc = segment[i];
651
+ if (subQuote === '"' && sc === '\\' && i + 1 < n) {
652
+ inner += sc + segment[i + 1];
653
+ i += 2;
654
+ continue;
655
+ }
656
+ if (sc === "'" || sc === '"') {
657
+ if (subQuote === null)
658
+ subQuote = sc;
659
+ else if (subQuote === sc)
660
+ subQuote = null;
661
+ inner += sc;
662
+ i++;
663
+ continue;
664
+ }
665
+ if (subQuote === null) {
666
+ if (sc === '(') {
667
+ depth++;
668
+ }
669
+ else if (sc === ')') {
670
+ depth--;
671
+ if (depth === 0) {
672
+ i++;
673
+ break;
674
+ }
675
+ }
676
+ }
677
+ inner += sc;
678
+ i++;
679
+ }
680
+ if (depth > 0 || subQuote !== null) {
681
+ return null;
682
+ }
683
+ substitutions.push(inner.trim());
684
+ sanitized += '__SUBST__';
685
+ continue;
686
+ }
687
+ if (c === '`') {
688
+ i++;
689
+ let inner = '';
690
+ let closed = false;
691
+ while (i < n) {
692
+ const bc = segment[i];
693
+ if (bc === '\\' && i + 1 < n) {
694
+ inner += bc + segment[i + 1];
695
+ i += 2;
696
+ continue;
697
+ }
698
+ if (bc === '`') {
699
+ closed = true;
700
+ i++;
701
+ break;
702
+ }
703
+ inner += bc;
704
+ i++;
705
+ }
706
+ if (!closed) {
707
+ return null;
708
+ }
709
+ substitutions.push(inner.trim());
710
+ sanitized += '__SUBST__';
711
+ continue;
712
+ }
713
+ sanitized += c;
714
+ i++;
715
+ }
716
+ if (quote !== null)
717
+ return null;
718
+ return { sanitized, substitutions };
719
+ }
720
+ const SHELL_WRAPPERS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh']);
721
+ const EXEC_WRAPPERS = new Set(['env', 'xargs', 'nohup', 'nice', 'exec']);
722
+ function windowMatchesPattern(patternTokens, valueTokens, start) {
723
+ for (let i = 0; i < patternTokens.length; i++) {
724
+ const alternatives = patternTokens[i].split('|');
725
+ const valueTok = valueTokens[start + i];
726
+ if (alternatives.includes(valueTok))
727
+ continue;
728
+ if (i === 0 && alternatives.includes(basename(valueTok)))
729
+ continue;
730
+ return false;
731
+ }
732
+ return true;
733
+ }
734
+ /**
735
+ * Does `value`, once tokenized, contain `patternTokens` as a contiguous run
736
+ * anywhere — not just at position 0 — and, failing that, does unwrapping one
737
+ * layer of `sh -c '...'` / `env` / `xargs` / `nohup` / `nice` reveal it? Used
738
+ * only for deny-list matching (finding 15): a single shell wrapper must not be
739
+ * enough to hide a HARD_DENY command from the gate.
740
+ */
741
+ function commandLineContainsPattern(patternTokens, value) {
742
+ const valueTokens = tokenizeCommand(value);
743
+ for (let start = 0; start + patternTokens.length <= valueTokens.length; start++) {
744
+ if (windowMatchesPattern(patternTokens, valueTokens, start))
745
+ return true;
746
+ }
747
+ // Handle rm -rf flag sets
748
+ if (patternTokens.length === 2 && patternTokens[0] === 'rm' && patternTokens[1] === '-rf') {
749
+ for (let i = 0; i < valueTokens.length; i++) {
750
+ if (basename(valueTokens[i]) === 'rm') {
751
+ if (isRmRfTokens(valueTokens.slice(i)))
752
+ return true;
753
+ }
754
+ }
755
+ }
756
+ // Check command substitutions inside value
757
+ const subst = extractCommandSubstitutions(value);
758
+ if (subst && subst.substitutions.length > 0) {
759
+ for (const inner of subst.substitutions) {
760
+ if (commandLineContainsPattern(patternTokens, inner))
761
+ return true;
762
+ }
763
+ }
764
+ if (valueTokens.length === 0)
765
+ return false;
766
+ const head = basename(valueTokens[0]);
767
+ if (SHELL_WRAPPERS.has(head)) {
768
+ const cIdx = valueTokens.findIndex((t, idx) => idx > 0 && isShellCFlag(t));
769
+ if (cIdx !== -1 && cIdx + 1 < valueTokens.length) {
770
+ const nested = valueTokens.slice(cIdx + 1).join(' ');
771
+ if (commandLineContainsPattern(patternTokens, nested))
772
+ return true;
773
+ }
774
+ return false;
775
+ }
776
+ if (EXEC_WRAPPERS.has(head)) {
777
+ let i = 1;
778
+ while (i < valueTokens.length && valueTokens[i].startsWith('-'))
779
+ i++;
780
+ while (i < valueTokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(valueTokens[i]))
781
+ i++;
782
+ if (i < valueTokens.length) {
783
+ const nested = valueTokens.slice(i).join(' ');
784
+ if (commandLineContainsPattern(patternTokens, nested))
785
+ return true;
786
+ }
787
+ }
788
+ return false;
789
+ }
790
+ /**
791
+ * Deny-only matcher. Unlike {@link matchRule} (used for allow-list matching,
792
+ * where anchoring the pattern at the command's first token is intentional
793
+ * precision — an allow rule must never widen because it happens to appear
794
+ * mid-command), this scans every token window of the (tokenized, quote-aware)
795
+ * command line and unwraps one layer of a shell/exec wrapper before giving up,
796
+ * so `sh -c 'git push origin main'`, `/usr/bin/curl ...` and `env curl ...`
797
+ * cannot slip past a `command(git push)` / `command(curl)` HARD_DENY entry
798
+ * (finding 15).
799
+ */
800
+ export function matchRuleForDenial(rule, subject) {
801
+ if (rule.verb !== subject.verb)
802
+ return false;
803
+ if (rule.regex) {
804
+ try {
805
+ return new RegExp(rule.pattern).test(subject.value);
806
+ }
807
+ catch {
808
+ return false;
809
+ }
810
+ }
811
+ if (rule.pattern === '*')
812
+ return true;
813
+ if (rule.verb === 'command') {
814
+ const patternTokens = rule.pattern.trim().split(/\s+/).filter(Boolean);
815
+ if (patternTokens.length === 0)
816
+ return false;
817
+ return commandLineContainsPattern(patternTokens, subject.value);
818
+ }
819
+ if (rule.pattern === '/')
820
+ return true;
821
+ return globToRegex(rule.pattern).test(subject.value);
822
+ }
823
+ /** Deny-list form of {@link firstMatch}, using {@link matchRuleForDenial}. */
824
+ export function firstMatchForDenial(rules, subject) {
825
+ for (const rule of rules) {
826
+ if (matchRuleForDenial(rule, subject))
827
+ return rule;
828
+ }
829
+ return null;
830
+ }
831
+ /**
832
+ * Bare glob matching against a filesystem path — no `verb(pattern)` rule
833
+ * envelope. Used for `Ceiling.read_roots`, which is a list of glob patterns,
834
+ * not permission rules, so it
835
+ * has no verb to check. Same `**`/`*` semantics `matchRule` already uses for
836
+ * `read_file`/`write_file` patterns (`globToRegex`) — exported here instead of
837
+ * a second implementation living in `policy/ceiling.ts`.
838
+ */
839
+ export function matchesPathGlob(pattern, value) {
840
+ if (pattern === '/')
841
+ return true;
842
+ return globToRegex(pattern).test(value);
843
+ }
844
+ export function matchRule(rule, subject) {
845
+ if (rule.verb !== subject.verb)
846
+ return false;
847
+ if (rule.regex) {
848
+ try {
849
+ return new RegExp(rule.pattern).test(subject.value);
850
+ }
851
+ catch {
852
+ return false;
853
+ }
854
+ }
855
+ if (rule.pattern === '*')
856
+ return true;
857
+ if (rule.verb === 'command')
858
+ return matchCommandPattern(rule.pattern, subject.value);
859
+ // `read_file(/)` etc.: `/` is the filesystem root, i.e. "matches everything".
860
+ if (rule.pattern === '/')
861
+ return true;
862
+ return globToRegex(rule.pattern).test(subject.value);
863
+ }
864
+ function requiredRuleForTokens(tokens) {
865
+ if (tokens[0] === 'git' && tokens.length >= 2 && !tokens[1]?.startsWith('-')) {
866
+ return `command(git ${tokens[1]})`;
867
+ }
868
+ return `command(${tokens.join(' ')})`;
869
+ }
870
+ export function evaluateCommandPolicy(commandLine, allowRules, denyRules, depth = 0) {
871
+ if (depth > 4) {
872
+ return {
873
+ allowed: false,
874
+ stage: 'default',
875
+ matchedRule: null,
876
+ requiredRule: null,
877
+ reason: 'recursion_limit_exceeded',
878
+ };
879
+ }
880
+ // Deny scan across whole command string (maintain existing window scan behavior)
881
+ for (const rule of denyRules) {
882
+ if (matchRuleForDenial(rule, { verb: 'command', value: commandLine })) {
883
+ return {
884
+ allowed: false,
885
+ stage: 'deny_list',
886
+ matchedRule: rule.raw,
887
+ requiredRule: null,
888
+ };
889
+ }
890
+ }
891
+ // Split chain by delimiters (&&, ||, ;, |, \n, \r\n, &, |&)
892
+ const segments = splitChainSegments(commandLine);
893
+ if (segments === null || segments.length === 0) {
894
+ return {
895
+ allowed: false,
896
+ stage: 'deny_list',
897
+ matchedRule: null,
898
+ requiredRule: null,
899
+ reason: 'parse_error',
900
+ };
901
+ }
902
+ let firstMatchedRule = null;
903
+ for (const segment of segments) {
904
+ // Check command substitutions: $(...), `...`, <(...), >(...)
905
+ const subst = extractCommandSubstitutions(segment);
906
+ if (subst === null) {
907
+ return {
908
+ allowed: false,
909
+ stage: 'deny_list',
910
+ matchedRule: null,
911
+ requiredRule: null,
912
+ reason: 'parse_error',
913
+ };
914
+ }
915
+ for (const inner of subst.substitutions) {
916
+ const innerResult = evaluateCommandPolicy(inner, allowRules, denyRules, depth + 1);
917
+ if (!innerResult.allowed) {
918
+ return innerResult;
919
+ }
920
+ }
921
+ // Tokenize sanitized segment
922
+ const tokens = tokenizeCommand(subst.sanitized);
923
+ if (tokens.length === 0)
924
+ continue;
925
+ const rawTokens = tokenizeCommandRaw(subst.sanitized);
926
+ // Strip env prefix
927
+ const envRes = stripEnvPrefixTokens(tokens);
928
+ if (envRes.deniedVar !== null) {
929
+ return {
930
+ allowed: false,
931
+ stage: 'deny_list',
932
+ matchedRule: null,
933
+ requiredRule: null,
934
+ reason: 'env_assignment_denied',
935
+ };
936
+ }
937
+ if (envRes.remainingTokens.length === 0) {
938
+ if (envRes.isExport || envRes.isAssignmentOnly) {
939
+ if (!firstMatchedRule)
940
+ firstMatchedRule = 'command(export)';
941
+ continue;
942
+ }
943
+ return {
944
+ allowed: false,
945
+ stage: 'default',
946
+ matchedRule: null,
947
+ requiredRule: null,
948
+ };
949
+ }
950
+ let segTokens = envRes.remainingTokens;
951
+ const strippedCount = tokens.length - segTokens.length;
952
+ const rawHead = rawTokens.length > strippedCount ? rawTokens[strippedCount] : null;
953
+ if (headTokenIsObfuscated(segTokens[0]) || (rawHead !== null && headTokenIsObfuscated(rawHead))) {
954
+ return {
955
+ allowed: false,
956
+ stage: 'deny_list',
957
+ matchedRule: null,
958
+ requiredRule: null,
959
+ reason: 'parse_error',
960
+ };
961
+ }
962
+ let head = basename(segTokens[0]);
963
+ if (head === 'xargs') {
964
+ const target = extractXargsTarget(segTokens);
965
+ if (target === null || target.length === 0) {
966
+ return {
967
+ allowed: false,
968
+ stage: 'deny_list',
969
+ matchedRule: null,
970
+ requiredRule: null,
971
+ reason: 'xargs_target_missing',
972
+ };
973
+ }
974
+ const targetResult = evaluateCommandPolicy(target.join(' '), allowRules, denyRules, depth + 1);
975
+ if (!targetResult.allowed) {
976
+ return targetResult;
977
+ }
978
+ if (!firstMatchedRule)
979
+ firstMatchedRule = targetResult.matchedRule;
980
+ continue;
981
+ }
982
+ if (['bash', 'sh'].includes(head)) {
983
+ const cIdx = segTokens.findIndex((t, idx) => idx > 0 && isShellCFlag(t));
984
+ if (cIdx !== -1) {
985
+ if (cIdx + 1 < segTokens.length) {
986
+ const inner = segTokens[cIdx + 1];
987
+ const innerResult = evaluateCommandPolicy(inner, allowRules, denyRules, depth + 1);
988
+ if (!innerResult.allowed) {
989
+ return innerResult;
990
+ }
991
+ if (!firstMatchedRule)
992
+ firstMatchedRule = innerResult.matchedRule;
993
+ continue;
994
+ }
995
+ else {
996
+ return {
997
+ allowed: false,
998
+ stage: 'default',
999
+ matchedRule: null,
1000
+ requiredRule: `command(${head} -c)`,
1001
+ };
1002
+ }
1003
+ }
1004
+ else {
1005
+ // bash <script> without -c
1006
+ let sIdx = 1;
1007
+ while (sIdx < segTokens.length && segTokens[sIdx].startsWith('-'))
1008
+ sIdx++;
1009
+ if (sIdx < segTokens.length) {
1010
+ segTokens = [segTokens[sIdx], ...segTokens.slice(sIdx + 1)];
1011
+ head = basename(segTokens[0]);
1012
+ }
1013
+ }
1014
+ }
1015
+ if (['zsh', 'dash', 'fish', 'ksh', 'csh', 'tcsh'].includes(head)) {
1016
+ const hasC = segTokens.some((t, idx) => idx > 0 && isShellCFlag(t));
1017
+ if (hasC) {
1018
+ let shellWrapperAllowed = false;
1019
+ for (const rule of allowRules) {
1020
+ if (matchRule(rule, { verb: 'command', value: segTokens.join(' ') })) {
1021
+ shellWrapperAllowed = true;
1022
+ if (!firstMatchedRule)
1023
+ firstMatchedRule = rule.raw;
1024
+ break;
1025
+ }
1026
+ }
1027
+ if (!shellWrapperAllowed) {
1028
+ return {
1029
+ allowed: false,
1030
+ stage: 'deny_list',
1031
+ matchedRule: null,
1032
+ requiredRule: `command(${head} -c)`,
1033
+ reason: 'shell_wrapper_denied',
1034
+ };
1035
+ }
1036
+ continue;
1037
+ }
1038
+ }
1039
+ if (isRmRfTokens(segTokens)) {
1040
+ const rmRfRule = denyRules.find((r) => r.verb === 'command' && r.pattern === 'rm -rf');
1041
+ if (rmRfRule) {
1042
+ return {
1043
+ allowed: false,
1044
+ stage: 'deny_list',
1045
+ matchedRule: rmRfRule.raw,
1046
+ requiredRule: null,
1047
+ };
1048
+ }
1049
+ }
1050
+ const gitCheck = checkGitSegment(segTokens);
1051
+ if (gitCheck.denied) {
1052
+ return {
1053
+ allowed: false,
1054
+ stage: 'deny_list',
1055
+ matchedRule: null,
1056
+ requiredRule: null,
1057
+ reason: gitCheck.reason,
1058
+ };
1059
+ }
1060
+ if (isDeniedFindSegment(segTokens)) {
1061
+ return {
1062
+ allowed: false,
1063
+ stage: 'deny_list',
1064
+ matchedRule: null,
1065
+ requiredRule: null,
1066
+ reason: 'find_predicate_denied',
1067
+ };
1068
+ }
1069
+ const segCmd = segTokens.join(' ');
1070
+ // Check deny list for this segment
1071
+ for (const rule of denyRules) {
1072
+ if (matchRuleForDenial(rule, { verb: 'command', value: segCmd })) {
1073
+ return {
1074
+ allowed: false,
1075
+ stage: 'deny_list',
1076
+ matchedRule: rule.raw,
1077
+ requiredRule: null,
1078
+ };
1079
+ }
1080
+ }
1081
+ // Check allow list for this segment
1082
+ let matched = false;
1083
+ for (const rule of allowRules) {
1084
+ if (matchRule(rule, { verb: 'command', value: segCmd })) {
1085
+ matched = true;
1086
+ if (!firstMatchedRule)
1087
+ firstMatchedRule = rule.raw;
1088
+ break;
1089
+ }
1090
+ }
1091
+ if (!matched) {
1092
+ const required = requiredRuleForTokens(segTokens);
1093
+ return {
1094
+ allowed: false,
1095
+ stage: 'default',
1096
+ matchedRule: null,
1097
+ requiredRule: required,
1098
+ };
1099
+ }
1100
+ }
1101
+ return {
1102
+ allowed: true,
1103
+ stage: 'profile_allowlist',
1104
+ matchedRule: firstMatchedRule ?? (allowRules[0]?.raw ?? null),
1105
+ requiredRule: null,
1106
+ };
1107
+ }
1108
+ /** First matching rule in order, or null. Deny lists are scanned before allow lists. */
1109
+ export function firstMatch(rules, subject) {
1110
+ if (subject.verb === 'command') {
1111
+ const evalResult = evaluateCommandPolicy(subject.value, rules, []);
1112
+ if (evalResult.allowed) {
1113
+ if (evalResult.matchedRule) {
1114
+ try {
1115
+ return parseRule(evalResult.matchedRule);
1116
+ }
1117
+ catch {
1118
+ // fallback
1119
+ }
1120
+ }
1121
+ return rules[0] ?? null;
1122
+ }
1123
+ return null;
1124
+ }
1125
+ for (const rule of rules) {
1126
+ if (matchRule(rule, subject))
1127
+ return rule;
1128
+ }
1129
+ return null;
1130
+ }
1131
+ /**
1132
+ * Intersect a client's requested allow list with a profile ceiling.
1133
+ *
1134
+ * A request is kept only when some ceiling rule of the same verb covers it — an
1135
+ * unrelated rule can never let something new through. This is the single place
1136
+ * that stops a client widening the server's hard limit (see docs/permissions.md).
1137
+ *
1138
+ * "Covers" is checked by matching the *ceiling* rule against the *requested*
1139
+ * rule's own pattern treated as a subject value — the requested rule must be at
1140
+ * least as narrow as something the ceiling already allows.
1141
+ */
1142
+ export function intersectAllow(requested, ceiling) {
1143
+ const parsedCeiling = parseRulesLenient(ceiling);
1144
+ const allowed = [];
1145
+ const rejected = [];
1146
+ for (const raw of requested) {
1147
+ let req;
1148
+ try {
1149
+ req = parseRule(raw);
1150
+ }
1151
+ catch {
1152
+ rejected.push(raw);
1153
+ continue;
1154
+ }
1155
+ const covered = parsedCeiling.some((c) => c.verb === req.verb && matchRule(c, { verb: req.verb, value: req.pattern }));
1156
+ if (covered)
1157
+ allowed.push(raw);
1158
+ else
1159
+ rejected.push(raw);
1160
+ }
1161
+ return { allowed, rejected };
1162
+ }
1163
+ /**
1164
+ * The narrowest rule string that would have permitted `subject`.
1165
+ * Surfaced as `required_rule` so the calling agent can retry without a round trip.
1166
+ */
1167
+ export function requiredRuleFor(subject) {
1168
+ if (subject.verb === 'command') {
1169
+ const tokens = tokenizeCommand(subject.value);
1170
+ const stripped = stripEnvPrefixTokens(tokens).remainingTokens;
1171
+ if (stripped[0] === 'git' && stripped.length >= 2 && !stripped[1]?.startsWith('-')) {
1172
+ return `command(git ${stripped[1]})`;
1173
+ }
1174
+ if (stripped.length > 0) {
1175
+ return `command(${stripped.join(' ')})`;
1176
+ }
1177
+ }
1178
+ return `${subject.verb}(${subject.value})`;
1179
+ }
1180
+ //# sourceMappingURL=rules.js.map