@uniqbit/mate-core 0.18.4 → 1.0.0-canary.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 (231) hide show
  1. package/claude-plugin/hooks/companion-instructions.mjs +9 -0
  2. package/claude-plugin/hooks/hooks.json +12 -2
  3. package/opencode-plugin/src/add-dir.ts +76 -10
  4. package/opencode-plugin/src/companion.ts +1 -8
  5. package/opencode-plugin/src/server.ts +2 -15
  6. package/package.json +10 -2
  7. package/src/brand/art.generated.ts +69 -0
  8. package/src/brand/index.ts +12 -0
  9. package/src/brand/palette.ts +19 -0
  10. package/src/brand/svg.generated.ts +8 -0
  11. package/src/brand/terminal.ts +83 -0
  12. package/src/brand/types.ts +33 -0
  13. package/src/cli/commands/artifact/artifact.ts +8 -0
  14. package/src/cli/commands/artifact/delivery/archive.ts +117 -0
  15. package/src/cli/commands/artifact/delivery/branch-flags.ts +55 -0
  16. package/src/cli/commands/artifact/delivery/environment.ts +104 -0
  17. package/src/cli/commands/artifact/delivery/link.ts +154 -0
  18. package/src/cli/commands/artifact/delivery/output.ts +58 -0
  19. package/src/cli/commands/artifact/finish/command.ts +33 -3
  20. package/src/cli/commands/artifact/finish/engine.ts +47 -3
  21. package/src/cli/commands/artifact/finish/finisher.ts +10 -0
  22. package/src/cli/commands/artifact/finish/openspec.ts +200 -129
  23. package/src/cli/commands/artifact/finish/secret-scan.ts +43 -0
  24. package/src/cli/commands/artifact/pending/command.ts +31 -3
  25. package/src/cli/commands/artifact/pending/discovery.ts +21 -57
  26. package/src/cli/commands/cap/graphify.ts +52 -3
  27. package/src/cli/commands/cap/index-cmd.ts +8 -1
  28. package/src/cli/commands/cap/openspec.ts +13 -25
  29. package/src/cli/commands/companion/open.ts +56 -39
  30. package/src/cli/commands/companion/update.ts +75 -42
  31. package/src/cli/commands/doctor.ts +257 -78
  32. package/src/cli/commands/install.ts +0 -8
  33. package/src/cli/commands/plugin/install.ts +9 -4
  34. package/src/cli/commands/plugin/plugin.ts +3 -2
  35. package/src/cli/commands/report/adapter.ts +11 -2
  36. package/src/cli/commands/report/contract.ts +21 -0
  37. package/src/cli/commands/report/highlight.ts +34 -4
  38. package/src/cli/commands/report/markdown.ts +110 -0
  39. package/src/cli/commands/report/palette.ts +118 -0
  40. package/src/cli/commands/report/renderer.ts +456 -153
  41. package/src/cli/commands/report/types.ts +16 -0
  42. package/src/cli/commands/setup.ts +7 -15
  43. package/src/cli/commands/studio/client-asset.ts +16 -0
  44. package/src/cli/commands/studio/openspec-cli.ts +10 -58
  45. package/src/cli/commands/studio/routes.ts +2 -0
  46. package/src/cli/commands/studio/selection.ts +19 -1
  47. package/src/cli/commands/studio/server.ts +42 -1
  48. package/src/cli/commands/studio/terminal-launch.ts +7 -16
  49. package/src/cli/commands/studio/views/client.ts +6 -616
  50. package/src/cli/commands/studio/views/dashboard/changes.tsx +5 -10
  51. package/src/cli/commands/studio/views/document.tsx +137 -69
  52. package/src/cli/commands/studio/views/markdown.tsx +250 -0
  53. package/src/cli/commands/studio/views/palette.ts +134 -0
  54. package/src/cli/commands/studio/views/reports/index.tsx +2 -7
  55. package/src/cli/commands/studio/views/skills/index.tsx +43 -62
  56. package/src/cli/commands/studio/views/specs/index.tsx +7 -15
  57. package/src/cli/commands/studio/views/studio-client/actions.ts +51 -0
  58. package/src/cli/commands/studio/views/studio-client/companion.ts +13 -0
  59. package/src/cli/commands/studio/views/studio-client/index.ts +32 -0
  60. package/src/cli/commands/studio/views/studio-client/keys.ts +3 -0
  61. package/src/cli/commands/studio/views/studio-client/main.ts +3 -0
  62. package/src/cli/commands/studio/views/studio-client/page.ts +146 -0
  63. package/src/cli/commands/studio/views/studio-client/panels.ts +57 -0
  64. package/src/cli/commands/studio/views/studio-client/reports.ts +122 -0
  65. package/src/cli/commands/studio/views/studio-client/theme.ts +63 -0
  66. package/src/cli/commands/studio/views/studio-client/vault-editor.ts +166 -0
  67. package/src/cli/commands/studio/views/studio-client/vault-pane.ts +202 -0
  68. package/src/cli/commands/studio/views/studio-client/vault-tree.ts +258 -0
  69. package/src/cli/commands/studio/views/studio-client.generated.ts +2 -0
  70. package/src/cli/commands/studio/views/styles.ts +217 -206
  71. package/src/cli/commands/studio/views/vault/state.ts +3 -5
  72. package/src/cli/commands/studio/views/workflow/delivery-path.tsx +370 -0
  73. package/src/cli/commands/studio/views/workflow/graph.ts +220 -0
  74. package/src/cli/commands/studio/views/workflow/index.tsx +2 -3
  75. package/src/cli/commands/studio/views/workflow/steps.ts +228 -287
  76. package/src/cli/commands/wrap.ts +9 -42
  77. package/src/cli/main.ts +4 -1
  78. package/src/cli/parse-flags.ts +25 -7
  79. package/src/cli/setup-selector.tsx +38 -162
  80. package/src/cli/usage.ts +5 -2
  81. package/src/hooks/companion-instructions.ts +190 -0
  82. package/src/hooks/hook-io.ts +63 -0
  83. package/src/hooks/session-banner.ts +21 -29
  84. package/src/hooks/session-guidance.ts +10 -36
  85. package/src/hooks/session-prepare.ts +115 -28
  86. package/src/hooks/validate-artifact-path.ts +82 -24
  87. package/src/index.ts +4 -0
  88. package/src/lib/change-layout.ts +154 -0
  89. package/src/lib/components/wizard-header.tsx +3 -1
  90. package/src/lib/context-mode-package.ts +24 -11
  91. package/src/lib/delivery/archive-change.ts +154 -0
  92. package/src/lib/delivery/branches.ts +108 -0
  93. package/src/lib/delivery/environment.ts +29 -0
  94. package/src/lib/delivery/forge.ts +279 -0
  95. package/src/lib/delivery/overlap.ts +202 -0
  96. package/src/lib/delivery/record-delivery.ts +196 -0
  97. package/src/lib/delivery/record.ts +343 -0
  98. package/src/lib/delivery/refresh.ts +174 -0
  99. package/src/lib/delivery/repository-access.ts +97 -0
  100. package/src/lib/delivery/stages.ts +111 -0
  101. package/src/lib/delivery/token.ts +58 -0
  102. package/src/lib/frontmatter.ts +18 -0
  103. package/src/lib/opencode-plugin-package.ts +5 -5
  104. package/src/lib/openspec-cli.ts +116 -0
  105. package/src/lib/openspec-scopes.ts +86 -0
  106. package/src/lib/orchestrator/adapters/claude-args.ts +50 -0
  107. package/src/lib/orchestrator/adapters/claude.ts +60 -162
  108. package/src/lib/orchestrator/adapters/opencode.ts +9 -24
  109. package/src/lib/orchestrator/config-store.ts +2 -70
  110. package/src/lib/orchestrator/editor.ts +9 -9
  111. package/src/lib/orchestrator/openspec-store.ts +14 -54
  112. package/src/lib/orchestrator/projection-claude-commands.ts +36 -0
  113. package/src/lib/orchestrator/projection-claude-links.ts +159 -0
  114. package/src/lib/orchestrator/projection-claude-skills.ts +19 -190
  115. package/src/lib/orchestrator/projection-entries.ts +45 -15
  116. package/src/lib/orchestrator/projection-runtime-documents.ts +15 -6
  117. package/src/lib/orchestrator/projection-types.ts +3 -2
  118. package/src/lib/orchestrator/refresh-working-claude-links.ts +87 -0
  119. package/src/lib/orchestrator/session-prepare.ts +49 -87
  120. package/src/lib/orchestrator/setup-compatibilities.ts +18 -75
  121. package/src/lib/orchestrator/types.ts +2 -2
  122. package/src/lib/orchestrator/wrap-pass.ts +22 -88
  123. package/src/lib/package-paths.ts +8 -0
  124. package/src/opencode/companion-hooks.ts +64 -5
  125. package/src/opencode/companion-policy.ts +37 -18
  126. package/src/opencode/projected-guidance.ts +4 -1
  127. package/src/opencode/session-prepare.ts +2 -1
  128. package/src/opencode/tui.tsx +4 -2
  129. package/src/runtime/artifact-refusal.ts +63 -0
  130. package/src/runtime/claude-delivery-plan.ts +89 -7
  131. package/src/runtime/commit-attribution-guard.ts +181 -0
  132. package/src/runtime/companion-guidance.ts +15 -3
  133. package/src/runtime/companion-instructions.ts +340 -0
  134. package/src/runtime/error-message.ts +4 -0
  135. package/src/runtime/guidance.ts +0 -17
  136. package/src/runtime/index.ts +5 -0
  137. package/src/runtime/managed-block.ts +117 -0
  138. package/src/runtime/markdown-scan.ts +266 -0
  139. package/src/runtime/policy.ts +16 -15
  140. package/src/runtime/projected-flag.ts +7 -0
  141. package/src/runtime/projected-guidance.ts +1 -0
  142. package/src/runtime/runtime-document-manifest.ts +62 -28
  143. package/src/runtime/secret-guard.ts +148 -0
  144. package/src/runtime/secret-patterns.ts +164 -0
  145. package/src/runtime/secret-scan.ts +90 -0
  146. package/src/runtime/session-kind.ts +13 -0
  147. package/src/runtime/shell-heredoc.ts +81 -0
  148. package/src/runtime/wrap-state.ts +36 -6
  149. package/src/runtime/write-atomic.ts +64 -0
  150. package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +8 -6
  151. package/src/templates/capabilities/openspec-cap/mate-minimal/templates/spec.md +3 -5
  152. package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +7 -9
  153. package/src/templates/capabilities/openspec-cap/mate-v1/templates/spec.md +3 -5
  154. package/src/templates/capabilities/openspec-cap/openspec-conventions.yaml +10 -12
  155. package/src/templates/mate-skills/THIRD-PARTY-NOTICES.md +40 -0
  156. package/src/templates/mate-skills/agents/mate-apply-change/SKILL.md +76 -0
  157. package/src/templates/mate-skills/agents/mate-apply-change/references/openspec-apply-change.md +198 -0
  158. package/src/templates/mate-skills/agents/mate-archive-change/SKILL.md +59 -0
  159. package/src/templates/mate-skills/{claude/mate-openspec-backfill → agents/mate-backfill-spec}/SKILL.md +4 -4
  160. package/src/templates/mate-skills/agents/mate-clarify-intent/SKILL.md +95 -0
  161. package/src/templates/mate-skills/agents/mate-explore-change/SKILL.md +50 -0
  162. package/src/templates/mate-skills/agents/mate-explore-change/references/openspec-explore.md +348 -0
  163. package/src/templates/mate-skills/agents/mate-grill-me/SKILL.md +3 -3
  164. package/src/templates/mate-skills/agents/mate-interview-me/SKILL.md +5 -5
  165. package/src/templates/mate-skills/agents/mate-propose-change/SKILL.md +103 -0
  166. package/src/templates/mate-skills/agents/mate-propose-change/references/openspec-propose.md +167 -0
  167. package/src/templates/mate-skills/agents/mate-propose-change/references/openspec-update-change.md +101 -0
  168. package/src/templates/mate-skills/agents/{mate-artifact-publish → mate-publish-artifact}/SKILL.md +91 -23
  169. package/src/templates/mate-skills/agents/{mate-artifact-publish → mate-publish-artifact}/references/openspec.md +4 -3
  170. package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +74 -16
  171. package/src/templates/mate-skills/agents/mate-simplify-code/SKILL.md +74 -68
  172. package/src/templates/mate-skills/claude/mate-apply-change/SKILL.md +76 -0
  173. package/src/templates/mate-skills/claude/mate-apply-change/references/openspec-apply-change.md +198 -0
  174. package/src/templates/mate-skills/claude/mate-archive-change/SKILL.md +59 -0
  175. package/src/templates/mate-skills/{agents/mate-openspec-backfill → claude/mate-backfill-spec}/SKILL.md +4 -4
  176. package/src/templates/mate-skills/claude/mate-clarify-intent/SKILL.md +95 -0
  177. package/src/templates/mate-skills/claude/mate-explore-change/SKILL.md +50 -0
  178. package/src/templates/mate-skills/claude/mate-explore-change/references/openspec-explore.md +348 -0
  179. package/src/templates/mate-skills/claude/mate-grill-me/SKILL.md +3 -3
  180. package/src/templates/mate-skills/claude/mate-interview-me/SKILL.md +5 -5
  181. package/src/templates/mate-skills/claude/mate-propose-change/SKILL.md +103 -0
  182. package/src/templates/mate-skills/claude/mate-propose-change/references/openspec-propose.md +167 -0
  183. package/src/templates/mate-skills/claude/mate-propose-change/references/openspec-update-change.md +101 -0
  184. package/src/templates/mate-skills/claude/{mate-artifact-publish → mate-publish-artifact}/SKILL.md +91 -23
  185. package/src/templates/mate-skills/claude/{mate-artifact-publish → mate-publish-artifact}/references/openspec.md +4 -3
  186. package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +74 -16
  187. package/src/templates/mate-skills/claude/mate-simplify-code/SKILL.md +74 -68
  188. package/src/templates/mate-skills/openspec-references.ts +140 -0
  189. package/src/templates/root/TEMPLATE_AGENTS.md +2 -2
  190. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +3727 -320
  191. package/src/tools/setup/capabilities/context-mode.ts +25 -5
  192. package/src/tools/setup/capabilities/graphify-gate.ts +31 -0
  193. package/src/tools/setup/capabilities/graphify-secrets.ts +255 -0
  194. package/src/tools/setup/capabilities/graphify.ts +12 -0
  195. package/src/tools/setup/capabilities/openspec.ts +273 -270
  196. package/src/tools/setup/capabilities/tokensave.ts +36 -3
  197. package/src/tools/setup/context-services.ts +9 -17
  198. package/src/tools/setup/dynamic-plugins/audiences.ts +7 -39
  199. package/src/tools/setup/dynamic-plugins/declarations.ts +1 -1
  200. package/src/tools/setup/dynamic-plugins/hydrate.ts +4 -7
  201. package/src/tools/setup/dynamic-plugins/install.ts +24 -28
  202. package/src/tools/setup/engine.ts +93 -148
  203. package/src/tools/setup/managed-output.ts +51 -0
  204. package/src/tools/setup/mate.ts +86 -11
  205. package/src/tools/setup/plugin.ts +37 -17
  206. package/src/tools/setup/plugins/gitignore.ts +1 -7
  207. package/src/tools/setup/plugins/guidance.ts +7 -4
  208. package/src/tools/setup/providers/agent-file-sections.ts +4 -2
  209. package/src/tools/setup/providers/claude-attribution.ts +65 -0
  210. package/src/tools/setup/providers/claude-plugin-folders.ts +625 -0
  211. package/src/tools/setup/providers/claude-plugin-hooks.ts +39 -19
  212. package/src/tools/setup/providers/claude.ts +285 -34
  213. package/src/tools/setup/providers/opencode.ts +180 -11
  214. package/src/tools/setup/registry.ts +2 -24
  215. package/src/tools/setup/surface-target.ts +4 -13
  216. package/src/tools/setup/utils.ts +14 -4
  217. package/src/tools/setup.ts +131 -18
  218. package/wrappers/bin/graphify +52 -4
  219. package/wrappers/bin/openspec +33 -9
  220. package/wrappers/lib/graphify-gate.mjs +8 -0
  221. package/opencode-plugin/src/audience-layer.ts +0 -91
  222. package/src/cli/commands/studio/views/workflow/transcript.tsx +0 -190
  223. package/src/runtime/audience-layer.ts +0 -84
  224. package/src/runtime/opencode-audience-layer.ts +0 -176
  225. package/src/tools/setup/audience-isolation.fixture.ts +0 -325
  226. package/src/tools/setup/audience-layer.ts +0 -207
  227. package/src/tools/setup/audience-registrations.ts +0 -52
  228. package/src/tools/setup/contribution-ledger.ts +0 -207
  229. package/src/tools/setup/isolation-diagnostics.ts +0 -139
  230. package/src/tools/setup/runtime-documents.ts +0 -291
  231. package/src/tools/setup/upgrade-sweep.ts +0 -153
