@warlock.js/ai-workspace 5.30.0 → 5.31.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.
Files changed (114) hide show
  1. package/CHANGELOG.md +46 -2
  2. package/README.md +34 -21
  3. package/cjs/index.cjs +3116 -309
  4. package/cjs/index.cjs.map +1 -1
  5. package/esm/backends/local.d.mts.map +1 -1
  6. package/esm/backends/local.mjs +165 -27
  7. package/esm/backends/local.mjs.map +1 -1
  8. package/esm/backends/mock.d.mts +11 -5
  9. package/esm/backends/mock.d.mts.map +1 -1
  10. package/esm/backends/mock.mjs +34 -0
  11. package/esm/backends/mock.mjs.map +1 -1
  12. package/esm/contracts/index.d.mts +3 -3
  13. package/esm/contracts/tool-io.type.d.mts +202 -4
  14. package/esm/contracts/tool-io.type.d.mts.map +1 -1
  15. package/esm/contracts/workspace-backend.contract.d.mts +61 -1
  16. package/esm/contracts/workspace-backend.contract.d.mts.map +1 -1
  17. package/esm/contracts/workspace-ops.contract.d.mts +32 -3
  18. package/esm/contracts/workspace-ops.contract.d.mts.map +1 -1
  19. package/esm/contracts/workspace-policy.type.d.mts +133 -12
  20. package/esm/contracts/workspace-policy.type.d.mts.map +1 -1
  21. package/esm/contracts/workspace.contract.d.mts +63 -9
  22. package/esm/contracts/workspace.contract.d.mts.map +1 -1
  23. package/esm/errors.d.mts +79 -5
  24. package/esm/errors.d.mts.map +1 -1
  25. package/esm/errors.mjs +22 -1
  26. package/esm/errors.mjs.map +1 -1
  27. package/esm/index.d.mts +13 -5
  28. package/esm/index.mjs +10 -2
  29. package/esm/ops.d.mts +21 -2
  30. package/esm/ops.d.mts.map +1 -1
  31. package/esm/ops.mjs +230 -64
  32. package/esm/ops.mjs.map +1 -1
  33. package/esm/policy/jail.mjs +73 -0
  34. package/esm/policy/jail.mjs.map +1 -0
  35. package/esm/policy/policy.d.mts +25 -15
  36. package/esm/policy/policy.d.mts.map +1 -1
  37. package/esm/policy/policy.mjs +61 -85
  38. package/esm/policy/policy.mjs.map +1 -1
  39. package/esm/policy/shell-check.d.mts +64 -0
  40. package/esm/policy/shell-check.d.mts.map +1 -0
  41. package/esm/policy/shell-check.mjs +627 -0
  42. package/esm/policy/shell-check.mjs.map +1 -0
  43. package/esm/policy/shell-parser.mjs +802 -0
  44. package/esm/policy/shell-parser.mjs.map +1 -0
  45. package/esm/policy/tokenize-command.d.mts +63 -0
  46. package/esm/policy/tokenize-command.d.mts.map +1 -0
  47. package/esm/policy/tokenize-command.mjs +75 -23
  48. package/esm/policy/tokenize-command.mjs.map +1 -1
  49. package/esm/shell-jobs.d.mts +45 -0
  50. package/esm/shell-jobs.d.mts.map +1 -0
  51. package/esm/shell-jobs.mjs +247 -0
  52. package/esm/shell-jobs.mjs.map +1 -0
  53. package/esm/todos.d.mts +22 -0
  54. package/esm/todos.d.mts.map +1 -0
  55. package/esm/todos.mjs +73 -0
  56. package/esm/todos.mjs.map +1 -0
  57. package/esm/tools/edit-file.d.mts.map +1 -1
  58. package/esm/tools/edit-file.mjs +2 -1
  59. package/esm/tools/edit-file.mjs.map +1 -1
  60. package/esm/tools/glob.d.mts.map +1 -1
  61. package/esm/tools/glob.mjs +2 -1
  62. package/esm/tools/glob.mjs.map +1 -1
  63. package/esm/tools/grep.d.mts.map +1 -1
  64. package/esm/tools/grep.mjs +2 -1
  65. package/esm/tools/grep.mjs.map +1 -1
  66. package/esm/tools/json-schemas.mjs +175 -0
  67. package/esm/tools/json-schemas.mjs.map +1 -0
  68. package/esm/tools/list-dir.d.mts +32 -0
  69. package/esm/tools/list-dir.d.mts.map +1 -0
  70. package/esm/tools/list-dir.mjs +50 -0
  71. package/esm/tools/list-dir.mjs.map +1 -0
  72. package/esm/tools/multi-edit.d.mts +38 -0
  73. package/esm/tools/multi-edit.d.mts.map +1 -0
  74. package/esm/tools/multi-edit.mjs +121 -0
  75. package/esm/tools/multi-edit.mjs.map +1 -0
  76. package/esm/tools/read-file.d.mts.map +1 -1
  77. package/esm/tools/read-file.mjs +2 -1
  78. package/esm/tools/read-file.mjs.map +1 -1
  79. package/esm/tools/run-shell.d.mts +34 -12
  80. package/esm/tools/run-shell.d.mts.map +1 -1
  81. package/esm/tools/run-shell.mjs +59 -53
  82. package/esm/tools/run-shell.mjs.map +1 -1
  83. package/esm/tools/run-tests.d.mts +8 -0
  84. package/esm/tools/run-tests.d.mts.map +1 -1
  85. package/esm/tools/run-tests.mjs +10 -3
  86. package/esm/tools/run-tests.mjs.map +1 -1
  87. package/esm/tools/schema.mjs +23 -18
  88. package/esm/tools/schema.mjs.map +1 -1
  89. package/esm/tools/shell-kill.d.mts +25 -0
  90. package/esm/tools/shell-kill.d.mts.map +1 -0
  91. package/esm/tools/shell-kill.mjs +36 -0
  92. package/esm/tools/shell-kill.mjs.map +1 -0
  93. package/esm/tools/shell-output.d.mts +30 -0
  94. package/esm/tools/shell-output.d.mts.map +1 -0
  95. package/esm/tools/shell-output.mjs +49 -0
  96. package/esm/tools/shell-output.mjs.map +1 -0
  97. package/esm/tools/todo.d.mts +43 -0
  98. package/esm/tools/todo.d.mts.map +1 -0
  99. package/esm/tools/todo.mjs +126 -0
  100. package/esm/tools/todo.mjs.map +1 -0
  101. package/esm/tools/write-file.d.mts.map +1 -1
  102. package/esm/tools/write-file.mjs +2 -1
  103. package/esm/tools/write-file.mjs.map +1 -1
  104. package/esm/workspace.d.mts +11 -8
  105. package/esm/workspace.d.mts.map +1 -1
  106. package/esm/workspace.mjs +108 -27
  107. package/esm/workspace.mjs.map +1 -1
  108. package/llms-full.txt +155 -34
  109. package/llms.txt +1 -1
  110. package/package.json +3 -3
  111. package/skills/README.md +2 -2
  112. package/skills/build-loop-agent/SKILL.md +7 -7
  113. package/skills/index.md +6 -1
  114. package/skills/use-a-workspace/SKILL.md +148 -27
