headlesscode 1.0.2

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 (232) hide show
  1. package/ATTRIBUTION.md +53 -0
  2. package/CODE_OF_CONDUCT.md +130 -0
  3. package/CONTRIBUTING.md +107 -0
  4. package/LICENSE +202 -0
  5. package/README.md +486 -0
  6. package/SECURITY.md +211 -0
  7. package/bin/headlesscode.mjs +83 -0
  8. package/package.json +63 -0
  9. package/shared/prompts/review-mode-prompt-short.md +93 -0
  10. package/shared/prompts/review-mode-prompt.md +281 -0
  11. package/shared/rules-code/rules.md +22 -0
  12. package/shared/stacks/cpp/rules.md +30 -0
  13. package/shared/stacks/fastapi/rules.md +30 -0
  14. package/shared/stacks/javascript/rules.md +37 -0
  15. package/shared/stacks/postgresql/rules.md +31 -0
  16. package/shared/stacks/python/rules.md +35 -0
  17. package/shared/stacks/react/rules.md +11 -0
  18. package/shared/stacks/typescript/rules.md +10 -0
  19. package/src/budget/budget.ts +221 -0
  20. package/src/budget/concurrency.ts +126 -0
  21. package/src/budget/cost.ts +309 -0
  22. package/src/budget/index.ts +8 -0
  23. package/src/checkpoints/cli.ts +256 -0
  24. package/src/checkpoints/service.ts +227 -0
  25. package/src/cli.ts +1535 -0
  26. package/src/cloud/docker-provider.ts +334 -0
  27. package/src/cloud/provider.ts +300 -0
  28. package/src/codeintel/call-graph.ts +78 -0
  29. package/src/codeintel/find-references.ts +123 -0
  30. package/src/codeintel/go-to-definition.ts +193 -0
  31. package/src/codeintel/handlers.ts +190 -0
  32. package/src/codeintel/import-graph.ts +173 -0
  33. package/src/codeintel/outline.ts +180 -0
  34. package/src/codeintel/position.ts +77 -0
  35. package/src/codeintel/program.ts +350 -0
  36. package/src/codeintel/rename-symbol.ts +213 -0
  37. package/src/codeintel/tools.ts +280 -0
  38. package/src/codemap/build.ts +135 -0
  39. package/src/codemap/cli.ts +190 -0
  40. package/src/codemap/extract.ts +339 -0
  41. package/src/codemap/files.ts +236 -0
  42. package/src/codemap/fingerprint.ts +65 -0
  43. package/src/codemap/flows.ts +62 -0
  44. package/src/codemap/html.ts +451 -0
  45. package/src/codemap/lock.ts +80 -0
  46. package/src/codemap/types.ts +101 -0
  47. package/src/codesearch/airunner-embedder.ts +185 -0
  48. package/src/codesearch/chunk.ts +339 -0
  49. package/src/codesearch/cli.ts +223 -0
  50. package/src/codesearch/embedder.ts +332 -0
  51. package/src/codesearch/files.ts +280 -0
  52. package/src/codesearch/index.ts +469 -0
  53. package/src/codesearch/ollama-embedder.ts +205 -0
  54. package/src/codesearch/search.ts +141 -0
  55. package/src/codesearch/types.ts +100 -0
  56. package/src/config/mode-models.ts +218 -0
  57. package/src/dashboard/aggregate.ts +364 -0
  58. package/src/dashboard/chat-thread.ts +141 -0
  59. package/src/dashboard/checkpoints.ts +124 -0
  60. package/src/dashboard/cli.ts +193 -0
  61. package/src/dashboard/codemap.ts +44 -0
  62. package/src/dashboard/files.ts +121 -0
  63. package/src/dashboard/page.ts +2803 -0
  64. package/src/dashboard/self-improvement-metrics.ts +282 -0
  65. package/src/dashboard/server.ts +1103 -0
  66. package/src/dashboard/session-launch.ts +310 -0
  67. package/src/dashboard/timeline.ts +273 -0
  68. package/src/dashboard/tool-exec.ts +107 -0
  69. package/src/dashboard/trend-cli.ts +141 -0
  70. package/src/dashboard/trend.ts +413 -0
  71. package/src/decision-proxy/cli.ts +261 -0
  72. package/src/decision-proxy/proxy.ts +569 -0
  73. package/src/deploy/gate-cli.ts +147 -0
  74. package/src/deploy/gate.ts +254 -0
  75. package/src/engine/condense.ts +512 -0
  76. package/src/engine/events.ts +428 -0
  77. package/src/engine/handoff.ts +71 -0
  78. package/src/engine/lazy-tools.ts +160 -0
  79. package/src/engine/local-explore.ts +653 -0
  80. package/src/engine/logger.ts +96 -0
  81. package/src/engine/loop.ts +5517 -0
  82. package/src/engine/parser.ts +347 -0
  83. package/src/engine/prompt.ts +860 -0
  84. package/src/engine/reports.ts +47 -0
  85. package/src/engine/stacks.ts +448 -0
  86. package/src/engine/types.ts +291 -0
  87. package/src/engine/usage.ts +186 -0
  88. package/src/github/app-auth.ts +161 -0
  89. package/src/github/cli.ts +448 -0
  90. package/src/github/installations.ts +133 -0
  91. package/src/github/pr.ts +321 -0
  92. package/src/github/provision.ts +118 -0
  93. package/src/github/push.ts +122 -0
  94. package/src/index-util.ts +50 -0
  95. package/src/index.ts +81 -0
  96. package/src/init/cli.ts +248 -0
  97. package/src/init/gitignore.ts +74 -0
  98. package/src/llm/ollama.ts +308 -0
  99. package/src/llm/openrouter.ts +868 -0
  100. package/src/llm/preflight.ts +367 -0
  101. package/src/llm/transcript-capture.ts +84 -0
  102. package/src/memory/embed.ts +110 -0
  103. package/src/memory/index.ts +22 -0
  104. package/src/memory/local.ts +259 -0
  105. package/src/memory/summarizer.ts +283 -0
  106. package/src/memory/types.ts +153 -0
  107. package/src/memory/uwuchat.ts +157 -0
  108. package/src/migrate/cli.ts +115 -0
  109. package/src/orchestrator/analyze-cli.ts +104 -0
  110. package/src/orchestrator/auto-split.ts +206 -0
  111. package/src/orchestrator/cleanup.ts +1003 -0
  112. package/src/orchestrator/cli.ts +3571 -0
  113. package/src/orchestrator/cost-estimate.ts +564 -0
  114. package/src/orchestrator/cost-history-cli.ts +242 -0
  115. package/src/orchestrator/cost-history.ts +397 -0
  116. package/src/orchestrator/git-sync.ts +250 -0
  117. package/src/orchestrator/index.ts +153 -0
  118. package/src/orchestrator/log-analysis.ts +0 -0
  119. package/src/orchestrator/merge-check.ts +108 -0
  120. package/src/orchestrator/pipeline.ts +411 -0
  121. package/src/orchestrator/resume.ts +1940 -0
  122. package/src/orchestrator/reviewer.ts +503 -0
  123. package/src/orchestrator/split.ts +296 -0
  124. package/src/orchestrator/state.ts +542 -0
  125. package/src/orchestrator/status.ts +697 -0
  126. package/src/orchestrator/verification-gate.ts +134 -0
  127. package/src/orchestrator/watch.ts +898 -0
  128. package/src/permissions/commands.ts +1083 -0
  129. package/src/permissions/config.ts +241 -0
  130. package/src/permissions/index.ts +12 -0
  131. package/src/permissions/protected-files.ts +96 -0
  132. package/src/permissions/store-protection.ts +272 -0
  133. package/src/project-store.ts +648 -0
  134. package/src/projects/cli.ts +382 -0
  135. package/src/qa/qa.ts +487 -0
  136. package/src/tools/browser/handler.ts +346 -0
  137. package/src/tools/browser/service.ts +406 -0
  138. package/src/tools/browser/smoke.ts +78 -0
  139. package/src/tools/browser/tool.ts +99 -0
  140. package/src/tools/executor.ts +2575 -0
  141. package/src/tools/language-detect.ts +183 -0
  142. package/src/tools/output-summarizer.ts +369 -0
  143. package/src/tools/run-tests.ts +302 -0
  144. package/src/tools/set-indentation-tool.ts +49 -0
  145. package/src/tools/test-selection.ts +160 -0
  146. package/src/vendor/tests/smoke.ts +103 -0
  147. package/src/vendor/zoo-code/VENDOR-NOTES.md +213 -0
  148. package/src/vendor/zoo-code/shim/anthropic.ts +71 -0
  149. package/src/vendor/zoo-code/shim/openai.d.ts +60 -0
  150. package/src/vendor/zoo-code/shim/os-name.ts +18 -0
  151. package/src/vendor/zoo-code/shim/strip-bom.ts +14 -0
  152. package/src/vendor/zoo-code/shim/vscode.ts +76 -0
  153. package/src/vendor/zoo-code/src/core/config/CustomModesManager.ts +1015 -0
  154. package/src/vendor/zoo-code/src/core/diff/strategies/multi-search-replace.ts +670 -0
  155. package/src/vendor/zoo-code/src/core/prompts/sections/capabilities.ts +46 -0
  156. package/src/vendor/zoo-code/src/core/prompts/sections/custom-instructions.ts +559 -0
  157. package/src/vendor/zoo-code/src/core/prompts/sections/index.ts +10 -0
  158. package/src/vendor/zoo-code/src/core/prompts/sections/markdown-formatting.ts +7 -0
  159. package/src/vendor/zoo-code/src/core/prompts/sections/modes.ts +35 -0
  160. package/src/vendor/zoo-code/src/core/prompts/sections/objective.ts +13 -0
  161. package/src/vendor/zoo-code/src/core/prompts/sections/rules.ts +95 -0
  162. package/src/vendor/zoo-code/src/core/prompts/sections/skills.ts +105 -0
  163. package/src/vendor/zoo-code/src/core/prompts/sections/system-info.ts +30 -0
  164. package/src/vendor/zoo-code/src/core/prompts/sections/tool-use-guidelines.ts +9 -0
  165. package/src/vendor/zoo-code/src/core/prompts/sections/tool-use.ts +7 -0
  166. package/src/vendor/zoo-code/src/core/prompts/system.ts +176 -0
  167. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/access_mcp_resource.ts +41 -0
  168. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_diff.ts +40 -0
  169. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_patch.ts +61 -0
  170. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/ask_followup_question.ts +62 -0
  171. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/attempt_completion.ts +33 -0
  172. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/codebase_search.ts +43 -0
  173. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/converters.ts +109 -0
  174. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit.ts +48 -0
  175. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit_file.ts +72 -0
  176. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/execute_command.ts +54 -0
  177. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/generate_image.ts +51 -0
  178. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/index.ts +75 -0
  179. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/list_files.ts +41 -0
  180. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/mcp_server.ts +75 -0
  181. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/new_task.ts +39 -0
  182. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_command_output.ts +81 -0
  183. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_file.ts +169 -0
  184. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/run_slash_command.ts +31 -0
  185. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_files.ts +50 -0
  186. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_replace.ts +51 -0
  187. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/skill.ts +33 -0
  188. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/switch_mode.ts +31 -0
  189. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/update_todo_list.ts +54 -0
  190. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/write_to_file.ts +40 -0
  191. package/src/vendor/zoo-code/src/core/prompts/types.ts +12 -0
  192. package/src/vendor/zoo-code/src/i18n/index.ts +19 -0
  193. package/src/vendor/zoo-code/src/integrations/misc/extract-text.ts +81 -0
  194. package/src/vendor/zoo-code/src/services/checkpoints/RepoPerTaskCheckpointService.ts +15 -0
  195. package/src/vendor/zoo-code/src/services/checkpoints/ShadowCheckpointService.ts +553 -0
  196. package/src/vendor/zoo-code/src/services/checkpoints/excludes.ts +212 -0
  197. package/src/vendor/zoo-code/src/services/checkpoints/index.ts +3 -0
  198. package/src/vendor/zoo-code/src/services/checkpoints/types.ts +35 -0
  199. package/src/vendor/zoo-code/src/services/code-index/manager.ts +19 -0
  200. package/src/vendor/zoo-code/src/services/mcp/McpHub.ts +36 -0
  201. package/src/vendor/zoo-code/src/services/roo-config/index.ts +441 -0
  202. package/src/vendor/zoo-code/src/services/search/file-search.ts +143 -0
  203. package/src/vendor/zoo-code/src/services/skills/SkillsManager.ts +20 -0
  204. package/src/vendor/zoo-code/src/shared/globalFileNames.ts +9 -0
  205. package/src/vendor/zoo-code/src/shared/language.ts +43 -0
  206. package/src/vendor/zoo-code/src/shared/modes.ts +257 -0
  207. package/src/vendor/zoo-code/src/shared/tools.ts +385 -0
  208. package/src/vendor/zoo-code/src/utils/fs.ts +39 -0
  209. package/src/vendor/zoo-code/src/utils/globalContext.ts +22 -0
  210. package/src/vendor/zoo-code/src/utils/json-schema.ts +16 -0
  211. package/src/vendor/zoo-code/src/utils/logging.ts +21 -0
  212. package/src/vendor/zoo-code/src/utils/mcp-name.ts +190 -0
  213. package/src/vendor/zoo-code/src/utils/object.ts +18 -0
  214. package/src/vendor/zoo-code/src/utils/path.ts +94 -0
  215. package/src/vendor/zoo-code/src/utils/shell.ts +376 -0
  216. package/src/vendor/zoo-code/src/utils/text-normalization.ts +99 -0
  217. package/src/vendor/zoo-code/types/global-settings.ts +19 -0
  218. package/src/vendor/zoo-code/types/index.ts +22 -0
  219. package/src/vendor/zoo-code/types/message.ts +375 -0
  220. package/src/vendor/zoo-code/types/mode.ts +241 -0
  221. package/src/vendor/zoo-code/types/todo.ts +19 -0
  222. package/src/vendor/zoo-code/types/tool-params.ts +116 -0
  223. package/src/vendor/zoo-code/types/tool.ts +67 -0
  224. package/src/vendor/zoo-code/types/vscode.ts +84 -0
  225. package/src/vision/describe.ts +242 -0
  226. package/src/vision/tool.ts +91 -0
  227. package/src/watcher/cli.ts +369 -0
  228. package/src/watcher/github.ts +304 -0
  229. package/src/watcher/index.ts +59 -0
  230. package/src/watcher/state.ts +254 -0
  231. package/src/watcher/watch.ts +562 -0
  232. package/tsconfig.json +18 -0
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Command allow/deny + protected-file permissions configuration for the
3
+ * headless harness.
4
+ *
5
+ * Resolution precedence (mirrors the flag/env/file convention established for
6
+ * the per-session budget in src/budget/cost.ts and src/cli.ts):
7
+ *
8
+ * CLI flags > env vars > central permissions.json > built-in defaults
9
+ *
10
+ * - CLI flags: `--allowed-commands` / `--denied-commands` / `--protected-files`
11
+ * / `--allow-protected-writes` (see src/cli.ts parseArgs)
12
+ * - env vars: `HEADLESSCODE_ALLOWED_COMMANDS` / `HEADLESSCODE_DENIED_COMMANDS`
13
+ * / `HEADLESSCODE_PROTECTED_FILES` /
14
+ * `HEADLESSCODE_ALLOW_PROTECTED_WRITES`
15
+ * - config file: the CENTRAL project store's `permissions.json`
16
+ * (`~/.local/share/headlesscode/projects/<key>/permissions.json` —
17
+ * see src/project-store.ts; keyed by the repo's git-common-dir, so
18
+ * worktrees share one policy with zero per-directory setup),
19
+ * auto-loaded when present (no flag needed). Schema:
20
+ * `{ "allowedCommands": string[], "deniedCommands": string[],
21
+ * "protectedFiles": string[], "allowProtectedWrites": boolean }`.
22
+ * A malformed file fails loudly — mirroring how
23
+ * `HEADLESSCODE_PRICING_JSON` is treated in src/budget/cost.ts —
24
+ * never silently weakens a policy.
25
+ *
26
+ * Deliberate defaults (documented in plans/permissions-parity.md):
27
+ * - `allowedCommands` defaults to `[]`, which the command decision layer
28
+ * (src/permissions/commands.ts) treats as "allow everything except the
29
+ * deny-list" — default-ALLOW, backward compatible with the pre-permissions
30
+ * harness. `deniedCommands` always applies even then.
31
+ * - `protectedFiles` defaults to `DEFAULT_PROTECTED_FILES` (secret/credential
32
+ * file patterns) — an unattended agent must not silently overwrite .env /
33
+ * keys / PEMs. Unlike command allow/deny, leaving this OFF by default would
34
+ * be unsafe.
35
+ * - `allowProtectedWrites` defaults to `false` (an explicit escape hatch only).
36
+ */
37
+
38
+ import * as fs from "node:fs"
39
+ import * as path from "node:path"
40
+
41
+ import { resolveProjectDataDir } from "../project-store.js"
42
+ import { DEFAULT_PROTECTED_FILES } from "./protected-files.js"
43
+
44
+ /**
45
+ * Fully resolved permissions for one session / executor. Everything is
46
+ * concrete — no `undefined` fields — so enforcement code never has to guess.
47
+ */
48
+ export interface PermissionsConfig {
49
+ /** Command prefixes that may run (empty = default-ALLOW; see commands.ts). */
50
+ allowedCommands: string[]
51
+ /** Command prefixes that may NEVER run (deny wins over allow). */
52
+ deniedCommands: string[]
53
+ /** Glob patterns of files that may not be written (see protected-files.ts). */
54
+ protectedFiles: string[]
55
+ /** Escape hatch: when true, the protected-files check is bypassed. */
56
+ allowProtectedWrites: boolean
57
+ }
58
+
59
+ /**
60
+ * Raw (pre-resolution) overrides. Lists arrive as comma-separated strings —
61
+ * the same shape the CLI flags and env vars carry, so callers don't need to
62
+ * pre-split. `null`/absent means "no override at this layer".
63
+ */
64
+ export interface PermissionsOverrides {
65
+ allowedCommands?: string | null
66
+ deniedCommands?: string | null
67
+ protectedFiles?: string | null
68
+ allowProtectedWrites?: boolean | null
69
+ }
70
+
71
+ /** The auto-loaded per-repo config file (schema above). */
72
+ export interface PermissionsFile {
73
+ allowedCommands?: string[]
74
+ deniedCommands?: string[]
75
+ protectedFiles?: string[]
76
+ allowProtectedWrites?: boolean
77
+ }
78
+
79
+ /** Config file basename inside the central project data dir. */
80
+ export const PERMISSIONS_CONFIG_FILE = "permissions.json"
81
+
82
+ /** Absolute path of the central permissions.json for a workspace root. */
83
+ export function permissionsFilePath(workspaceRoot: string): string {
84
+ return path.join(resolveProjectDataDir(workspaceRoot), PERMISSIONS_CONFIG_FILE)
85
+ }
86
+
87
+ /** Split a comma-separated list (CLI flag / env var) into trimmed entries. */
88
+ export function parseCommaSeparated(raw: string | undefined): string[] | undefined {
89
+ if (raw === undefined || raw === null) {
90
+ return undefined
91
+ }
92
+ const entries = raw
93
+ .split(",")
94
+ .map((s) => s.trim())
95
+ .filter((s) => s.length > 0)
96
+ return entries.length > 0 ? entries : undefined
97
+ }
98
+
99
+ /**
100
+ * Validate an already-parsed permissions-file body (an object of
101
+ * {allowedCommands?, deniedCommands?, protectedFiles?, allowProtectedWrites?})
102
+ * and return the normalized `PermissionsFile`. Throws on malformed input —
103
+ * a broken policy must fail loudly, never silently fall back to weaker
104
+ * defaults. The file path is only used in error messages.
105
+ *
106
+ * Shared by BOTH the file loader below and the dashboard settings POST, so
107
+ * the HTTP save path can never drift from what the CLI/env path enforces.
108
+ */
109
+ export function parsePermissionsFileBody(parsed: unknown, source: string): PermissionsFile {
110
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
111
+ throw new Error(
112
+ `permissions: '${source}' must be a JSON object of {allowedCommands, deniedCommands, protectedFiles, allowProtectedWrites}`,
113
+ )
114
+ }
115
+
116
+ const out: PermissionsFile = {}
117
+ const obj = parsed as Record<string, unknown>
118
+
119
+ for (const key of ["allowedCommands", "deniedCommands", "protectedFiles"] as const) {
120
+ const value = obj[key]
121
+ if (value === undefined) {
122
+ continue
123
+ }
124
+ if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) {
125
+ throw new Error(`permissions: '${source}' field '${key}' must be an array of strings`)
126
+ }
127
+ out[key] = value as string[]
128
+ }
129
+
130
+ const allowProtectedWrites = obj.allowProtectedWrites
131
+ if (allowProtectedWrites !== undefined) {
132
+ if (typeof allowProtectedWrites !== "boolean") {
133
+ throw new Error(`permissions: '${source}' field 'allowProtectedWrites' must be a boolean`)
134
+ }
135
+ out.allowProtectedWrites = allowProtectedWrites
136
+ }
137
+
138
+ return out
139
+ }
140
+
141
+ /**
142
+ * Load the central `permissions.json` for a workspace when present.
143
+ * Returns `null` when the file does not exist (the common case). A
144
+ * present-but-malformed file throws — a broken policy must fail loudly, never
145
+ * silently fall back to weaker defaults. Falls back to the legacy
146
+ * `<workspaceRoot>/.headlesscode/permissions.json` only when the central file
147
+ * is absent AND the legacy one still exists (pre-migration grace — the
148
+ * migration in resolveProjectDataDir normally moves it before this is reached).
149
+ */
150
+ export function loadPermissionsFile(workspaceRoot: string): PermissionsFile | null {
151
+ const filePath = permissionsFilePath(workspaceRoot)
152
+ let raw: string
153
+ try {
154
+ raw = fs.readFileSync(filePath, "utf-8")
155
+ } catch (err) {
156
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
157
+ const legacy = path.resolve(workspaceRoot, ".headlesscode", PERMISSIONS_CONFIG_FILE)
158
+ try {
159
+ raw = fs.readFileSync(legacy, "utf-8")
160
+ } catch {
161
+ return null
162
+ }
163
+ } else {
164
+ throw new Error(
165
+ `permissions: cannot read permissions file '${filePath}': ${err instanceof Error ? err.message : String(err)}`,
166
+ )
167
+ }
168
+ }
169
+
170
+ let parsed: unknown
171
+ try {
172
+ parsed = JSON.parse(raw)
173
+ } catch (err) {
174
+ throw new Error(
175
+ `permissions: invalid JSON in '${filePath}': ${err instanceof Error ? err.message : String(err)}`,
176
+ )
177
+ }
178
+
179
+ return parsePermissionsFileBody(parsed, filePath)
180
+ }
181
+
182
+ /** Parse a truthy env flag ("1"/"true"/"yes"); undefined when unset. */
183
+ function envBoolean(name: string, env: NodeJS.ProcessEnv): boolean | undefined {
184
+ const raw = env[name]
185
+ if (raw === undefined || raw === "") {
186
+ return undefined
187
+ }
188
+ return raw === "1" || raw.toLowerCase() === "true" || raw.toLowerCase() === "yes"
189
+ }
190
+
191
+ /**
192
+ * Resolve the effective permissions for a session:
193
+ * CLI-flag overrides > env vars > permissions.json > built-in defaults.
194
+ *
195
+ * `workspaceRoot` is required so the auto-loaded `<workspaceRoot>/.headlesscode/
196
+ * permissions.json` policy can participate. Callers that have no CLI flags (the
197
+ * reviewer/QA executors, which resolve at executor-construction time) simply
198
+ * omit `overrides` — env + config file + defaults still apply.
199
+ */
200
+ export function resolvePermissions(options: {
201
+ workspaceRoot: string
202
+ overrides?: PermissionsOverrides
203
+ env?: NodeJS.ProcessEnv
204
+ }): PermissionsConfig {
205
+ const { workspaceRoot, overrides = {}, env = process.env } = options
206
+ const file = loadPermissionsFile(workspaceRoot)
207
+
208
+ const allowedCommands =
209
+ parseCommaSeparated(overrides.allowedCommands ?? undefined) ??
210
+ parseCommaSeparated(env.HEADLESSCODE_ALLOWED_COMMANDS) ??
211
+ file?.allowedCommands ??
212
+ []
213
+
214
+ const deniedCommands =
215
+ parseCommaSeparated(overrides.deniedCommands ?? undefined) ??
216
+ parseCommaSeparated(env.HEADLESSCODE_DENIED_COMMANDS) ??
217
+ file?.deniedCommands ??
218
+ []
219
+
220
+ const protectedFiles =
221
+ parseCommaSeparated(overrides.protectedFiles ?? undefined) ??
222
+ parseCommaSeparated(env.HEADLESSCODE_PROTECTED_FILES) ??
223
+ file?.protectedFiles ??
224
+ [...DEFAULT_PROTECTED_FILES]
225
+
226
+ // The escape hatch has no env-var requirement upstream, but honoring an
227
+ // env var keeps it usable for unattended workers (run-worker.sh sets
228
+ // policy via env). Precedence is the same chain.
229
+ const allowProtectedWrites =
230
+ overrides.allowProtectedWrites ??
231
+ envBoolean("HEADLESSCODE_ALLOW_PROTECTED_WRITES", env) ??
232
+ file?.allowProtectedWrites ??
233
+ false
234
+
235
+ return { allowedCommands, deniedCommands, protectedFiles, allowProtectedWrites }
236
+ }
237
+
238
+ /** Pretty-printed canonical serialization for the settings file. */
239
+ export function stringifyPermissionsFile(file: PermissionsFile): string {
240
+ return JSON.stringify(file, null, 2) + "\n"
241
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Permissions module — command allow/deny + protected-file enforcement config
3
+ * and matching for the headless harness.
4
+ *
5
+ * - `config.ts` — resolution precedence (CLI flags > env > .headlesscode/permissions.json > defaults)
6
+ * - `commands.ts` — parseCommand + containsDangerousSubstitution + allow/deny decisions (ported from Zoo Code)
7
+ * - `protected-files.ts`— default protected patterns + glob matching
8
+ */
9
+
10
+ export * from "./config.js"
11
+ export * from "./commands.js"
12
+ export * from "./protected-files.js"
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Protected-file glob matching for the headless harness.
3
+ *
4
+ * A write to any path matching the resolved protected-files list is refused
5
+ * (unless the `--allow-protected-writes` escape hatch is on). Simple glob
6
+ * matching, implemented in-repo — the vendored checkpoint excludes.ts
7
+ * (src/vendor/zoo-code/src/services/checkpoints/excludes.ts) only *builds*
8
+ * pattern lists that shadow-git feeds to git itself (via `git config
9
+ * core.excludesFile`-style config), it has no reusable TS matcher, and there
10
+ * is no glob/minimatch/picomatch dependency in this repo or node_modules.
11
+ *
12
+ * Matching semantics (deliberate, documented):
13
+ * - Paths are normalized to POSIX separators and matched against their path
14
+ * relative to the workspace root.
15
+ * - A pattern WITHOUT a slash matches the BASENAME at any depth — so `.env`
16
+ * matches `subdir/.env`, `*.key` matches `a/b/secret.key`, `id_rsa*`
17
+ * matches `config/id_rsa_backup`. This is the behavior the plan requires
18
+ * ("a bare filename pattern like `.env` should match `.env` at any depth").
19
+ * - A pattern WITH a slash is anchored to the workspace root (gitignore-style)
20
+ * and matches the full relative path — `config/credentials.key` matches
21
+ * `config/credentials.key` but not `other/config/credentials.key`.
22
+ * - A pattern ending in `/` protects a directory and everything under it.
23
+ * - `*` matches within one path segment (`[^/]*`), `**` matches across
24
+ * segments (`.*`), `?` matches one non-`/` character. Matching is
25
+ * case-sensitive (Linux glob convention).
26
+ */
27
+
28
+ /** Built-in protected-file default: secrets/credentials an unattended agent
29
+ * must never silently overwrite. Adjust/extend via config, but ship a real
30
+ * default. */
31
+ export const DEFAULT_PROTECTED_FILES: string[] = [".env", ".env.*", "*.pem", "*.key", "id_rsa*"]
32
+
33
+ /** Escape regex metacharacters except the glob wildcards we handle ourselves. */
34
+ function escapeRegExp(ch: string): string {
35
+ return "\\^$.[]{}()|+".includes(ch) ? `\\${ch}` : ch
36
+ }
37
+
38
+ /** Convert `*`/`**`/`?` glob wildcards to a regex source (segment-aware). */
39
+ function globSource(pattern: string): string {
40
+ let out = ""
41
+ for (let i = 0; i < pattern.length; i++) {
42
+ const ch = pattern[i]
43
+ if (ch === "*") {
44
+ if (pattern[i + 1] === "*") {
45
+ out += ".*"
46
+ i++
47
+ } else {
48
+ out += "[^/]*"
49
+ }
50
+ } else if (ch === "?") {
51
+ out += "[^/]"
52
+ } else {
53
+ out += escapeRegExp(ch)
54
+ }
55
+ }
56
+ return out
57
+ }
58
+
59
+ /** Compile one protected-files pattern to a RegExp (see module header). */
60
+ export function patternToRegExp(pattern: string): RegExp {
61
+ // Directory pattern: "secrets/" protects the dir and everything under it.
62
+ if (pattern.endsWith("/")) {
63
+ const dir = globSource(pattern.slice(0, -1))
64
+ return new RegExp(`^${dir}(?:/.*)?$`)
65
+ }
66
+ // Anchored always. No-slash patterns are matched against the basename
67
+ // (findMatchingPattern picks the basename candidate), slash patterns against
68
+ // the full workspace-relative path — but both are full matches, never
69
+ // substring matches (so `.env` matches `.env` but not `.envs` or `.env.local`).
70
+ return new RegExp(`^${globSource(pattern)}$`)
71
+ }
72
+
73
+ /**
74
+ * Return the first pattern in `patternList` that matches `relPath`, or `null`.
75
+ * `relPath` is the workspace-relative path (POSIX separators preferred; `\`
76
+ * is normalized).
77
+ */
78
+ export function findMatchingPattern(relPath: string, patternList: string[]): string | null {
79
+ const normalized = relPath.replace(/\\/g, "/")
80
+ for (const pattern of patternList) {
81
+ if (pattern === "") {
82
+ continue
83
+ }
84
+ const hasSlash = pattern.includes("/")
85
+ const candidate = hasSlash ? normalized : normalized.split("/").pop() ?? ""
86
+ if (candidate !== "" && patternToRegExp(pattern).test(candidate)) {
87
+ return pattern
88
+ }
89
+ }
90
+ return null
91
+ }
92
+
93
+ /** True when `relPath` matches any of the protected patterns. */
94
+ export function isProtectedPath(relPath: string, patternList: string[]): boolean {
95
+ return findMatchingPattern(relPath, patternList) !== null
96
+ }
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Default-on protection for the shared central data store against destructive
3
+ * commands run through the `execute_command` TOOL.
4
+ *
5
+ * Background (plans/protect-shared-store-from-destructive-commands.md): a
6
+ * worker deleted the real `~/.local/share/headlesscode` with `rm -rf` as an
7
+ * ad-hoc verification step — three separate times. The command allow/deny
8
+ * system is deliberately opt-in (`permissions.json` / flags / env), so a
9
+ * session that configures nothing has ZERO default protection. The central
10
+ * store (src/project-store.ts) is a categorically different resource: it is
11
+ * SHARED across every project on the machine, so a single misbehaving
12
+ * workspace must not be able to destroy it. This check is therefore
13
+ * always-applied (even with an empty `deniedCommands` list) and NOT
14
+ * overridable by per-workspace permissions config — a config file a worker
15
+ * could write must never authorize deleting the shared store.
16
+ *
17
+ * Honest scope: this is PATTERN-BASED detection, not a hermetic sandbox. It
18
+ * catches recursive `rm` invocations (`rm -rf`, `rm -r`, `rm -fr`, `-R`,
19
+ * `--recursive`, in any flag order/bundle) whose resolved target is the store
20
+ * root, a PARENT of it, or a DESCENDANT of it (`rm -rf <store>/<subdir>`). It
21
+ * also follows symlinks when resolving the target (via `fs.realpathSync`,
22
+ * falling back to the lexical path when the target doesn't exist yet — e.g.
23
+ * `ln -s <store> /tmp/x && rm -rf /tmp/x` resolves through the symlink). It
24
+ * deliberately does NOT try to enumerate every way to destroy a file — a
25
+ * script, `python -c "shutil.rmtree(...)"`, `find ... -delete`, `command rm`,
26
+ * `sudo rm`, or a non-recursive `rm` of a single file inside the store are all
27
+ * out of scope for this version. It is a speed bump against the exact class
28
+ * of mistake that already happened, not a proof of safety. See SEC-5/SEC-6 in
29
+ * SECURITY.md for the full honest-scope writeup, including the residual
30
+ * cd-chain caveat (only a leading `cd <dir> &&` prefix chain updates the
31
+ * effective cwd used for later sub-commands; `cd` inside a subshell, via a
32
+ * variable, or via `pushd` is not tracked).
33
+ *
34
+ * Matching rule (per the plan): a command's target is refused when the
35
+ * RESOLVED path equals the store root, is an ancestor of it, or is a
36
+ * descendant of it. Resolution handles `~` / `~/`, `$VAR` / `${VAR}` env
37
+ * expansion, quoting, symlink following, and relative-vs-absolute paths
38
+ * (relative targets anchor on the command's working directory — the executor
39
+ * passes the resolved `cwd`, and `checkCommand` additionally tracks a leading
40
+ * `cd` chain across `&&`-joined sub-commands). A trailing `/*` glob is treated
41
+ * as the directory itself (the "delete the contents" idiom targets the same
42
+ * resource).
43
+ */
44
+
45
+ import * as fs from "node:fs"
46
+ import * as os from "node:os"
47
+ import * as path from "node:path"
48
+
49
+ import { projectStoreRoot } from "../project-store.js"
50
+
51
+ /**
52
+ * Split a command string into shell argv words, honoring single quotes, double
53
+ * quotes and backslash escapes. `~` and `$VAR`/`${VAR}` are KEPT verbatim in
54
+ * the words so the path expander can process them (quote stripping only).
55
+ * Deliberately a minimal approximation of shell word splitting — good enough
56
+ * for the pattern this module detects, not a general shell parser.
57
+ */
58
+ export function splitCommandWords(command: string): string[] {
59
+ const words: string[] = []
60
+ let current = ""
61
+ let inWord = false
62
+ let i = 0
63
+ while (i < command.length) {
64
+ const ch = command[i]
65
+ if (ch === "'") {
66
+ inWord = true
67
+ i++
68
+ while (i < command.length && command[i] !== "'") {
69
+ current += command[i++]
70
+ }
71
+ i++ // closing quote
72
+ } else if (ch === '"') {
73
+ inWord = true
74
+ i++
75
+ while (i < command.length && command[i] !== '"') {
76
+ if (command[i] === "\\" && i + 1 < command.length && '\\"$`'.includes(command[i + 1])) {
77
+ current += command[i + 1]
78
+ i += 2
79
+ } else {
80
+ current += command[i++]
81
+ }
82
+ }
83
+ i++ // closing quote
84
+ } else if (ch === "\\") {
85
+ inWord = true
86
+ if (i + 1 < command.length) {
87
+ current += command[i + 1]
88
+ i += 2
89
+ } else {
90
+ i++
91
+ }
92
+ } else if (/\s/.test(ch)) {
93
+ if (inWord) {
94
+ words.push(current)
95
+ current = ""
96
+ inWord = false
97
+ }
98
+ i++
99
+ } else {
100
+ inWord = true
101
+ current += ch
102
+ i++
103
+ }
104
+ }
105
+ if (inWord) {
106
+ words.push(current)
107
+ }
108
+ return words
109
+ }
110
+
111
+ /**
112
+ * True when `words` describe a recursive `rm`: the program is `rm` and any
113
+ * option is `-r`/`-R` (in any short-flag bundle, e.g. `-rf`, `-fr`, `-Rf`) or
114
+ * the long `--recursive`. Flags are scanned across ALL words (GNU rm permutes
115
+ * options and operands), stopping at a literal `--`. Case-insensitive for the
116
+ * short flag (POSIX `-R` is the traditional spelling).
117
+ */
118
+ export function isRecursiveDelete(words: string[]): boolean {
119
+ if (words.length === 0 || path.basename(words[0]) !== "rm") {
120
+ return false
121
+ }
122
+ for (let i = 1; i < words.length; i++) {
123
+ const w = words[i]
124
+ if (w === "--") {
125
+ break
126
+ }
127
+ if (w === "--recursive") {
128
+ return true
129
+ }
130
+ if (w.startsWith("-") && w.length > 1 && !w.startsWith("--")) {
131
+ // Short-flag bundle: -r, -rf, -fr, -Rf, -rfi, ...
132
+ if (w.slice(1).toLowerCase().includes("r")) {
133
+ return true
134
+ }
135
+ }
136
+ }
137
+ return false
138
+ }
139
+
140
+ /**
141
+ * The operand (path) words of a recursive `rm`: every word that is not an
142
+ * option, honoring a `--` end-of-options marker (everything after `--` is an
143
+ * operand even if it starts with `-`).
144
+ */
145
+ export function deleteTargets(words: string[]): string[] {
146
+ const targets: string[] = []
147
+ let afterDoubleDash = false
148
+ for (let i = 1; i < words.length; i++) {
149
+ const w = words[i]
150
+ if (afterDoubleDash) {
151
+ targets.push(w)
152
+ continue
153
+ }
154
+ if (w === "--") {
155
+ afterDoubleDash = true
156
+ continue
157
+ }
158
+ if (w.startsWith("-") && w.length > 1) {
159
+ continue // option bundle / long option
160
+ }
161
+ targets.push(w)
162
+ }
163
+ return targets
164
+ }
165
+
166
+ /** Expand a leading `~` / `~/` to the home directory. `~user` forms are left
167
+ * alone (they are not expanded by this module — vanishingly rare in the
168
+ * command class under guard, and resolving them needs a passwd lookup). */
169
+ export function expandHome(p: string): string {
170
+ if (p === "~") {
171
+ return os.homedir()
172
+ }
173
+ if (p.startsWith("~/")) {
174
+ return path.join(os.homedir(), p.slice(2))
175
+ }
176
+ return p
177
+ }
178
+
179
+ /** Expand `$VAR` and `${VAR}` from `env` (default: process.env). An unset
180
+ * variable stays literal (the shell would turn it into an empty word, but
181
+ * keeping it literal errs toward refusing — safe direction). */
182
+ export function expandEnv(p: string, env: NodeJS.ProcessEnv = process.env): string {
183
+ return p
184
+ .replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (m, name: string) => env[name] ?? m)
185
+ .replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (m, name: string) => env[name] ?? m)
186
+ }
187
+
188
+ /**
189
+ * Resolve a raw target word to an absolute path: env expansion, `~`
190
+ * expansion, then `path.resolve` against the command's working directory
191
+ * (relative targets anchor there). A trailing `/*` glob is dropped — deleting
192
+ * "the store's contents" via `rm -rf <store>/*` targets the store itself.
193
+ */
194
+ export function resolveCommandTarget(target: string, workspaceRoot: string): string {
195
+ let expanded = expandEnv(expandHome(target))
196
+ if (expanded.endsWith("/*")) {
197
+ expanded = expanded.slice(0, -2)
198
+ }
199
+ return path.resolve(workspaceRoot, expanded)
200
+ }
201
+
202
+ /**
203
+ * Best-effort symlink resolution: `fs.realpathSync` when the path exists,
204
+ * otherwise the lexical path unchanged (a not-yet-existing target can't be
205
+ * resolved through a symlink, and that's fine — a `rm -rf` of a nonexistent
206
+ * path is a no-op anyway). Never throws.
207
+ */
208
+ function tryRealpath(p: string): string {
209
+ try {
210
+ return fs.realpathSync(p)
211
+ } catch {
212
+ return p
213
+ }
214
+ }
215
+
216
+ /**
217
+ * True when the resolved `target` is the central store root, an ancestor of
218
+ * it, or a DESCENDANT of it. Checked both lexically (path.resolve) and
219
+ * against the symlink-resolved realpath — the same convention
220
+ * `resolveWithinWorkspace` documents for the harness's own path safety, plus
221
+ * realpath so `ln -s <store> /tmp/x && rm -rf /tmp/x` is caught too. The
222
+ * store root comes from project-store.ts's own resolver (projectStoreRoot),
223
+ * never a second copy of that path logic.
224
+ */
225
+ export function isCentralStoreOrParent(target: string): boolean {
226
+ const storeRoot = projectStoreRoot()
227
+ const candidates = new Set([path.resolve(target), tryRealpath(path.resolve(target))])
228
+ for (const t of candidates) {
229
+ if (t === storeRoot) {
230
+ return true
231
+ }
232
+ // Ancestor: storeRoot is inside t.
233
+ if (storeRoot.startsWith(t.endsWith(path.sep) ? t : t + path.sep)) {
234
+ return true
235
+ }
236
+ // Descendant: t is inside storeRoot.
237
+ if (t.startsWith(storeRoot.endsWith(path.sep) ? storeRoot : storeRoot + path.sep)) {
238
+ return true
239
+ }
240
+ }
241
+ return false
242
+ }
243
+
244
+ /** What a refused destructive command hit, for the model-facing message. */
245
+ export interface StoreDestructionRefusal {
246
+ /** The resolved absolute target that matched (store root or an ancestor). */
247
+ target: string
248
+ /** The protected central store root (projectStoreRoot()). */
249
+ storeRoot: string
250
+ }
251
+
252
+ /**
253
+ * Check ONE sub-command (already split by parseCommand) for a recursive
254
+ * delete targeting the central store or a parent of it. Returns a refusal
255
+ * descriptor when it must be blocked, or `null` when it is not a
256
+ * store-targeting destructive command. `workspaceRoot` anchors relative
257
+ * targets (defaults to process.cwd()).
258
+ */
259
+ export function checkCentralStoreDestruction(subCommand: string, workspaceRoot?: string): StoreDestructionRefusal | null {
260
+ const words = splitCommandWords(subCommand)
261
+ if (!isRecursiveDelete(words)) {
262
+ return null
263
+ }
264
+ const root = path.resolve(workspaceRoot ?? process.cwd())
265
+ for (const target of deleteTargets(words)) {
266
+ const resolved = resolveCommandTarget(target, root)
267
+ if (isCentralStoreOrParent(resolved)) {
268
+ return { target: resolved, storeRoot: projectStoreRoot() }
269
+ }
270
+ }
271
+ return null
272
+ }