@@ -0,0 +1,148 @@
1
+ import { resolveSecretPolicy } from "./policy";
2
+ import { globTargetsSecretFiles, isSecretFile } from "./secret-patterns";
3
+ import { describeSecretFindings, scanContent, type SecretFinding } from "./secret-scan";
4
+ import { HEREDOC_MARKER, stripHeredocBodies } from "./shell-heredoc";
5
+
6
+ /**
7
+ * The secret check both runtimes run before a tool call: the Claude PreToolUse
8
+ * hook and the OpenCode middleware translate their own tool payloads into one
9
+ * of these shapes, so the two refuse the same calls for the same reasons.
10
+ */
11
+ export type SecretToolCall =
12
+ | { kind: "read"; paths: string[] }
13
+ /** `content` is what the call would put into the file: a whole body or an edit's new text. */
14
+ | { kind: "write"; writes: { path: string; content: string }[] }
15
+ | { kind: "shell"; command: string }
16
+ /** A content search: refused when it names a secret file or a glob aimed at secret files. */
17
+ | { kind: "search"; paths: string[]; globs: string[] };
18
+
19
+ export interface SecretGuardScope {
20
+ companionPath: string;
21
+ /** Whether a target lands in the Companion Repository, whose writes are content-scanned. */
22
+ isCompanionPath: (filePath: string) => boolean;
23
+ }
24
+
25
+ export interface SecretGuardDeps {
26
+ scan?: (filePath: string, content: string) => Promise<SecretFinding[]>;
27
+ }
28
+
29
+ const SWITCH_HINT =
30
+ "Secret guardrails are on. The user can add an allow pattern or opt out under `secrets` in the companion's framework.yaml; do not work around this block.";
31
+
32
+ function secretFileRefusal(filePath: string): string {
33
+ return [
34
+ "Mate guardrail: secret files are off-limits to agents.",
35
+ ` target: ${filePath}`,
36
+ "Refer to the variable by name, read an example file such as .env.example, or ask the user to set the value.",
37
+ SWITCH_HINT,
38
+ ].join("\n");
39
+ }
40
+
41
+ const SHELL_SEPARATOR = /[\s;|&<>()`]/;
42
+
43
+ /**
44
+ * Shell words with quotes removed, redirect operators as separators and
45
+ * heredoc bodies dropped. A heuristic, not a parser: it exists to find file
46
+ * names a command spells out, and a quoted string stays one word, so a
47
+ * commit message that mentions `.env` is not a reference to the file.
48
+ */
49
+ export function shellWords(command: string): string[] {
50
+ const words: string[] = [];
51
+ for (const line of stripHeredocBodies(command).split("\n")) {
52
+ let word = "";
53
+ let quote: string | undefined;
54
+ let quoted = false;
55
+ const flush = () => {
56
+ if (word || quoted) words.push(word);
57
+ word = "";
58
+ quoted = false;
59
+ };
60
+ for (let i = 0; i < line.length; i++) {
61
+ const char = line[i];
62
+ if (quote) {
63
+ if (char === quote) quote = undefined;
64
+ else if (char === "\\" && quote === '"' && i + 1 < line.length) word += line[++i];
65
+ else word += char;
66
+ } else if (char === "'" || char === '"') {
67
+ quote = char;
68
+ quoted = true;
69
+ } else if (char === "\\" && i + 1 < line.length) {
70
+ word += line[++i];
71
+ } else if (char === "<" && line[i + 1] === "<") {
72
+ flush();
73
+ const marker = HEREDOC_MARKER.exec(line.slice(i));
74
+ i += marker ? marker[0].length - 1 : 1;
75
+ } else if (SHELL_SEPARATOR.test(char)) {
76
+ flush();
77
+ } else {
78
+ word += char;
79
+ }
80
+ }
81
+ flush();
82
+ }
83
+ return words.filter(Boolean);
84
+ }
85
+
86
+ /** A word, plus the value of a `--flag=path` or `NAME=path` word. */
87
+ function candidatePaths(word: string): string[] {
88
+ const equals = word.indexOf("=");
89
+ return equals > 0 ? [word, word.slice(equals + 1)] : [word];
90
+ }
91
+
92
+ /**
93
+ * Returns the refusal message for a call that touches a secret, or null. Reads
94
+ * the companion's `secrets` block live, so a toggle applies to the next call.
95
+ */
96
+ export async function checkSecretAccess(
97
+ call: SecretToolCall,
98
+ scope: SecretGuardScope,
99
+ deps: SecretGuardDeps = {},
100
+ ): Promise<string | null> {
101
+ const policy = resolveSecretPolicy(scope.companionPath);
102
+ if (!policy.guardrails) return null;
103
+
104
+ if (call.kind === "shell") {
105
+ const named = shellWords(call.command)
106
+ .flatMap(candidatePaths)
107
+ .find((word) => isSecretFile(word, policy));
108
+ return named ? secretFileRefusal(named) : null;
109
+ }
110
+
111
+ if (call.kind === "search") {
112
+ const named =
113
+ call.paths.find((filePath) => filePath && isSecretFile(filePath, policy)) ??
114
+ call.globs.find((glob) => globTargetsSecretFiles(glob, policy));
115
+ return named ? secretFileRefusal(named) : null;
116
+ }
117
+
118
+ const paths = call.kind === "read" ? call.paths : call.writes.map((write) => write.path);
119
+ const secretPath = paths.find((filePath) => filePath && isSecretFile(filePath, policy));
120
+ if (secretPath) return secretFileRefusal(secretPath);
121
+ if (call.kind === "read") return null;
122
+
123
+ const scan = deps.scan ?? scanContent;
124
+ for (const write of call.writes) {
125
+ if (!write.path || !write.content || !scope.isCompanionPath(write.path)) continue;
126
+ let findings: SecretFinding[];
127
+ try {
128
+ // oxlint-disable-next-line no-await-in-loop -- the first refusal wins
129
+ findings = await scan(write.path, write.content);
130
+ } catch {
131
+ return [
132
+ "Mate guardrail: content for the Companion Repository could not be scanned for secrets, so the write is blocked.",
133
+ ` target: ${write.path}`,
134
+ SWITCH_HINT,
135
+ ].join("\n");
136
+ }
137
+ if (findings.length > 0) {
138
+ return [
139
+ "Mate guardrail: this write would put a secret into the Companion Repository.",
140
+ ` target: ${write.path}`,
141
+ ...describeSecretFindings(findings).map((line) => ` finding: ${line}`),
142
+ "Replace the value with a placeholder or the variable name. For a deliberate fake, wrap it in secretlint-disable/secretlint-enable comments.",
143
+ SWITCH_HINT,
144
+ ].join("\n");
145
+ }
146
+ }
147
+ return null;
148
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * The one secret-file pattern set every guardrail layer reads: guidance,
3
+ * projected deny rules, hook and middleware enforcement, indexer exclusion and
4
+ * the publish scan. Keeping it in one module is what stops the layers from
5
+ * disagreeing about what a secret file is.
6
+ */
7
+
8
+ export const DEFAULT_SECRET_DENY_PATTERNS: readonly string[] = [
9
+ ".env",
10
+ ".env.*",
11
+ "*.pem",
12
+ "*.key",
13
+ "*.p12",
14
+ "*.pfx",
15
+ "id_rsa",
16
+ "id_ecdsa",
17
+ "id_ed25519",
18
+ ".netrc",
19
+ ".pgpass",
20
+ ".npmrc",
21
+ ];
22
+
23
+ /** Example files that document which variables exist; they never hold values. */
24
+ export const DEFAULT_SECRET_ALLOW_PATTERNS: readonly string[] = [
25
+ ".env.example",
26
+ ".env.sample",
27
+ ".env.template",
28
+ ".env.dist",
29
+ ];
30
+
31
+ export interface SecretPolicy {
32
+ /** False only when the companion explicitly opts out. */
33
+ guardrails: boolean;
34
+ /** Defaults plus the companion's extra `secrets.deny` patterns. */
35
+ deny: string[];
36
+ /** Defaults plus the companion's extra `secrets.allow` patterns. */
37
+ allow: string[];
38
+ }
39
+
40
+ export const defaultSecretPolicy = (): SecretPolicy => ({
41
+ guardrails: true,
42
+ deny: [...DEFAULT_SECRET_DENY_PATTERNS],
43
+ allow: [...DEFAULT_SECRET_ALLOW_PATTERNS],
44
+ });
45
+
46
+ function patternList(value: unknown): string[] {
47
+ if (!Array.isArray(value)) return [];
48
+ return value.filter((entry): entry is string => typeof entry === "string" && entry.trim() !== "");
49
+ }
50
+
51
+ /**
52
+ * Parses the `secrets` block of `framework.yaml`. Anything that is not an
53
+ * object yields the defaults with guardrails on, and only a literal `false`
54
+ * turns them off: a broken config never disables protection.
55
+ */
56
+ export function parseSecretsConfig(value: unknown): SecretPolicy {
57
+ const policy = defaultSecretPolicy();
58
+ if (!value || typeof value !== "object" || Array.isArray(value)) return policy;
59
+ const block = value as Record<string, unknown>;
60
+ return {
61
+ guardrails: block.guardrails !== false,
62
+ deny: [...policy.deny, ...patternList(block.deny)],
63
+ allow: [...policy.allow, ...patternList(block.allow)],
64
+ };
65
+ }
66
+
67
+ const compiled = new Map<string, RegExp>();
68
+
69
+ /** Gitignore-style subset: `*` and `?` stay inside a segment, `**` crosses them. */
70
+ function patternRegExp(pattern: string): RegExp {
71
+ const cached = compiled.get(pattern);
72
+ if (cached) return cached;
73
+ let source = "";
74
+ const body = pattern.replace(/^\/+/, "");
75
+ for (let i = 0; i < body.length; i++) {
76
+ const char = body[i];
77
+ if (char === "*" && body[i + 1] === "*") {
78
+ // `**/` matches zero or more whole segments; a bare `**` matches anything.
79
+ if (body[i + 2] === "/") {
80
+ source += "(?:.*/)?";
81
+ i += 2;
82
+ } else {
83
+ source += ".*";
84
+ i += 1;
85
+ }
86
+ } else if (char === "*") {
87
+ source += "[^/]*";
88
+ } else if (char === "?") {
89
+ source += "[^/]";
90
+ } else {
91
+ source += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
92
+ }
93
+ }
94
+ // A slash-free pattern matches the basename; one with a slash matches the
95
+ // trailing segments of the path, since the repository root is not known here.
96
+ const regExp = body.includes("/") ? new RegExp(`(?:^|/)${source}$`) : new RegExp(`^${source}$`);
97
+ compiled.set(pattern, regExp);
98
+ return regExp;
99
+ }
100
+
101
+ function normalizePath(filePath: string): string {
102
+ return filePath.replaceAll("\\", "/").replace(/\/+$/, "");
103
+ }
104
+
105
+ export function matchesSecretPattern(filePath: string, patterns: readonly string[]): boolean {
106
+ const normalized = normalizePath(filePath);
107
+ if (!normalized) return false;
108
+ const basename = normalized.slice(normalized.lastIndexOf("/") + 1);
109
+ return patterns.some((pattern) => {
110
+ const regExp = patternRegExp(pattern);
111
+ return pattern.replace(/^\/+/, "").includes("/")
112
+ ? regExp.test(normalized)
113
+ : regExp.test(basename);
114
+ });
115
+ }
116
+
117
+ /** A path is a secret file when it matches a deny pattern and no allow pattern. */
118
+ export function isSecretFile(filePath: string, policy: SecretPolicy): boolean {
119
+ return (
120
+ matchesSecretPattern(filePath, policy.deny) && !matchesSecretPattern(filePath, policy.allow)
121
+ );
122
+ }
123
+
124
+ /**
125
+ * Narrower stand-ins for a default deny pattern that also matches an allowed
126
+ * file. Runtime deny rules are evaluated before allow rules, so `.env.*` there
127
+ * would deny `.env.example` too; these cover the common environment files.
128
+ */
129
+ const ALLOW_SAFE_EXPANSIONS: Readonly<Record<string, readonly string[]>> = {
130
+ ".env.*": [".env.local", ".env.development", ".env.production", ".env.test", ".env.*.local"],
131
+ };
132
+
133
+ /**
134
+ * The default deny patterns a runtime's own deny rules may carry: every
135
+ * default that can't match an allow-listed file, with an overlapping pattern
136
+ * replaced by its narrower stand-ins. Extra `secrets.deny` patterns are never
137
+ * projected: once removed from config, nothing would mark their rules as Mate's.
138
+ */
139
+ export function projectableSecretDenyPatterns(): string[] {
140
+ const overlapsAllow = (pattern: string) =>
141
+ DEFAULT_SECRET_ALLOW_PATTERNS.some((allowed) => matchesSecretPattern(allowed, [pattern]));
142
+ return DEFAULT_SECRET_DENY_PATTERNS.flatMap((pattern) =>
143
+ overlapsAllow(pattern) ? (ALLOW_SAFE_EXPANSIONS[pattern] ?? []) : [pattern],
144
+ ).filter((pattern) => !overlapsAllow(pattern));
145
+ }
146
+
147
+ /** Ordinary files a broad glob also matches; a glob matching one is not aimed at secrets. */
148
+ const ORDINARY_SAMPLES = ["index.ts", "README.md", "config.json"];
149
+
150
+ /**
151
+ * Whether a search glob is aimed at secret files: it matches a sample name of
152
+ * some deny pattern that the policy still treats as secret, and no ordinary
153
+ * file. `.env*` and `*.key` are; `**\/*` and `*.ts` are not. A heuristic for
154
+ * search tools, which take a glob rather than a path.
155
+ */
156
+ export function globTargetsSecretFiles(glob: string, policy: SecretPolicy): boolean {
157
+ const pattern = glob.trim();
158
+ if (!pattern) return false;
159
+ if (ORDINARY_SAMPLES.some((sample) => matchesSecretPattern(sample, [pattern]))) return false;
160
+ return policy.deny.some((deny) => {
161
+ const sample = deny.slice(deny.lastIndexOf("/") + 1).replace(/[*?]+/g, "x");
162
+ return isSecretFile(sample, policy) && matchesSecretPattern(sample, [pattern]);
163
+ });
164
+ }
@@ -0,0 +1,90 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import type { lintSource as LintSourceFn } from "@secretlint/core";
5
+ import type { SecretLintCoreConfig } from "@secretlint/types";
6
+
7
+ /** One secretlint finding. Deliberately carries no message: messages quote the value. */
8
+ export interface SecretFinding {
9
+ path: string;
10
+ line: number;
11
+ ruleId: string;
12
+ }
13
+
14
+ type LintSource = typeof LintSourceFn;
15
+
16
+ let engine: Promise<{ lintSource: LintSource; config: SecretLintCoreConfig }> | undefined;
17
+
18
+ /**
19
+ * Loaded on first use only, so hook calls that never scan don't pay for it.
20
+ * The config is fixed in memory: the recommended preset and nothing else. No
21
+ * `.secretlintrc` is ever read, so a repository can't weaken the scan.
22
+ */
23
+ function loadEngine() {
24
+ engine ??= (async () => {
25
+ const [{ lintSource }, { creator }] = await Promise.all([
26
+ import("@secretlint/core"),
27
+ import("@secretlint/secretlint-rule-preset-recommend"),
28
+ ]);
29
+ const config: SecretLintCoreConfig = {
30
+ rules: [{ id: "@secretlint/secretlint-rule-preset-recommend", rule: creator }],
31
+ };
32
+ return { lintSource, config };
33
+ })();
34
+ return engine;
35
+ }
36
+
37
+ /** Scans in-memory content. Throws when the engine fails, so callers can fail closed. */
38
+ export async function scanContent(filePath: string, content: string): Promise<SecretFinding[]> {
39
+ const { lintSource, config } = await loadEngine();
40
+ const result = await lintSource({
41
+ source: { content, filePath, contentType: "text" },
42
+ options: { config, maskSecrets: true, noPhysicFilePath: true },
43
+ });
44
+ return result.messages.map((message) => ({
45
+ path: filePath,
46
+ line: message.loc.start.line,
47
+ ruleId: message.ruleId,
48
+ }));
49
+ }
50
+
51
+ /** Every file under a path, in name order; a path that doesn't exist yields none. */
52
+ export async function listFiles(target: string): Promise<string[]> {
53
+ let stat;
54
+ try {
55
+ stat = await fs.stat(target);
56
+ } catch {
57
+ return [];
58
+ }
59
+ if (stat.isFile()) return [target];
60
+ if (!stat.isDirectory()) return [];
61
+ const entries = await fs.readdir(target, { withFileTypes: true });
62
+ const nested = await Promise.all(
63
+ entries
64
+ .toSorted((a, b) => a.name.localeCompare(b.name))
65
+ .map((entry) => listFiles(path.join(target, entry.name))),
66
+ );
67
+ return nested.flat();
68
+ }
69
+
70
+ /** Scans files and directories on disk; paths that don't exist are skipped. */
71
+ export async function scanFiles(targets: readonly string[]): Promise<SecretFinding[]> {
72
+ const findings: SecretFinding[] = [];
73
+ for (const target of targets) {
74
+ // oxlint-disable-next-line no-await-in-loop -- findings keep the caller's order
75
+ for (const file of await listFiles(target)) {
76
+ // oxlint-disable-next-line no-await-in-loop -- one file at a time bounds memory
77
+ const content = await fs.readFile(file, "utf8");
78
+ // oxlint-disable-next-line no-await-in-loop -- see above
79
+ findings.push(...(await scanContent(file, content)));
80
+ }
81
+ }
82
+ return findings;
83
+ }
84
+
85
+ /** One line per finding, for refusal messages: never the value. Line 0 means the whole file. */
86
+ export function describeSecretFindings(findings: readonly SecretFinding[]): string[] {
87
+ return findings.map(
88
+ (finding) => `${finding.path}${finding.line > 0 ? `:${finding.line}` : ""} (${finding.ruleId})`,
89
+ );
90
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The kind of session a prepare or a delivery plan answers for, shared by every
3
+ * Agent Runtime. `managed-repo`: a managed launch with a Working Repository; its
4
+ * prepare just reconciled the wrap, which the launcher asserts.
5
+ * `managed-companion`: a companion-scoped managed launch. `unmanaged`: a
6
+ * session with no launch environment.
7
+ */
8
+ export type SessionKind = "managed-repo" | "managed-companion" | "unmanaged";
9
+
10
+ /** A managed launch's kind: with a Working Repository, or companion-scoped. */
11
+ export function managedSessionKind(withRepository: boolean): SessionKind {
12
+ return withRepository ? "managed-repo" : "managed-companion";
13
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Where a heredoc starts, matched at a `<<` outside quotes: an optional `-`,
3
+ * then the terminator word, optionally quoted. Shared by the secret guard's
4
+ * shell tokenizer and the artifact guard's redirect scan, so both agree on
5
+ * what is command text and what is a body.
6
+ */
7
+ export const HEREDOC_MARKER = /^<<-?\s*(['"]?)([\w.-]+)\1/;
8
+
9
+ /** The terminators a command line opens, in order; quoted text is skipped. */
10
+ function heredocTerminators(line: string): string[] {
11
+ const terminators: string[] = [];
12
+ let quote: string | undefined;
13
+ for (let i = 0; i < line.length; i++) {
14
+ const char = line[i];
15
+ if (quote) {
16
+ if (char === quote) quote = undefined;
17
+ else if (char === "\\" && quote === '"') i++;
18
+ } else if (char === "'" || char === '"') {
19
+ quote = char;
20
+ } else if (char === "\\") {
21
+ i++;
22
+ } else if (char === "<" && line[i + 1] === "<") {
23
+ if (line[i + 2] === "<") {
24
+ i += 2;
25
+ continue;
26
+ }
27
+ const marker = HEREDOC_MARKER.exec(line.slice(i));
28
+ if (marker) {
29
+ terminators.push(marker[2]);
30
+ i += marker[0].length - 1;
31
+ } else {
32
+ i++;
33
+ }
34
+ }
35
+ }
36
+ return terminators;
37
+ }
38
+
39
+ /** A heredoc body and the line, in the stripped command, whose marker opened it. */
40
+ export interface HeredocBody {
41
+ line: number;
42
+ text: string;
43
+ }
44
+
45
+ /** The command's own lines and each heredoc body, split in one pass. */
46
+ function splitHeredocs(command: string): { kept: string[]; bodies: HeredocBody[] } {
47
+ const kept: string[] = [];
48
+ const bodies: HeredocBody[] = [];
49
+ const pending: string[] = [];
50
+ let body: string[] = [];
51
+ for (const line of command.split("\n")) {
52
+ if (pending.length > 0) {
53
+ if (line.trim() === pending[0]) {
54
+ pending.shift();
55
+ bodies.push({ line: kept.length - 1, text: body.join("\n") });
56
+ body = [];
57
+ } else {
58
+ body.push(line);
59
+ }
60
+ continue;
61
+ }
62
+ kept.push(line);
63
+ pending.push(...heredocTerminators(line));
64
+ }
65
+ if (pending.length > 0) bodies.push({ line: kept.length - 1, text: body.join("\n") });
66
+ return { kept, bodies };
67
+ }
68
+
69
+ /**
70
+ * The command with every heredoc body and terminator line removed. Marker
71
+ * lines stay: a redirect the command performs is spelled there, never in the
72
+ * body it writes. A heuristic, not a parser, like the tokenizer it mirrors.
73
+ */
74
+ export function stripHeredocBodies(command: string): string {
75
+ return splitHeredocs(command).kept.join("\n");
76
+ }
77
+
78
+ /** Every heredoc body in the command, in order; an unterminated one runs to the end. */
79
+ export function heredocBodies(command: string): HeredocBody[] {
80
+ return splitHeredocs(command).bodies;
81
+ }
@@ -9,12 +9,12 @@ import {
9
9
  copies,
10
10
  documents,
11
11
  links,
12
+ recordedMapEntries,
12
13
  manifestPath,
13
14
  parseManifest,
14
15
  parseManifestStrict,
15
16
  readManifestText,
16
17
  recordedListValues,
17
- recordedAudience,
18
18
  recordedMapKeys,
19
19
  type Manifest,
20
20
  type WrapRuntime,
@@ -22,17 +22,18 @@ import {
22
22
 
23
23
  /**
24
24
  * Wrap State: the one read model over the runtime document manifest for "which
25
- * runtimes are wrapped" and "which Claude hook groups does the wrap carry".
25
+ * runtimes are wrapped" and "which Claude hook groups and plugin folders does
26
+ * the wrap carry".
26
27
  * Mate's own plugin groups are not folded in; `isMateGuardHookGroup` owns those.
27
28
  */
28
29
  export interface WrapState {
29
30
  readonly runtimes: ReadonlySet<WrapRuntime>;
30
31
  readonly carriedClaudeHookGroups: readonly ClaudeHookGroup[];
31
32
  carriesClaudeHookGroup(group: ClaudeHookGroup): boolean;
33
+ /** Capability plugin folders the wrap enables through a `directory` marketplace, as recorded. */
34
+ readonly carriedClaudePluginDirectories: readonly string[];
32
35
  /** MCP server names the wrap declared in `.opencode/opencode.json`; user servers are not listed. */
33
36
  readonly openCodeMcpServers: readonly string[];
34
- /** The audience the checkout is wrapped for; `null` for base only. */
35
- readonly audience: string | null;
36
37
  }
37
38
 
38
39
  export const WRAP_RUNTIMES: readonly WrapRuntime[] = ["claude", "opencode"];
@@ -46,10 +47,39 @@ function wrappedFor(manifest: Manifest, runtime: WrapRuntime): boolean {
46
47
  );
47
48
  }
48
49
 
49
- function isHookGroup(value: unknown): value is ClaudeHookGroup {
50
+ /**
51
+ * The folders of the recorded `directory` marketplaces whose plugin the wrap
52
+ * also enables: one without the other loads nothing.
53
+ */
54
+ function carriedPluginDirectories(manifest: Manifest): string[] {
55
+ const marketplaces = recordedMapEntries(manifest, CLAUDE_SETTINGS_DOCUMENT, [
56
+ "extraKnownMarketplaces",
57
+ ]);
58
+ const enabled = recordedMapEntries(manifest, CLAUDE_SETTINGS_DOCUMENT, ["enabledPlugins"]);
59
+ const enabledMarketplaces = new Set(
60
+ Object.entries(enabled)
61
+ .filter(([, value]) => value === true)
62
+ .map(([id]) => id.slice(id.lastIndexOf("@") + 1)),
63
+ );
64
+ return Object.entries(marketplaces).flatMap(([name, entry]) => {
65
+ const source = isRecord(entry) ? entry.source : undefined;
66
+ return enabledMarketplaces.has(name) &&
67
+ isRecord(source) &&
68
+ source.source === "directory" &&
69
+ typeof source.path === "string"
70
+ ? [source.path]
71
+ : [];
72
+ });
73
+ }
74
+
75
+ function isRecord(value: unknown): value is Record<string, unknown> {
50
76
  return typeof value === "object" && value !== null && !Array.isArray(value);
51
77
  }
52
78
 
79
+ function isHookGroup(value: unknown): value is ClaudeHookGroup {
80
+ return isRecord(value);
81
+ }
82
+
53
83
  export function wrapStateOf(manifest: Manifest): WrapState {
54
84
  const carried = recordedListValues(manifest, CLAUDE_SETTINGS_DOCUMENT, ["hooks"]).filter(
55
85
  isHookGroup,
@@ -59,12 +89,12 @@ export function wrapStateOf(manifest: Manifest): WrapState {
59
89
  runtimes: new Set(WRAP_RUNTIMES.filter((runtime) => wrappedFor(manifest, runtime))),
60
90
  carriedClaudeHookGroups: carried,
61
91
  carriesClaudeHookGroup: (group) => carriedKeys.has(hookGroupKey(group)),
92
+ carriedClaudePluginDirectories: carriedPluginDirectories(manifest),
62
93
  openCodeMcpServers: recordedMapKeys(
63
94
  manifest,
64
95
  OPENCODE_CONFIG_DOCUMENT,
65
96
  OPENCODE_MCP_SERVERS_AT,
66
97
  ),
67
- audience: recordedAudience(manifest),
68
98
  };
69
99
  }
70
100
 
@@ -1,5 +1,7 @@
1
+ // oxlint-disable no-await-in-loop
1
2
  import crypto from "node:crypto";
2
3
  import fs from "node:fs/promises";
4
+ import path from "node:path";
3
5
 
4
6
  /**
5
7
  * Writes via a sibling temp file + `fs.rename()` so an interrupted write cannot
@@ -11,17 +13,79 @@ export async function writeAtomic(
11
13
  target: string,
12
14
  contents: string | Buffer,
13
15
  mode?: number,
16
+ ): Promise<void> {
17
+ await replaceViaTemp(target, contents, mode, false);
18
+ }
19
+
20
+ /** `exactMode`: chmod past the umask, so a replaced file keeps the mode it had. */
21
+ async function replaceViaTemp(
22
+ target: string,
23
+ contents: string | Buffer,
24
+ mode: number | undefined,
25
+ exactMode: boolean,
14
26
  ): Promise<void> {
15
27
  const tempPath = `${target}.${crypto.randomUUID()}.tmp`;
16
28
  try {
29
+ /** Created with the mode, so private contents are never readable at a wider one. */
17
30
  await fs.writeFile(
18
31
  tempPath,
19
32
  contents,
20
33
  mode === undefined ? "utf8" : { encoding: "utf8", mode },
21
34
  );
35
+ if (exactMode && mode !== undefined) await fs.chmod(tempPath, mode);
22
36
  await fs.rename(tempPath, target);
23
37
  } catch (error) {
24
38
  await fs.rm(tempPath, { force: true }).catch(() => {});
25
39
  throw error;
26
40
  }
27
41
  }
42
+
43
+ const MAX_LINK_HOPS = 40;
44
+
45
+ /**
46
+ * Follows a symlink chain to its end, which may not exist yet: a dangling link names where to create.
47
+ * Link text is never normalized lexically: `..` after a directory symlink means the real parent.
48
+ */
49
+ async function resolveLinkEnd(filePath: string): Promise<string> {
50
+ let current = filePath;
51
+ for (let hop = 0; hop < MAX_LINK_HOPS; hop++) {
52
+ const parent = await fs.realpath(path.dirname(current));
53
+ const entry = path.join(parent, path.basename(current));
54
+ const link = await fs.readlink(entry).catch((error: NodeJS.ErrnoException) => {
55
+ if (error.code === "ENOENT" || error.code === "EINVAL") return null;
56
+ throw error;
57
+ });
58
+ if (link === null) return entry;
59
+ current = path.isAbsolute(link) ? link : `${parent}${path.sep}${link}`;
60
+ }
61
+ throw Object.assign(new Error(`too many levels of symbolic links: ${filePath}`), {
62
+ code: "ELOOP",
63
+ });
64
+ }
65
+
66
+ /**
67
+ * As `writeAtomic`, through a symlink: the link stays and its target is
68
+ * replaced, or created when the link dangles. An existing file keeps its mode.
69
+ * A read-only file is refused, as a plain write would refuse it. A hardlinked
70
+ * file is written in place: replacing it would split it from its other links.
71
+ */
72
+ export async function writeAtomicThroughLink(
73
+ filePath: string,
74
+ contents: string | Buffer,
75
+ ): Promise<void> {
76
+ const target = await resolveLinkEnd(filePath);
77
+ const existing = await fs.stat(target).catch(() => null);
78
+ if (existing && existing.nlink > 1) return fs.writeFile(target, contents, "utf8");
79
+ if (existing) await fs.access(target, fs.constants.W_OK);
80
+ await replaceViaTemp(target, contents, existing ? existing.mode & 0o7777 : undefined, true);
81
+ }
82
+
83
+ /**
84
+ * Deletes a file its writer left without content. A symlink stays: deleting it
85
+ * would leave the old text in its target, so the target is emptied instead.
86
+ */
87
+ export async function removeEmptiedFile(filePath: string): Promise<void> {
88
+ const entry = await fs.lstat(filePath);
89
+ if (entry.isSymbolicLink()) await writeAtomicThroughLink(filePath, "");
90
+ else await fs.unlink(filePath);
91
+ }