@@ -26,6 +26,17 @@ interface ResolvedPath {
26
26
  * `denyPaths` glob even while inside `cwd`, is rejected with a
27
27
  * {@link WorkspacePolicyError} of type `"path-escape"`.
28
28
  *
29
+ * Why this does not use `@warlock.js/fs`'s confined reader (`fs.confined`):
30
+ * the two answer different questions. The confined reader refuses absolute
31
+ * paths, refuses every symbolic link (or follows only links inside its one
32
+ * root), requires the path to exist, and reads the file itself through a
33
+ * no-follow, identity-checked open. This jail must instead accept absolute
34
+ * paths inside `cwd`, several `allowPaths` roots, `denyPaths` globs, a
35
+ * symlinked `cwd`, links whose target stays inside, and paths that do not
36
+ * exist yet (`writeFile`, `mkdir`), and it returns a path that writes,
37
+ * `exec`'s working directory and redirections use afterwards. Switching would
38
+ * change what agents may touch, so the workspace keeps this resolver.
39
+ *
29
40
  * @param policy - The bounding policy (its `cwd` is the jail root).
30
41
  * @param inputPath - A workspace-relative or absolute path to resolve.
31
42
  * @returns The canonical absolute path plus its `/`-separated relative form.
@@ -36,23 +47,22 @@ interface ResolvedPath {
36
47
  */
37
48
  declare function resolveInJail(policy: WorkspacePolicy, inputPath: string): Promise<ResolvedPath>;
38
49
  /**
39
- * Whether a shell command is permitted by the policy's `shell` sub-policy.
50
+ * Whether a command line is permitted by the policy's `shell` sub-policy —
51
+ * `checkShellCommand(policy, command).allowed`.
40
52
  *
41
- * The command is first tokenized via {@link tokenizeCommand} — a command
42
- * that cannot be represented as a single argv (unbalanced quotes, or
43
- * unquoted shell metacharacters such as `;`, `&&`, `|`, backticks, `$`,
44
- * redirection) is denied outright. The backend spawns the argv directly
45
- * with no shell, so such a command has no meaning here — and unquoted
46
- * metacharacters were exactly how an injected command chain used to ride
47
- * past the allowlist. The resolved `argv[0]` basename is then matched
48
- * against `shell.deny` then `shell.allow`. **Deny always wins.** When
49
- * `shell.allow` is set, the executable MUST appear in it (fail-closed
50
- * allowlist); when `allow` is absent/empty, any non-denied command is
51
- * permitted. An absent `shell` block means no command may run at all.
53
+ * In argv mode (the default) the command must tokenize into a single argv
54
+ * (no unquoted `;`, `&&`, `|`, backticks, `$`, redirection, …): the backend
55
+ * spawns it with no shell, and unquoted metacharacters were exactly how an
56
+ * injected command chain used to ride past the allowlist. In shell mode
57
+ * (`interpreter: "sh"`) every simple command in the line is checked. Each
58
+ * argv's executable basename is matched against `shell.deny` (wins, case
59
+ * insensitive) then `shell.allow` (exhaustive when non-empty), then
60
+ * `shell.denyPatterns` and `shell.allowCommand`. An absent `shell` block
61
+ * means no command may run at all.
52
62
  *
53
- * Returns a plain `boolean` rather than throwing — the ops layer raises
54
- * the {@link WorkspacePolicyError} so the thrown context (`command`)
55
- * lives next to the call site.
63
+ * Returns a plain `boolean`; use {@link checkShellCommand} for the reason
64
+ * and the model-facing message. Redirection targets are checked lexically
65
+ * here; `exec` re-checks them with symlinks resolved.
56
66
  *
57
67
  * @example
58
68
  * isCommandAllowed({ cwd, shell: { allow: ["npm"] } }, "npm test"); // true
@@ -1 +1 @@
1
- {"version":3,"file":"policy.d.mts","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/policy/policy.ts"],"mappings":";;;;;AAYA;;;UAAiB,YAAA;EAQH;EANZ,YAAA;EAkJiC;;;;;EA5IjC,YAAY;AAAA;;;;;;;AA+IS;AAoFvB;;;;;;;;AAEiB;AA+CjB;;iBAxIsB,aAAA,CACpB,MAAA,EAAQ,eAAA,EACR,SAAA,WACC,OAAA,CAAQ,YAAA;;;;;;AAqI8C;;;;;;;;;;;;;;;;;;;iBAjDzC,gBAAA,CACd,MAAA,EAAQ,eAAe,EACvB,OAAA;;;;;;;;;;;;;iBA+Cc,QAAA,CAAS,MAAA,EAAQ,eAAA,GAAkB,MAAM"}
1
+ {"version":3,"file":"policy.d.mts","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/policy/policy.ts"],"mappings":";;;;;AAaA;;;UAAiB,YAAA;EAQH;EANZ,YAAA;EA+EiC;;;;;EAzEjC,YAAY;AAAA;;;;;;;AA4ES;AAqEvB;;;;;;;;AAAyE;AAgBzE;;;;;;;;AAAyD;;;;;iBAxFnC,aAAA,CACpB,MAAA,EAAQ,eAAA,EACR,SAAA,WACC,OAAA,CAAQ,YAAA;;;;;;;;;;;;;;;;;;;;;;;;iBAqEK,gBAAA,CAAiB,MAAA,EAAQ,eAAe,EAAE,OAAA;;;;;;;;;;;;;iBAgB1C,QAAA,CAAS,MAAA,EAAQ,eAAA,GAAkB,MAAM"}
@@ -1,5 +1,6 @@
1
1
  import { WorkspacePolicyError } from "../errors.mjs";
2
- import { tokenizeCommand } from "./tokenize-command.mjs";
2
+ import { isInside, matchesAnyGlob } from "./jail.mjs";
3
+ import { checkShellCommand } from "./shell-check.mjs";
3
4
  import path from "node:path";
4
5
  import { realpath } from "node:fs/promises";
5
6
 
@@ -26,55 +27,6 @@ async function canonicalize(target) {
26
27
  }
27
28
  }
28
29
  /**
29
- * Whether `child` is contained within `root` (or equals it), comparing
30
- * canonical absolute paths. Guards against the `/srv/app-evil` vs
31
- * `/srv/app` prefix-collision by anchoring on a path separator.
32
- */
33
- function isInside(child, root) {
34
- const relative = path.relative(root, child);
35
- return relative === "" || !relative.startsWith("..") && !path.isAbsolute(relative);
36
- }
37
- /**
38
- * Translate a glob (the small subset used by `denyPaths` — `*`, `**`,
39
- * `?`) into an anchored `RegExp` over a `/`-separated relative path.
40
- * `**` spans path separators; a single `*` does not.
41
- */
42
- function globToRegExp(glob) {
43
- let source = "";
44
- for (let index = 0; index < glob.length; index++) {
45
- const char = glob[index];
46
- if (char === "*") {
47
- if (glob[index + 1] === "*") {
48
- source += ".*";
49
- index++;
50
- if (glob[index + 1] === "/") index++;
51
- } else source += "[^/]*";
52
- continue;
53
- }
54
- if (char === "?") {
55
- source += "[^/]";
56
- continue;
57
- }
58
- source += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
59
- }
60
- return new RegExp(`^${source}$`);
61
- }
62
- /**
63
- * Whether a workspace-relative (`/`-separated) path matches any of the
64
- * policy's `denyPaths` globs. A deny glob also blocks everything beneath
65
- * a matched directory (`".git/**"` blocks `.git/config`).
66
- */
67
- function matchesDeny(relativePath, denyPaths) {
68
- return denyPaths.some((glob) => {
69
- if (globToRegExp(glob).test(relativePath)) return true;
70
- if (!glob.includes("*") && !glob.includes("?")) {
71
- const prefix = glob.endsWith("/") ? glob : `${glob}/`;
72
- return relativePath.startsWith(prefix);
73
- }
74
- return false;
75
- });
76
- }
77
- /**
78
30
  * Resolve and jail a single input path against a {@link WorkspacePolicy}.
79
31
  *
80
32
  * The input is resolved against `policy.cwd`, canonicalized (symlinks in
@@ -84,6 +36,17 @@ function matchesDeny(relativePath, denyPaths) {
84
36
  * `denyPaths` glob even while inside `cwd`, is rejected with a
85
37
  * {@link WorkspacePolicyError} of type `"path-escape"`.
86
38
  *
39
+ * Why this does not use `@warlock.js/fs`'s confined reader (`fs.confined`):
40
+ * the two answer different questions. The confined reader refuses absolute
41
+ * paths, refuses every symbolic link (or follows only links inside its one
42
+ * root), requires the path to exist, and reads the file itself through a
43
+ * no-follow, identity-checked open. This jail must instead accept absolute
44
+ * paths inside `cwd`, several `allowPaths` roots, `denyPaths` globs, a
45
+ * symlinked `cwd`, links whose target stays inside, and paths that do not
46
+ * exist yet (`writeFile`, `mkdir`), and it returns a path that writes,
47
+ * `exec`'s working directory and redirections use afterwards. Switching would
48
+ * change what agents may touch, so the workspace keeps this resolver.
49
+ *
87
50
  * @param policy - The bounding policy (its `cwd` is the jail root).
88
51
  * @param inputPath - A workspace-relative or absolute path to resolve.
89
52
  * @returns The canonical absolute path plus its `/`-separated relative form.
@@ -110,7 +73,7 @@ async function resolveInJail(policy, inputPath) {
110
73
  });
111
74
  const relativeToJail = insideCwd ? path.relative(jailRoot, absolutePath).split(path.sep).join("/") : "";
112
75
  if (insideCwd && policy.denyPaths && policy.denyPaths.length > 0) {
113
- if (matchesDeny(relativeToJail, policy.denyPaths)) throw new WorkspacePolicyError(`Path "${inputPath}" is blocked by the workspace deny list.`, {
76
+ if (matchesAnyGlob(relativeToJail, policy.denyPaths)) throw new WorkspacePolicyError(`Path "${inputPath}" is blocked by the workspace deny list.`, {
114
77
  type: "path-escape",
115
78
  path: inputPath
116
79
  });
@@ -121,32 +84,22 @@ async function resolveInJail(policy, inputPath) {
121
84
  };
122
85
  }
123
86
  /**
124
- * Reduce an argv's first element to the basename the allow/deny policy is
125
- * keyed on. `"npm"` → `"npm"`; `"/usr/bin/node"` → `"node"`; `"node.exe"`
126
- * → `"node"` (the `.exe`/`.cmd`/`.bat`/`.com` Windows extension is
127
- * stripped).
128
- */
129
- function executableBasename(firstToken) {
130
- return path.basename(firstToken).replace(/\.(exe|cmd|bat|com)$/i, "");
131
- }
132
- /**
133
- * Whether a shell command is permitted by the policy's `shell` sub-policy.
87
+ * Whether a command line is permitted by the policy's `shell` sub-policy —
88
+ * `checkShellCommand(policy, command).allowed`.
134
89
  *
135
- * The command is first tokenized via {@link tokenizeCommand} — a command
136
- * that cannot be represented as a single argv (unbalanced quotes, or
137
- * unquoted shell metacharacters such as `;`, `&&`, `|`, backticks, `$`,
138
- * redirection) is denied outright. The backend spawns the argv directly
139
- * with no shell, so such a command has no meaning here — and unquoted
140
- * metacharacters were exactly how an injected command chain used to ride
141
- * past the allowlist. The resolved `argv[0]` basename is then matched
142
- * against `shell.deny` then `shell.allow`. **Deny always wins.** When
143
- * `shell.allow` is set, the executable MUST appear in it (fail-closed
144
- * allowlist); when `allow` is absent/empty, any non-denied command is
145
- * permitted. An absent `shell` block means no command may run at all.
90
+ * In argv mode (the default) the command must tokenize into a single argv
91
+ * (no unquoted `;`, `&&`, `|`, backticks, `$`, redirection, …): the backend
92
+ * spawns it with no shell, and unquoted metacharacters were exactly how an
93
+ * injected command chain used to ride past the allowlist. In shell mode
94
+ * (`interpreter: "sh"`) every simple command in the line is checked. Each
95
+ * argv's executable basename is matched against `shell.deny` (wins, case
96
+ * insensitive) then `shell.allow` (exhaustive when non-empty), then
97
+ * `shell.denyPatterns` and `shell.allowCommand`. An absent `shell` block
98
+ * means no command may run at all.
146
99
  *
147
- * Returns a plain `boolean` rather than throwing — the ops layer raises
148
- * the {@link WorkspacePolicyError} so the thrown context (`command`)
149
- * lives next to the call site.
100
+ * Returns a plain `boolean`; use {@link checkShellCommand} for the reason
101
+ * and the model-facing message. Redirection targets are checked lexically
102
+ * here; `exec` re-checks them with symlinks resolved.
150
103
  *
151
104
  * @example
152
105
  * isCommandAllowed({ cwd, shell: { allow: ["npm"] } }, "npm test"); // true
@@ -154,15 +107,7 @@ function executableBasename(firstToken) {
154
107
  * isCommandAllowed({ cwd, shell: { allow: ["npm"] } }, "npm test; rm -rf /"); // false
155
108
  */
156
109
  function isCommandAllowed(policy, command) {
157
- const shell = policy.shell;
158
- if (!shell) return false;
159
- const argv = tokenizeCommand(command);
160
- if (argv === null) return false;
161
- const executable = executableBasename(argv[0]);
162
- if (executable === "") return false;
163
- if (shell.deny && shell.deny.includes(executable)) return false;
164
- if (shell.allow && shell.allow.length > 0) return shell.allow.includes(executable);
165
- return true;
110
+ return checkShellCommand(policy, command).allowed;
166
111
  }
167
112
  /**
168
113
  * Build the exact environment a spawned process receives — `process.env`
@@ -187,7 +132,38 @@ function buildEnv(policy) {
187
132
  if (shell.env) for (const [key, value] of Object.entries(shell.env)) env[key] = value;
188
133
  return env;
189
134
  }
135
+ /**
136
+ * Variables removed from the environment of a shell-mode (`interpreter:
137
+ * "sh"`) command: each changes how the shell itself starts, parses or
138
+ * resolves the line (`ENV`/`BASH_ENV` run a startup file, `SHELLOPTS` +
139
+ * `PS4` can run command substitutions, `BASH_FUNC_*` imports functions that
140
+ * shadow commands, `CDPATH` moves `cd`, `IFS` re-splits words).
141
+ */
142
+ const SHELL_STARTUP_VARIABLES = new Set([
143
+ "ENV",
144
+ "BASH_ENV",
145
+ "SHELLOPTS",
146
+ "BASHOPTS",
147
+ "CDPATH",
148
+ "IFS",
149
+ "PS4",
150
+ "GLOBIGNORE"
151
+ ]);
152
+ /**
153
+ * The environment a command actually receives: {@link buildEnv}, minus the
154
+ * shell start-up variables when the policy runs lines through a shell.
155
+ * Compared case-insensitively, because Windows environment names are.
156
+ */
157
+ function buildCommandEnv(policy) {
158
+ const env = buildEnv(policy);
159
+ if ((policy.shell?.interpreter ?? "none") !== "sh") return env;
160
+ for (const key of Object.keys(env)) {
161
+ const upper = key.toUpperCase();
162
+ if (SHELL_STARTUP_VARIABLES.has(upper) || upper.startsWith("BASH_FUNC_")) delete env[key];
163
+ }
164
+ return env;
165
+ }
190
166
 
191
167
  //#endregion
192
- export { buildEnv, isCommandAllowed, resolveInJail };
168
+ export { buildCommandEnv, buildEnv, isCommandAllowed, resolveInJail };
193
169
  //# sourceMappingURL=policy.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"policy.mjs","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/policy/policy.ts"],"sourcesContent":["import path from \"node:path\";\nimport { realpath } from \"node:fs/promises\";\nimport { WorkspacePolicyError } from \"../errors\";\nimport { tokenizeCommand } from \"./tokenize-command\";\nimport type { WorkspacePolicy } from \"../contracts\";\n\n/**\n * The outcome of resolving a workspace-relative (or absolute) input path\n * against the jail — the canonical absolute location the backend should\n * touch, plus the workspace-relative form (POSIX-style, `/`-separated)\n * the agent and tool results echo back.\n */\nexport interface ResolvedPath {\n /** Canonical absolute path (symlinks in existing ancestors collapsed). */\n absolutePath: string;\n /**\n * The path relative to `policy.cwd`, `/`-separated regardless of OS,\n * used in tool results so the agent always sees stable workspace paths.\n * Empty string when the resolved path IS the jail root.\n */\n relativePath: string;\n}\n\n/**\n * Resolve the canonical absolute form of `target`, collapsing any\n * symlinks. `target` may not yet exist (a fresh `writeFile`/`mkdir`),\n * so we realpath the deepest **existing** ancestor and re-attach the\n * non-existent tail — a symlinked ancestor still cannot smuggle the\n * path out of the jail, while genuinely new leaves stay creatable.\n */\nasync function canonicalize(target: string): Promise<string> {\n let resolvedTarget = path.resolve(target);\n const tail: string[] = [];\n\n // Walk up until an existing ancestor is found (or we hit the root).\n // eslint-disable-next-line no-constant-condition\n while (true) {\n try {\n const real = await realpath(resolvedTarget);\n\n return tail.length > 0 ? path.join(real, ...tail) : real;\n } catch (error) {\n const code = (error as NodeJS.ErrnoException).code;\n\n // Only ENOENT means \"this segment doesn't exist yet\" — keep\n // walking up. Any other error (EACCES, ELOOP, …) is a real fault.\n if (code !== \"ENOENT\") {\n throw error;\n }\n\n const parent = path.dirname(resolvedTarget);\n\n // Reached the filesystem root without finding an existing\n // ancestor — give back the lexically-resolved path unchanged.\n if (parent === resolvedTarget) {\n return path.join(resolvedTarget, ...tail);\n }\n\n tail.unshift(path.basename(resolvedTarget));\n resolvedTarget = parent;\n }\n }\n}\n\n/**\n * Whether `child` is contained within `root` (or equals it), comparing\n * canonical absolute paths. Guards against the `/srv/app-evil` vs\n * `/srv/app` prefix-collision by anchoring on a path separator.\n */\nfunction isInside(child: string, root: string): boolean {\n const relative = path.relative(root, child);\n\n return (\n relative === \"\" ||\n (!relative.startsWith(\"..\") && !path.isAbsolute(relative))\n );\n}\n\n/**\n * Translate a glob (the small subset used by `denyPaths` — `*`, `**`,\n * `?`) into an anchored `RegExp` over a `/`-separated relative path.\n * `**` spans path separators; a single `*` does not.\n */\nfunction globToRegExp(glob: string): RegExp {\n let source = \"\";\n\n for (let index = 0; index < glob.length; index++) {\n const char = glob[index];\n\n if (char === \"*\") {\n if (glob[index + 1] === \"*\") {\n // `**` — match across segments (and an optional trailing slash).\n source += \".*\";\n index++;\n\n if (glob[index + 1] === \"/\") {\n index++;\n }\n } else {\n // `*` — match within a single segment.\n source += \"[^/]*\";\n }\n\n continue;\n }\n\n if (char === \"?\") {\n source += \"[^/]\";\n\n continue;\n }\n\n // Escape everything else so it matches literally.\n source += char.replace(/[.+^${}()|[\\]\\\\]/g, \"\\\\$&\");\n }\n\n return new RegExp(`^${source}$`);\n}\n\n/**\n * Whether a workspace-relative (`/`-separated) path matches any of the\n * policy's `denyPaths` globs. A deny glob also blocks everything beneath\n * a matched directory (`\".git/**\"` blocks `.git/config`).\n */\nfunction matchesDeny(relativePath: string, denyPaths: string[]): boolean {\n return denyPaths.some((glob) => {\n if (globToRegExp(glob).test(relativePath)) {\n return true;\n }\n\n // A bare directory glob (`\".git\"`, `\"node_modules\"`) should also\n // block its contents, mirroring how `\".git/**\"` would behave.\n if (!glob.includes(\"*\") && !glob.includes(\"?\")) {\n const prefix = glob.endsWith(\"/\") ? glob : `${glob}/`;\n\n return relativePath.startsWith(prefix);\n }\n\n return false;\n });\n}\n\n/**\n * Resolve and jail a single input path against a {@link WorkspacePolicy}.\n *\n * The input is resolved against `policy.cwd`, canonicalized (symlinks in\n * existing ancestors collapsed so a symlinked directory cannot escape\n * the jail), then accepted **only** when it sits under `cwd` or one of\n * the `allowPaths` roots. A path that escapes, or that matches any\n * `denyPaths` glob even while inside `cwd`, is rejected with a\n * {@link WorkspacePolicyError} of type `\"path-escape\"`.\n *\n * @param policy - The bounding policy (its `cwd` is the jail root).\n * @param inputPath - A workspace-relative or absolute path to resolve.\n * @returns The canonical absolute path plus its `/`-separated relative form.\n * @throws {WorkspacePolicyError} When the path escapes the jail or hits a deny glob.\n *\n * @example\n * const { absolutePath } = await resolveInJail(policy, \"src/index.ts\");\n */\nexport async function resolveInJail(\n policy: WorkspacePolicy,\n inputPath: string,\n): Promise<ResolvedPath> {\n const jailRoot = await canonicalize(policy.cwd);\n const requested = path.isAbsolute(inputPath)\n ? inputPath\n : path.join(policy.cwd, inputPath);\n const absolutePath = await canonicalize(requested);\n\n const insideCwd = isInside(absolutePath, jailRoot);\n const allowRoots = policy.allowPaths ?? [];\n let insideAllow = false;\n\n if (!insideCwd) {\n for (const root of allowRoots) {\n const canonicalRoot = await canonicalize(root);\n\n if (isInside(absolutePath, canonicalRoot)) {\n insideAllow = true;\n\n break;\n }\n }\n }\n\n if (!insideCwd && !insideAllow) {\n throw new WorkspacePolicyError(\n `Path \"${inputPath}\" resolves outside the workspace jail.`,\n { type: \"path-escape\", path: inputPath },\n );\n }\n\n // `denyPaths` is evaluated relative to the jail root and wins even\n // when the path is comfortably inside `cwd`.\n const relativeToJail = insideCwd\n ? path.relative(jailRoot, absolutePath).split(path.sep).join(\"/\")\n : \"\";\n\n if (insideCwd && policy.denyPaths && policy.denyPaths.length > 0) {\n if (matchesDeny(relativeToJail, policy.denyPaths)) {\n throw new WorkspacePolicyError(\n `Path \"${inputPath}\" is blocked by the workspace deny list.`,\n { type: \"path-escape\", path: inputPath },\n );\n }\n }\n\n return { absolutePath, relativePath: relativeToJail };\n}\n\n/**\n * Reduce an argv's first element to the basename the allow/deny policy is\n * keyed on. `\"npm\"` → `\"npm\"`; `\"/usr/bin/node\"` → `\"node\"`; `\"node.exe\"`\n * → `\"node\"` (the `.exe`/`.cmd`/`.bat`/`.com` Windows extension is\n * stripped).\n */\nfunction executableBasename(firstToken: string): string {\n const base = path.basename(firstToken);\n\n return base.replace(/\\.(exe|cmd|bat|com)$/i, \"\");\n}\n\n/**\n * Whether a shell command is permitted by the policy's `shell` sub-policy.\n *\n * The command is first tokenized via {@link tokenizeCommand} — a command\n * that cannot be represented as a single argv (unbalanced quotes, or\n * unquoted shell metacharacters such as `;`, `&&`, `|`, backticks, `$`,\n * redirection) is denied outright. The backend spawns the argv directly\n * with no shell, so such a command has no meaning here — and unquoted\n * metacharacters were exactly how an injected command chain used to ride\n * past the allowlist. The resolved `argv[0]` basename is then matched\n * against `shell.deny` then `shell.allow`. **Deny always wins.** When\n * `shell.allow` is set, the executable MUST appear in it (fail-closed\n * allowlist); when `allow` is absent/empty, any non-denied command is\n * permitted. An absent `shell` block means no command may run at all.\n *\n * Returns a plain `boolean` rather than throwing — the ops layer raises\n * the {@link WorkspacePolicyError} so the thrown context (`command`)\n * lives next to the call site.\n *\n * @example\n * isCommandAllowed({ cwd, shell: { allow: [\"npm\"] } }, \"npm test\"); // true\n * isCommandAllowed({ cwd, shell: { allow: [\"npm\"] } }, \"rm -rf /\"); // false\n * isCommandAllowed({ cwd, shell: { allow: [\"npm\"] } }, \"npm test; rm -rf /\"); // false\n */\nexport function isCommandAllowed(\n policy: WorkspacePolicy,\n command: string,\n): boolean {\n const shell = policy.shell;\n\n // No shell sub-policy ⇒ fail-closed: nothing may run.\n if (!shell) {\n return false;\n }\n\n const argv = tokenizeCommand(command);\n\n if (argv === null) {\n return false;\n }\n\n const executable = executableBasename(argv[0]);\n\n if (executable === \"\") {\n return false;\n }\n\n // Deny wins over everything else.\n if (shell.deny && shell.deny.includes(executable)) {\n return false;\n }\n\n // An allowlist, when present, is exhaustive.\n if (shell.allow && shell.allow.length > 0) {\n return shell.allow.includes(executable);\n }\n\n // No allowlist: anything not explicitly denied is permitted.\n return true;\n}\n\n/**\n * Build the exact environment a spawned process receives — `process.env`\n * is **never** inherited wholesale. The result is\n * `{ ...pick(process.env, inheritEnv), ...shell.env }`, so a command\n * cannot see `PATH` (and thus often cannot find `node`/`npm`) unless the\n * policy opts in via `shell.inheritEnv: [\"PATH\"]`. Explicit `shell.env`\n * values override inherited ones on key collision.\n *\n * @example\n * buildEnv({ cwd, shell: { inheritEnv: [\"PATH\"], env: { CI: \"1\" } } });\n * // → { PATH: <process PATH>, CI: \"1\" }\n */\nexport function buildEnv(policy: WorkspacePolicy): Record<string, string> {\n const shell = policy.shell;\n const env: Record<string, string> = {};\n\n if (!shell) {\n return env;\n }\n\n for (const key of shell.inheritEnv ?? []) {\n const value = process.env[key];\n\n if (value !== undefined) {\n env[key] = value;\n }\n }\n\n if (shell.env) {\n for (const [key, value] of Object.entries(shell.env)) {\n env[key] = value;\n }\n }\n\n return env;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"policy.mjs","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/policy/policy.ts"],"sourcesContent":["import path from \"node:path\";\nimport { realpath } from \"node:fs/promises\";\nimport { WorkspacePolicyError } from \"../errors\";\nimport type { WorkspacePolicy } from \"../contracts\";\nimport { isInside, matchesAnyGlob } from \"./jail\";\nimport { checkShellCommand } from \"./shell-check\";\n\n/**\n * The outcome of resolving a workspace-relative (or absolute) input path\n * against the jail — the canonical absolute location the backend should\n * touch, plus the workspace-relative form (POSIX-style, `/`-separated)\n * the agent and tool results echo back.\n */\nexport interface ResolvedPath {\n /** Canonical absolute path (symlinks in existing ancestors collapsed). */\n absolutePath: string;\n /**\n * The path relative to `policy.cwd`, `/`-separated regardless of OS,\n * used in tool results so the agent always sees stable workspace paths.\n * Empty string when the resolved path IS the jail root.\n */\n relativePath: string;\n}\n\n/**\n * Resolve the canonical absolute form of `target`, collapsing any\n * symlinks. `target` may not yet exist (a fresh `writeFile`/`mkdir`),\n * so we realpath the deepest **existing** ancestor and re-attach the\n * non-existent tail — a symlinked ancestor still cannot smuggle the\n * path out of the jail, while genuinely new leaves stay creatable.\n */\nasync function canonicalize(target: string): Promise<string> {\n let resolvedTarget = path.resolve(target);\n const tail: string[] = [];\n\n // Walk up until an existing ancestor is found (or we hit the root).\n // eslint-disable-next-line no-constant-condition\n while (true) {\n try {\n const real = await realpath(resolvedTarget);\n\n return tail.length > 0 ? path.join(real, ...tail) : real;\n } catch (error) {\n const code = (error as NodeJS.ErrnoException).code;\n\n // Only ENOENT means \"this segment doesn't exist yet\" — keep\n // walking up. Any other error (EACCES, ELOOP, …) is a real fault.\n if (code !== \"ENOENT\") {\n throw error;\n }\n\n const parent = path.dirname(resolvedTarget);\n\n // Reached the filesystem root without finding an existing\n // ancestor — give back the lexically-resolved path unchanged.\n if (parent === resolvedTarget) {\n return path.join(resolvedTarget, ...tail);\n }\n\n tail.unshift(path.basename(resolvedTarget));\n resolvedTarget = parent;\n }\n }\n}\n\n/**\n * Resolve and jail a single input path against a {@link WorkspacePolicy}.\n *\n * The input is resolved against `policy.cwd`, canonicalized (symlinks in\n * existing ancestors collapsed so a symlinked directory cannot escape\n * the jail), then accepted **only** when it sits under `cwd` or one of\n * the `allowPaths` roots. A path that escapes, or that matches any\n * `denyPaths` glob even while inside `cwd`, is rejected with a\n * {@link WorkspacePolicyError} of type `\"path-escape\"`.\n *\n * Why this does not use `@warlock.js/fs`'s confined reader (`fs.confined`):\n * the two answer different questions. The confined reader refuses absolute\n * paths, refuses every symbolic link (or follows only links inside its one\n * root), requires the path to exist, and reads the file itself through a\n * no-follow, identity-checked open. This jail must instead accept absolute\n * paths inside `cwd`, several `allowPaths` roots, `denyPaths` globs, a\n * symlinked `cwd`, links whose target stays inside, and paths that do not\n * exist yet (`writeFile`, `mkdir`), and it returns a path that writes,\n * `exec`'s working directory and redirections use afterwards. Switching would\n * change what agents may touch, so the workspace keeps this resolver.\n *\n * @param policy - The bounding policy (its `cwd` is the jail root).\n * @param inputPath - A workspace-relative or absolute path to resolve.\n * @returns The canonical absolute path plus its `/`-separated relative form.\n * @throws {WorkspacePolicyError} When the path escapes the jail or hits a deny glob.\n *\n * @example\n * const { absolutePath } = await resolveInJail(policy, \"src/index.ts\");\n */\nexport async function resolveInJail(\n policy: WorkspacePolicy,\n inputPath: string,\n): Promise<ResolvedPath> {\n const jailRoot = await canonicalize(policy.cwd);\n const requested = path.isAbsolute(inputPath) ? inputPath : path.join(policy.cwd, inputPath);\n const absolutePath = await canonicalize(requested);\n\n const insideCwd = isInside(absolutePath, jailRoot);\n const allowRoots = policy.allowPaths ?? [];\n let insideAllow = false;\n\n if (!insideCwd) {\n for (const root of allowRoots) {\n const canonicalRoot = await canonicalize(root);\n\n if (isInside(absolutePath, canonicalRoot)) {\n insideAllow = true;\n\n break;\n }\n }\n }\n\n if (!insideCwd && !insideAllow) {\n throw new WorkspacePolicyError(`Path \"${inputPath}\" resolves outside the workspace jail.`, {\n type: \"path-escape\",\n path: inputPath,\n });\n }\n\n // `denyPaths` is evaluated relative to the jail root and wins even\n // when the path is comfortably inside `cwd`.\n const relativeToJail = insideCwd\n ? path.relative(jailRoot, absolutePath).split(path.sep).join(\"/\")\n : \"\";\n\n if (insideCwd && policy.denyPaths && policy.denyPaths.length > 0) {\n if (matchesAnyGlob(relativeToJail, policy.denyPaths)) {\n throw new WorkspacePolicyError(`Path \"${inputPath}\" is blocked by the workspace deny list.`, {\n type: \"path-escape\",\n path: inputPath,\n });\n }\n }\n\n return { absolutePath, relativePath: relativeToJail };\n}\n\n/**\n * Whether a command line is permitted by the policy's `shell` sub-policy —\n * `checkShellCommand(policy, command).allowed`.\n *\n * In argv mode (the default) the command must tokenize into a single argv\n * (no unquoted `;`, `&&`, `|`, backticks, `$`, redirection, …): the backend\n * spawns it with no shell, and unquoted metacharacters were exactly how an\n * injected command chain used to ride past the allowlist. In shell mode\n * (`interpreter: \"sh\"`) every simple command in the line is checked. Each\n * argv's executable basename is matched against `shell.deny` (wins, case\n * insensitive) then `shell.allow` (exhaustive when non-empty), then\n * `shell.denyPatterns` and `shell.allowCommand`. An absent `shell` block\n * means no command may run at all.\n *\n * Returns a plain `boolean`; use {@link checkShellCommand} for the reason\n * and the model-facing message. Redirection targets are checked lexically\n * here; `exec` re-checks them with symlinks resolved.\n *\n * @example\n * isCommandAllowed({ cwd, shell: { allow: [\"npm\"] } }, \"npm test\"); // true\n * isCommandAllowed({ cwd, shell: { allow: [\"npm\"] } }, \"rm -rf /\"); // false\n * isCommandAllowed({ cwd, shell: { allow: [\"npm\"] } }, \"npm test; rm -rf /\"); // false\n */\nexport function isCommandAllowed(policy: WorkspacePolicy, command: string): boolean {\n return checkShellCommand(policy, command).allowed;\n}\n\n/**\n * Build the exact environment a spawned process receives — `process.env`\n * is **never** inherited wholesale. The result is\n * `{ ...pick(process.env, inheritEnv), ...shell.env }`, so a command\n * cannot see `PATH` (and thus often cannot find `node`/`npm`) unless the\n * policy opts in via `shell.inheritEnv: [\"PATH\"]`. Explicit `shell.env`\n * values override inherited ones on key collision.\n *\n * @example\n * buildEnv({ cwd, shell: { inheritEnv: [\"PATH\"], env: { CI: \"1\" } } });\n * // → { PATH: <process PATH>, CI: \"1\" }\n */\nexport function buildEnv(policy: WorkspacePolicy): Record<string, string> {\n const shell = policy.shell;\n const env: Record<string, string> = {};\n\n if (!shell) {\n return env;\n }\n\n for (const key of shell.inheritEnv ?? []) {\n const value = process.env[key];\n\n if (value !== undefined) {\n env[key] = value;\n }\n }\n\n if (shell.env) {\n for (const [key, value] of Object.entries(shell.env)) {\n env[key] = value;\n }\n }\n\n return env;\n}\n\n/**\n * Variables removed from the environment of a shell-mode (`interpreter:\n * \"sh\"`) command: each changes how the shell itself starts, parses or\n * resolves the line (`ENV`/`BASH_ENV` run a startup file, `SHELLOPTS` +\n * `PS4` can run command substitutions, `BASH_FUNC_*` imports functions that\n * shadow commands, `CDPATH` moves `cd`, `IFS` re-splits words).\n */\nconst SHELL_STARTUP_VARIABLES = new Set([\n \"ENV\",\n \"BASH_ENV\",\n \"SHELLOPTS\",\n \"BASHOPTS\",\n \"CDPATH\",\n \"IFS\",\n \"PS4\",\n \"GLOBIGNORE\",\n]);\n\n/**\n * The environment a command actually receives: {@link buildEnv}, minus the\n * shell start-up variables when the policy runs lines through a shell.\n * Compared case-insensitively, because Windows environment names are.\n */\nexport function buildCommandEnv(policy: WorkspacePolicy): Record<string, string> {\n const env = buildEnv(policy);\n\n if ((policy.shell?.interpreter ?? \"none\") !== \"sh\") {\n return env;\n }\n\n for (const key of Object.keys(env)) {\n const upper = key.toUpperCase();\n\n if (SHELL_STARTUP_VARIABLES.has(upper) || upper.startsWith(\"BASH_FUNC_\")) {\n delete env[key];\n }\n }\n\n return env;\n}\n"],"mappings":";;;;;;;;;;;;;;AA+BA,eAAe,aAAa,QAAiC;CAC3D,IAAI,iBAAiB,KAAK,QAAQ,MAAM;CACxC,MAAM,OAAiB,CAAC;CAIxB,OAAO,MACL,IAAI;EACF,MAAM,OAAO,MAAM,SAAS,cAAc;EAE1C,OAAO,KAAK,SAAS,IAAI,KAAK,KAAK,MAAM,GAAG,IAAI,IAAI;CACtD,SAAS,OAAO;EAKd,IAJc,MAAgC,SAIjC,UACX,MAAM;EAGR,MAAM,SAAS,KAAK,QAAQ,cAAc;EAI1C,IAAI,WAAW,gBACb,OAAO,KAAK,KAAK,gBAAgB,GAAG,IAAI;EAG1C,KAAK,QAAQ,KAAK,SAAS,cAAc,CAAC;EAC1C,iBAAiB;CACnB;AAEJ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+BA,eAAsB,cACpB,QACA,WACuB;CACvB,MAAM,WAAW,MAAM,aAAa,OAAO,GAAG;CAE9C,MAAM,eAAe,MAAM,aADT,KAAK,WAAW,SAAS,IAAI,YAAY,KAAK,KAAK,OAAO,KAAK,SAAS,CACzC;CAEjD,MAAM,YAAY,SAAS,cAAc,QAAQ;CACjD,MAAM,aAAa,OAAO,cAAc,CAAC;CACzC,IAAI,cAAc;CAElB,IAAI,CAAC,WACH;OAAK,MAAM,QAAQ,YAGjB,IAAI,SAAS,cAAc,MAFC,aAAa,IAAI,CAEL,GAAG;GACzC,cAAc;GAEd;EACF;CACF;CAGF,IAAI,CAAC,aAAa,CAAC,aACjB,MAAM,IAAI,qBAAqB,SAAS,UAAU,yCAAyC;EACzF,MAAM;EACN,MAAM;CACR,CAAC;CAKH,MAAM,iBAAiB,YACnB,KAAK,SAAS,UAAU,YAAY,CAAC,CAAC,MAAM,KAAK,GAAG,CAAC,CAAC,KAAK,GAAG,IAC9D;CAEJ,IAAI,aAAa,OAAO,aAAa,OAAO,UAAU,SAAS,GAC7D;MAAI,eAAe,gBAAgB,OAAO,SAAS,GACjD,MAAM,IAAI,qBAAqB,SAAS,UAAU,2CAA2C;GAC3F,MAAM;GACN,MAAM;EACR,CAAC;CACH;CAGF,OAAO;EAAE;EAAc,cAAc;CAAe;AACtD;;;;;;;;;;;;;;;;;;;;;;;;AAyBA,SAAgB,iBAAiB,QAAyB,SAA0B;CAClF,OAAO,kBAAkB,QAAQ,OAAO,CAAC,CAAC;AAC5C;;;;;;;;;;;;;AAcA,SAAgB,SAAS,QAAiD;CACxE,MAAM,QAAQ,OAAO;CACrB,MAAM,MAA8B,CAAC;CAErC,IAAI,CAAC,OACH,OAAO;CAGT,KAAK,MAAM,OAAO,MAAM,cAAc,CAAC,GAAG;EACxC,MAAM,QAAQ,QAAQ,IAAI;EAE1B,IAAI,UAAU,QACZ,IAAI,OAAO;CAEf;CAEA,IAAI,MAAM,KACR,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,MAAM,GAAG,GACjD,IAAI,OAAO;CAIf,OAAO;AACT;;;;;;;;AASA,MAAM,0BAA0B,IAAI,IAAI;CACtC;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACF,CAAC;;;;;;AAOD,SAAgB,gBAAgB,QAAiD;CAC/E,MAAM,MAAM,SAAS,MAAM;CAE3B,KAAK,OAAO,OAAO,eAAe,YAAY,MAC5C,OAAO;CAGT,KAAK,MAAM,OAAO,OAAO,KAAK,GAAG,GAAG;EAClC,MAAM,QAAQ,IAAI,YAAY;EAE9B,IAAI,wBAAwB,IAAI,KAAK,KAAK,MAAM,WAAW,YAAY,GACrE,OAAO,IAAI;CAEf;CAEA,OAAO;AACT"}
@@ -0,0 +1,64 @@
1
+ import { WorkspacePolicy } from "../contracts/workspace-policy.type.mjs";
2
+ import { WorkspaceShellRefusalReason } from "../errors.mjs";
3
+
4
+ //#region ../../packages/ai-workspace/src/policy/shell-check.d.ts
5
+ /** How a shell-mode line touches a path the jail has to vet. */
6
+ type WorkspaceShellPathAccess = "read" | "write" | "cwd";
7
+ /** A path a shell-mode line redirects to/from or `cd`s into. */
8
+ interface WorkspaceShellPath {
9
+ /** Absolute path, resolved lexically against the workspace root (or an earlier `cd`). */
10
+ path: string;
11
+ /** What the line does with it. */
12
+ access: WorkspaceShellPathAccess;
13
+ /** The target as written in the line. */
14
+ target: string;
15
+ }
16
+ /**
17
+ * The shell policy's verdict on one command line — see
18
+ * {@link checkShellCommand}.
19
+ */
20
+ interface WorkspaceShellCheck {
21
+ /** Whether the line may run (path targets still get a symlink-aware recheck in `exec`). */
22
+ allowed: boolean;
23
+ /** Why it was refused. */
24
+ reason?: WorkspaceShellRefusalReason;
25
+ /** Model-facing refusal text: the cause, and how to fix the command. */
26
+ message?: string;
27
+ /** The executable the refusal is about, when there is one. */
28
+ executable?: string;
29
+ /** The redirection target or `cd` directory a `"path-escape"` refusal is about. */
30
+ path?: string;
31
+ /** The argv of every simple command checked, in order (wrapped commands included). */
32
+ commands: string[][];
33
+ /** Shell mode: every redirection target and `cd` directory, for the jail recheck. */
34
+ paths: WorkspaceShellPath[];
35
+ }
36
+ /**
37
+ * The shell policy's verdict on a command line, without touching the
38
+ * filesystem.
39
+ *
40
+ * - **argv mode** (`interpreter: "none"`, the default): the line must
41
+ * tokenize into one argv; otherwise it is refused with a
42
+ * `"metacharacter"` / `"unbalanced-quotes"` reason and a message that
43
+ * names the operator and suggests the split.
44
+ * - **shell mode** (`interpreter: "sh"`): the line is parsed and every
45
+ * simple command — inside `;`, `&&`, `||`, pipes, subshells, `$( )`,
46
+ * backticks and here-documents — is checked; redirection targets and `cd`
47
+ * directories must stay in the jail (checked lexically here; `exec`
48
+ * re-checks them with symlinks resolved).
49
+ *
50
+ * Every argv goes through `deny` (case-insensitive, wins), `allow`
51
+ * (exhaustive when non-empty), `denyPatterns` and `allowCommand`.
52
+ * Wrappers (`env`, `time`, `timeout`, `nice`, `nohup`, `command`,
53
+ * `builtin`, `exec`) and `find -exec` are followed to the command they run.
54
+ * A runner the policy cannot see into (`bash -c`, `eval`, `xargs`, `sudo`,
55
+ * …) is refused while the policy has deny rules, unless `allow` names it.
56
+ *
57
+ * @example
58
+ * checkShellCommand({ cwd, shell: { allow: ["git"] } }, "git add -A && git commit -m wip");
59
+ * // { allowed: false, reason: "metacharacter", message: "`&&` is not supported in argv mode: run `git add -A`, then `git commit -m wip`, …" }
60
+ */
61
+ declare function checkShellCommand(policy: WorkspacePolicy, command: string): WorkspaceShellCheck;
62
+ //#endregion
63
+ export { WorkspaceShellCheck, WorkspaceShellPath, WorkspaceShellPathAccess, checkShellCommand };
64
+ //# sourceMappingURL=shell-check.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shell-check.d.mts","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/policy/shell-check.ts"],"mappings":";;;;;KAmBY,wBAAA;AAAZ;AAAA,UAGiB,kBAAA;;EAEf,IAAA;EALkC;EAOlC,MAAA,EAAQ,wBAAwB;EAJC;EAMjC,MAAA;AAAA;;;;;UAOe,mBAAA;EAPT;EASN,OAAA;EAFkC;EAIlC,MAAA,GAAS,2BAAA;EAUgB;EARzB,OAAA;EAFA;EAIA,UAAA;EAFA;EAIA,IAAA;EAAA;EAEA,QAAA;EAEA;EAAA,KAAA,EAAO,kBAAkB;AAAA;;AA6hCqE;;;;;;;;;;;;;;;;;;;;;;;;iBAAhF,iBAAA,CAAkB,MAAA,EAAQ,eAAA,EAAiB,OAAA,WAAkB,mBAAmB"}